From 16ef2059cd00ecdfb8f2853402950a38cabc10ec Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Fri, 31 Oct 2025 15:49:53 +0100 Subject: [PATCH] feat: ERD for components and schemas --- public/app.js | 42 +++++-- public/graph.js | 285 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 9 +- server.js | 102 ++++++++++++++++- 4 files changed, 426 insertions(+), 12 deletions(-) diff --git a/public/app.js b/public/app.js index f40f958..ebd5169 100644 --- a/public/app.js +++ b/public/app.js @@ -11,6 +11,9 @@ let currentTab = 'yaml'; const opIndexByPath = new Map(); // path -> first operation element + let currentGraphData = null; + let currentERDData = null; + const graphTypeSelect = document.getElementById('graph-type-select'); const editor = CodeMirror.fromTextArea(textarea, { mode: 'yaml', @@ -456,7 +459,10 @@ paths: throw new Error(data && data.error ? data.error : 'Failed to parse'); } - const { graph, info, servers } = data; + const { graph, erdGraph, info, servers } = data; + currentGraphData = graph; + currentERDData = erdGraph || { nodes: [], links: [] }; + if (info && (info.title || info.version)) { const title = info.title || 'Untitled'; const version = info.version ? ` v${info.version}` : ''; @@ -490,8 +496,26 @@ paths: const operations = graph.operations || []; renderOperations(operations); - // Graph rendering with node click jump - const endpoints = graph.endpoints || []; + // Render the selected graph type + renderSelectedGraph(); + + // Auto-switch to Form tab after successful parse + setTab('form'); + } catch (e) { + errorEl.textContent = e.message || String(e); + } + } + + function renderSelectedGraph() { + if (!currentGraphData && !currentERDData) return; + + const graphType = graphTypeSelect ? graphTypeSelect.value : 'endpoints'; + + if (graphType === 'erd') { + renderERDGraph('#graph', currentERDData); + } else { + // Endpoint graph + const endpoints = currentGraphData.endpoints || []; function onNodeClick(segmentId) { const match = endpoints.find(ep => { const parts = (ep.path || '').split('/').filter(Boolean); @@ -515,16 +539,14 @@ paths: } } } - - renderGraph('#graph', graph, { onNodeClick }); - - // Auto-switch to Form tab after successful parse - setTab('form'); - } catch (e) { - errorEl.textContent = e.message || String(e); + renderGraph('#graph', currentGraphData, { onNodeClick }); } } + if (graphTypeSelect) { + graphTypeSelect.addEventListener('change', renderSelectedGraph); + } + renderBtn.addEventListener('click', parseAndRender); // Auto-parse when YAML is pasted, then switch to Form tab diff --git a/public/graph.js b/public/graph.js index e4cb0bc..939a6b6 100644 --- a/public/graph.js +++ b/public/graph.js @@ -238,3 +238,288 @@ function renderGraph(containerSelector, graph, options = {}) { return { depths: depth, maxDepth }; } } + +function renderERDGraph(containerSelector, erdGraph, options = {}) { + const container = document.querySelector(containerSelector); + container.innerHTML = ''; + const width = container.clientWidth || 800; + const height = Math.max(window.innerHeight - 100, container.clientHeight || 500, 600); + const paddingX = 60; + const paddingY = 30; + + const svg = d3.select(container) + .append('svg') + .attr('viewBox', [0, 0, width, height]) + .attr('width', '100%') + .attr('height', height); + + const g = svg.append('g'); + + const zoom = d3.zoom().on('zoom', (event) => { + g.attr('transform', event.transform); + }); + svg.call(zoom); + + const links = erdGraph.links.map(d => Object.assign({}, d)); + const nodes = erdGraph.nodes.map(d => Object.assign({}, d)); + + // --- Compute clusters (connected groups) --- + // Build adjacency + const idToIndex = new Map(nodes.map((n, i) => [n.id, i])); + const parent = nodes.map((_, i) => i); + const find = (x) => (parent[x] === x ? x : (parent[x] = find(parent[x]))); + const unite = (a, b) => { a = find(a); b = find(b); if (a !== b) parent[b] = a; }; + for (const l of links) { + const si = idToIndex.get(typeof l.source === 'object' ? l.source.id : l.source); + const ti = idToIndex.get(typeof l.target === 'object' ? l.target.id : l.target); + if (si != null && ti != null) unite(si, ti); + } + // Assign group IDs + const rootToNodes = new Map(); + nodes.forEach((_, i) => { + const r = find(i); + if (!rootToNodes.has(r)) rootToNodes.set(r, []); + rootToNodes.get(r).push(i); + }); + // Collect clusters; all singletons go into a shared "isolated" cluster + const clusters = []; + const isolated = []; + for (const idxs of rootToNodes.values()) { + if (idxs.length === 1) isolated.push(idxs[0]); else clusters.push(idxs); + } + if (isolated.length) clusters.push(isolated); + + // Compute cluster centers arranged in a grid (unbounded, scales with cluster count) + const clusterCount = clusters.length || 1; + const cols = Math.ceil(Math.sqrt(clusterCount)); + const rows = Math.ceil(clusterCount / cols); + const clusterPaddingX = 280; // spacing between clusters + const clusterPaddingY = 250; + // Calculate base position from viewport center, then expand outward + const baseX = width / 2; + const baseY = height / 2; + const clusterCenters = clusters.map((_, i) => { + const gx = i % cols; + const gy = Math.floor(i / cols); + // Center the grid around the viewport center + const offsetX = (gx - (cols - 1) / 2) * clusterPaddingX; + const offsetY = (gy - (rows - 1) / 2) * clusterPaddingY; + return { + x: baseX + offsetX, + y: baseY + offsetY, + }; + }); + + // Tag nodes with cluster index + const nodeToCluster = new Map(); + clusters.forEach((idxs, ci) => idxs.forEach(ni => { nodeToCluster.set(ni, ci); })); + nodes.forEach((n, i) => { n.__cluster = nodeToCluster.get(i) ?? 0; }); + + if (nodes.length === 0) { + const text = g.append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', '#666') + .text('No schema components found in this OpenAPI spec'); + return; + } + + // Calculate node dimensions based on fields + const nodePadding = 12; + const fieldHeight = 20; + const headerHeight = 40; + const minWidth = 180; + const maxWidth = 400; + + // Helper function to measure text width + function measureText(text, fontSize, fontWeight = 'normal') { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.font = `${fontWeight} ${fontSize} Arial, sans-serif`; + return context.measureText(text).width; + } + + nodes.forEach(node => { + const fieldCount = node.fields.length; + // Calculate width needed for each field and the header + let maxFieldWidth = 0; + const headerTextWidth = measureText(node.name, '14px', 'bold'); + + node.fields.forEach(field => { + const fieldText = `${field.required ? '*' : ' '}${field.name}: ${field.type}`; + const textWidth = measureText(fieldText, '11px', 'normal'); + maxFieldWidth = Math.max(maxFieldWidth, textWidth); + }); + + // Width = max of header, longest field, or minimum, capped at maximum + node.width = Math.min(maxWidth, Math.max(minWidth, Math.max(headerTextWidth, maxFieldWidth) + nodePadding * 2)); + node.height = headerHeight + (fieldCount * fieldHeight) + nodePadding * 2; + // Initial positions near their cluster center (no viewport constraints) + const c = clusterCenters[node.__cluster] || { x: width / 2, y: height / 2 }; + node.x = c.x + (Math.random() - 0.5) * 120; + node.y = c.y + (Math.random() - 0.5) * 120; + }); + + // Create link elements + const link = g.append('g') + .attr('stroke-width', 2) + .attr('stroke', '#999') + .attr('stroke-opacity', 0.6) + .selectAll('line') + .data(links) + .enter().append('line'); + + // Create node groups + const nodeGroup = g.append('g') + .selectAll('g') + .data(nodes) + .enter().append('g') + .attr('class', 'erd-node'); + + // Draw rectangle for each node + const rect = nodeGroup.append('rect') + .attr('x', d => d.x - d.width / 2) + .attr('y', d => d.y - d.height / 2) + .attr('width', d => d.width) + .attr('height', d => d.height) + .attr('rx', 4) + .attr('fill', '#fff') + .attr('stroke', '#333') + .attr('stroke-width', 2); + + // Draw header with schema name + const header = nodeGroup.append('rect') + .attr('x', d => d.x - d.width / 2) + .attr('y', d => d.y - d.height / 2) + .attr('width', d => d.width) + .attr('height', headerHeight) + .attr('rx', 4) + .attr('ry', 4) + .attr('fill', '#4f46e5') + .attr('fill-opacity', 0.1); + + const headerText = nodeGroup.append('text') + .attr('x', d => d.x) + .attr('y', d => d.y - d.height / 2 + headerHeight / 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-weight', 'bold') + .attr('font-size', '14px') + .attr('fill', '#333') + .text(d => d.name); + + // Draw fields + const fieldsGroup = nodeGroup.selectAll('.fields-group') + .data(d => [d]) + .enter() + .append('g') + .attr('class', 'fields-group'); + + fieldsGroup.each(function(node) { + const fieldGroup = d3.select(this); + fieldGroup.selectAll('.field') + .data(node.fields) + .enter() + .append('text') + .attr('class', 'field') + .attr('x', node.x - node.width / 2 + nodePadding) + .attr('y', (d, i) => node.y - node.height / 2 + headerHeight + nodePadding + (i * fieldHeight) + fieldHeight / 2) + .attr('dominant-baseline', 'middle') + .attr('font-size', '11px') + .attr('fill', d => d.required ? '#dc2626' : '#666') + .text(d => `${d.required ? '*' : ' '}${d.name}: ${d.type}`); + }); + + // Force simulation tuned to keep entities closer and stable + const simulation = d3.forceSimulation(nodes) + .alpha(1) + .alphaDecay(0.08) // settle faster + .velocityDecay(0.6) // damp motion to prevent drifting + .force('link', d3.forceLink(links).id(d => d.id) + .distance(l => { + // Slightly shorter distance for intra-cluster, longer for inter-cluster (rare) + const sIdx = idToIndex.get(typeof l.source === 'object' ? l.source.id : l.source); + const tIdx = idToIndex.get(typeof l.target === 'object' ? l.target.id : l.target); + const same = sIdx != null && tIdx != null && (nodeToCluster.get(sIdx) === nodeToCluster.get(tIdx)); + return same ? 140 : 220; + }) + .strength(0.9)) + .force('charge', d3.forceManyBody().strength(-280)) + // Pull nodes toward their cluster center + .force('clusterX', d3.forceX(d => clusterCenters[d.__cluster]?.x || width / 2).strength(0.25)) + .force('clusterY', d3.forceY(d => clusterCenters[d.__cluster]?.y || height / 2).strength(0.25)) + .force('collision', d3.forceCollide().radius(d => Math.max(d.width, d.height) / 2 + 32).strength(1.0)); + // Removed global center force - let clusters spread naturally + + // Make nodes draggable + const dragHandler = d3.drag() + .on('start', function(event) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + event.subject.fx = event.subject.x; + event.subject.fy = event.subject.y; + }) + .on('drag', function(event) { + event.subject.fx = event.x; + event.subject.fy = event.y; + }) + .on('end', function(event) { + if (!event.active) simulation.alphaTarget(0); + event.subject.fx = null; + event.subject.fy = null; + }); + + nodeGroup.call(dragHandler); + + function ticked() { + // No viewport clamping - entities can spread freely + // Update link positions + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + // Update node positions + rect + .attr('x', d => d.x - d.width / 2) + .attr('y', d => d.y - d.height / 2); + + header + .attr('x', d => d.x - d.width / 2) + .attr('y', d => d.y - d.height / 2); + + headerText + .attr('x', d => d.x) + .attr('y', d => d.y - d.height / 2 + headerHeight / 2); + + // Update field positions + nodeGroup.selectAll('.fields-group') + .each(function(node) { + d3.select(this).selectAll('.field') + .attr('x', node.x - node.width / 2 + nodePadding) + .attr('y', (d, i) => node.y - node.height / 2 + headerHeight + nodePadding + (i * fieldHeight) + fieldHeight / 2); + }); + } + + simulation.on('tick', ticked); + + // Hover effects + nodeGroup + .on('mouseenter', function(event, d) { + link.classed('highlighted', l => { + const sourceId = typeof l.source === 'object' ? l.source.id : l.source; + const targetId = typeof l.target === 'object' ? l.target.id : l.target; + return sourceId === d.id || targetId === d.id; + }); + link.attr('stroke-width', l => { + const sourceId = typeof l.source === 'object' ? l.source.id : l.source; + const targetId = typeof l.target === 'object' ? l.target.id : l.target; + return (sourceId === d.id || targetId === d.id) ? 3 : 2; + }); + }) + .on('mouseleave', function() { + link.classed('highlighted', false); + link.attr('stroke-width', 2); + }); +} diff --git a/public/index.html b/public/index.html index d678e76..0788c6e 100644 --- a/public/index.html +++ b/public/index.html @@ -39,7 +39,14 @@
-

