feat: ERD for components and schemas

This commit is contained in:
Patrick Balsiger
2025-10-31 15:49:53 +01:00
parent cccc152edc
commit 16ef2059cd
4 changed files with 426 additions and 12 deletions

View File

@@ -11,6 +11,9 @@
let currentTab = 'yaml'; let currentTab = 'yaml';
const opIndexByPath = new Map(); // path -> first operation element 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, { const editor = CodeMirror.fromTextArea(textarea, {
mode: 'yaml', mode: 'yaml',
@@ -456,7 +459,10 @@ paths:
throw new Error(data && data.error ? data.error : 'Failed to parse'); 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)) { if (info && (info.title || info.version)) {
const title = info.title || 'Untitled'; const title = info.title || 'Untitled';
const version = info.version ? ` v${info.version}` : ''; const version = info.version ? ` v${info.version}` : '';
@@ -490,8 +496,26 @@ paths:
const operations = graph.operations || []; const operations = graph.operations || [];
renderOperations(operations); renderOperations(operations);
// Graph rendering with node click jump // Render the selected graph type
const endpoints = graph.endpoints || []; 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) { function onNodeClick(segmentId) {
const match = endpoints.find(ep => { const match = endpoints.find(ep => {
const parts = (ep.path || '').split('/').filter(Boolean); const parts = (ep.path || '').split('/').filter(Boolean);
@@ -515,16 +539,14 @@ paths:
} }
} }
} }
renderGraph('#graph', currentGraphData, { onNodeClick });
renderGraph('#graph', graph, { onNodeClick });
// Auto-switch to Form tab after successful parse
setTab('form');
} catch (e) {
errorEl.textContent = e.message || String(e);
} }
} }
if (graphTypeSelect) {
graphTypeSelect.addEventListener('change', renderSelectedGraph);
}
renderBtn.addEventListener('click', parseAndRender); renderBtn.addEventListener('click', parseAndRender);
// Auto-parse when YAML is pasted, then switch to Form tab // Auto-parse when YAML is pasted, then switch to Form tab

View File

@@ -238,3 +238,288 @@ function renderGraph(containerSelector, graph, options = {}) {
return { depths: depth, maxDepth }; 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);
});
}

View File

@@ -39,7 +39,14 @@
</div> </div>
<div id="right-pane"> <div id="right-pane">
<div class="pane-header"> <div class="pane-header">
<h2>Endpoint Graph</h2> <h2>Graph Visualization</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<label for="graph-type-select" style="font-size: 12px; color: #666;">Type:</label>
<select id="graph-type-select" style="padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 12px;">
<option value="endpoints">Endpoint Graph</option>
<option value="erd">ERD (Schema Relationships)</option>
</select>
</div>
<div id="meta"></div> <div id="meta"></div>
</div> </div>
<div id="graph"></div> <div id="graph"></div>

102
server.js
View File

@@ -151,6 +151,100 @@ function buildGraphFromPaths(pathsObject, spec) {
return { nodes, links, endpoints, operations }; 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) => { app.post('/api/parse', (req, res) => {
try { try {
const { yaml: yamlText } = req.body || {}; const { yaml: yamlText } = req.body || {};
@@ -164,8 +258,14 @@ app.post('/api/parse', (req, res) => {
const pathsObject = spec.paths || {}; const pathsObject = spec.paths || {};
const graph = buildGraphFromPaths(pathsObject, spec); const graph = buildGraphFromPaths(pathsObject, spec);
const erdGraph = buildERDGraph(spec);
const servers = Array.isArray(spec.servers) ? spec.servers.map(s => s && s.url).filter(Boolean) : []; 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) { } catch (err) {
res.status(400).json({ error: err.message || 'Failed to parse YAML' }); res.status(400).json({ error: err.message || 'Failed to parse YAML' });
} }