feat: ERD for components and schemas
This commit is contained in:
@@ -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
|
||||
|
||||
285
public/graph.js
285
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,14 @@
|
||||
</div>
|
||||
<div id="right-pane">
|
||||
<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>
|
||||
<div id="graph"></div>
|
||||
|
||||
102
server.js
102
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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user