Endpoint Graph

+

Graph Visualization

+
+ + +
diff --git a/server.js b/server.js index dc9cf71..9ac5a08 100644 --- a/server.js +++ b/server.js @@ -151,6 +151,100 @@ function buildGraphFromPaths(pathsObject, spec) { return { nodes, links, endpoints, operations }; } +function buildERDGraph(spec) { + const components = spec.components || {}; + const schemas = components.schemas || {}; + const nodes = []; + const links = []; + const nodeMap = new Map(); + + // Create nodes for each schema + for (const [schemaName, schema] of Object.entries(schemas)) { + const resolved = resolveSchema(spec, schema) || {}; + const properties = resolved.properties || {}; + const requiredSet = new Set(Array.isArray(resolved.required) ? resolved.required : []); + + const fields = []; + for (const [propName, propSchema] of Object.entries(properties)) { + const resolvedProp = resolveSchema(spec, propSchema) || {}; + const isRequired = requiredSet.has(propName); + const propType = resolvedProp.type || 'object'; + let typeInfo = propType; + + // Handle $ref in properties + if (propSchema.$ref) { + const refPath = propSchema.$ref.replace('#/components/schemas/', ''); + typeInfo = refPath; + } else if (resolvedProp.$ref) { + const refPath = resolvedProp.$ref.replace('#/components/schemas/', ''); + typeInfo = refPath; + } else if (resolvedProp.items && resolvedProp.items.$ref) { + const refPath = resolvedProp.items.$ref.replace('#/components/schemas/', ''); + typeInfo = `[${refPath}]`; + } else if (resolvedProp.items && resolvedProp.items.type) { + typeInfo = `[${resolvedProp.items.type}]`; + } else if (Array.isArray(resolvedProp.enum)) { + typeInfo = `enum(${resolvedProp.enum.length})`; + } + + fields.push({ + name: propName, + type: typeInfo, + required: isRequired + }); + } + + nodes.push({ + id: schemaName, + name: schemaName, + fields: fields, + description: resolved.description || '' + }); + nodeMap.set(schemaName, nodes[nodes.length - 1]); + } + + // Create links based on $ref relationships + for (const node of nodes) { + for (const field of node.fields) { + // Check if field type references another schema + const typeStr = String(field.type); + if (typeStr && !typeStr.startsWith('[') && !typeStr.includes('enum') && nodeMap.has(typeStr)) { + // Check if link already exists (avoid duplicates) + const linkExists = links.some(l => + (l.source === node.id && l.target === typeStr) || + (l.target === node.id && l.source === typeStr) + ); + if (!linkExists) { + links.push({ + source: node.id, + target: typeStr, + field: field.name + }); + } + } + // Handle array types like [SchemaName] + const arrayMatch = typeStr.match(/^\[(.+)\]$/); + if (arrayMatch && nodeMap.has(arrayMatch[1])) { + const targetSchema = arrayMatch[1]; + const linkExists = links.some(l => + (l.source === node.id && l.target === targetSchema) || + (l.target === node.id && l.source === targetSchema) + ); + if (!linkExists) { + links.push({ + source: node.id, + target: targetSchema, + field: field.name, + relationship: 'array' + }); + } + } + } + } + + return { nodes, links }; +} + app.post('/api/parse', (req, res) => { try { const { yaml: yamlText } = req.body || {}; @@ -164,8 +258,14 @@ app.post('/api/parse', (req, res) => { const pathsObject = spec.paths || {}; const graph = buildGraphFromPaths(pathsObject, spec); + const erdGraph = buildERDGraph(spec); const servers = Array.isArray(spec.servers) ? spec.servers.map(s => s && s.url).filter(Boolean) : []; - res.json({ graph, info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version }, servers }); + res.json({ + graph, + erdGraph, + info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version }, + servers + }); } catch (err) { res.status(400).json({ error: err.message || 'Failed to parse YAML' }); }