feat: ERD for components and schemas
This commit is contained in:
@@ -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
|
||||||
|
|||||||
285
public/graph.js
285
public/graph.js
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
102
server.js
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user