feat: spec graph
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1303
package-lock.json
generated
Normal file
1303
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "specgraph",
|
||||
"version": "1.0.0",
|
||||
"description": "Vanilla JS D3 app to visualize OpenAPI endpoints",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "4.19.2",
|
||||
"js-yaml": "4.1.0"
|
||||
}
|
||||
}
|
||||
117
public/app.js
Normal file
117
public/app.js
Normal file
@@ -0,0 +1,117 @@
|
||||
(function() {
|
||||
const textarea = document.getElementById('yaml-input');
|
||||
const errorEl = document.getElementById('error');
|
||||
const metaEl = document.getElementById('meta');
|
||||
const renderBtn = document.getElementById('render-btn');
|
||||
|
||||
const editor = CodeMirror.fromTextArea(textarea, {
|
||||
mode: 'yaml',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
tabSize: 2,
|
||||
indentUnit: 2,
|
||||
});
|
||||
|
||||
const sample = `openapi: 3.0.0
|
||||
info:
|
||||
title: Sample API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/test/bla:
|
||||
get:
|
||||
summary: Get bla
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/test/foo:
|
||||
post:
|
||||
summary: Create foo
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/api/users/{id}:
|
||||
get:
|
||||
summary: Get user
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
`;
|
||||
editor.setValue(sample);
|
||||
|
||||
function selectWholeLine(lineNumber) {
|
||||
const lineText = editor.getLine(lineNumber) || '';
|
||||
const from = { line: lineNumber, ch: 0 };
|
||||
const to = { line: lineNumber, ch: lineText.length };
|
||||
editor.setSelection(from, to);
|
||||
editor.scrollIntoView({ from, to }, 100);
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
function jumpToEndpointInYaml(path) {
|
||||
const lineCount = editor.lineCount();
|
||||
const target = `${path}:`;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const line = editor.getLine(i);
|
||||
if (!line) continue;
|
||||
if (line.trimStart().startsWith(target)) {
|
||||
selectWholeLine(i);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function parseAndRender() {
|
||||
errorEl.textContent = '';
|
||||
metaEl.textContent = '';
|
||||
|
||||
const yamlText = editor.getValue();
|
||||
try {
|
||||
const res = await fetch('/api/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ yaml: yamlText })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data && data.error ? data.error : 'Failed to parse');
|
||||
}
|
||||
|
||||
const { graph, info } = data;
|
||||
if (info && (info.title || info.version)) {
|
||||
const title = info.title || 'Untitled';
|
||||
const version = info.version ? ` v${info.version}` : '';
|
||||
metaEl.textContent = `${title}${version}`;
|
||||
}
|
||||
|
||||
const endpoints = graph.endpoints || [];
|
||||
function onNodeClick(segmentId) {
|
||||
const match = endpoints.find(ep => {
|
||||
const parts = (ep.path || '').split('/').filter(Boolean);
|
||||
return parts.includes(segmentId);
|
||||
});
|
||||
if (match) {
|
||||
if (!jumpToEndpointInYaml(match.path)) {
|
||||
// Fallback: best-effort contains check
|
||||
const lineCount = editor.lineCount();
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const line = editor.getLine(i) || '';
|
||||
if (line.includes(match.path)) {
|
||||
selectWholeLine(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderGraph('#graph', graph, { onNodeClick });
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message || String(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderBtn.addEventListener('click', parseAndRender);
|
||||
|
||||
// initial render
|
||||
parseAndRender();
|
||||
})();
|
||||
157
public/graph.js
Normal file
157
public/graph.js
Normal file
@@ -0,0 +1,157 @@
|
||||
function renderGraph(containerSelector, graph, options = {}) {
|
||||
const container = document.querySelector(containerSelector);
|
||||
container.innerHTML = '';
|
||||
const width = container.clientWidth || 800;
|
||||
const height = container.clientHeight || 500;
|
||||
const paddingX = 60;
|
||||
const paddingY = 30;
|
||||
|
||||
const svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('viewBox', [0, 0, width, height])
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%');
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
const zoom = d3.zoom().on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
});
|
||||
svg.call(zoom);
|
||||
|
||||
const links = graph.links.map(d => Object.assign({}, d));
|
||||
const nodes = graph.nodes.map(d => Object.assign({}, d));
|
||||
const endpoints = Array.isArray(graph.endpoints) ? graph.endpoints : [];
|
||||
|
||||
// Visual constants
|
||||
const nodeRadius = 16;
|
||||
const labelOffset = 12; // below the circle
|
||||
|
||||
// Compute left-to-right depth strictly from path segment indices
|
||||
const { depths, maxDepth } = computeDepthsFromEndpoints(nodes, endpoints);
|
||||
const depthScale = d3.scaleLinear()
|
||||
.domain([0, Math.max(1, maxDepth)])
|
||||
.range([paddingX, Math.max(paddingX + 1, width - paddingX)]);
|
||||
|
||||
// Seed initial positions near their depth column with limited vertical jitter
|
||||
for (const n of nodes) {
|
||||
const d = depths.get(n.id) ?? 0;
|
||||
n.x = depthScale(d) + (Math.random() - 0.5) * 30;
|
||||
n.y = height / 2 + (Math.random() - 0.5) * (height * 0.3);
|
||||
}
|
||||
|
||||
const link = g.append('g')
|
||||
.attr('stroke-width', 1.5)
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter().append('line')
|
||||
.attr('class', 'link');
|
||||
|
||||
const node = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter().append('g')
|
||||
.attr('class', 'node');
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', nodeRadius)
|
||||
.call(drag(simulation()));
|
||||
|
||||
node.append('text')
|
||||
.attr('x', 0)
|
||||
.attr('y', nodeRadius + labelOffset)
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.id);
|
||||
|
||||
// Node click -> delegate to callback if provided
|
||||
if (typeof options.onNodeClick === 'function') {
|
||||
node.style('cursor', 'pointer')
|
||||
.on('click', (event, d) => {
|
||||
options.onNodeClick(d.id, d);
|
||||
});
|
||||
}
|
||||
|
||||
const sim = simulation();
|
||||
sim.nodes(nodes).on('tick', ticked);
|
||||
sim.force('link').links(links);
|
||||
|
||||
function ticked() {
|
||||
// Clamp nodes vertically to avoid drifting too far down/up
|
||||
const minY = paddingY + nodeRadius;
|
||||
const maxY = height - paddingY - nodeRadius - labelOffset - 2;
|
||||
for (const n of nodes) {
|
||||
if (n.y < minY) n.y = minY;
|
||||
else if (n.y > maxY) n.y = maxY;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
function simulation() {
|
||||
// Gentle bias left->right; organic, tree-like cohesion
|
||||
const fx = d3.forceX(d => depthScale(depths.get(d.id) ?? 0)).strength(0.55);
|
||||
const fy = d3.forceY(height / 2).strength(0.1);
|
||||
|
||||
return d3.forceSimulation()
|
||||
.velocityDecay(0.35)
|
||||
.force('link', d3.forceLink().id(d => d.id).distance(40).strength(1.1).iterations(2))
|
||||
.force('charge', d3.forceManyBody().strength(-420))
|
||||
.force('collision', d3.forceCollide().radius(nodeRadius + 50).strength(0.96))
|
||||
.force('x', fx)
|
||||
.force('y', fy);
|
||||
}
|
||||
|
||||
function drag(sim) {
|
||||
function dragstarted(event) {
|
||||
if (!event.active) sim.alphaTarget(0.3).restart();
|
||||
event.subject.fx = event.subject.x;
|
||||
event.subject.fy = event.subject.y;
|
||||
}
|
||||
|
||||
function dragged(event) {
|
||||
event.subject.fx = event.x;
|
||||
event.subject.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event) {
|
||||
if (!event.active) sim.alphaTarget(0);
|
||||
event.subject.fx = null;
|
||||
event.subject.fy = null;
|
||||
}
|
||||
|
||||
return d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended);
|
||||
}
|
||||
|
||||
function computeDepthsFromEndpoints(nodes, endpoints) {
|
||||
const nodeIds = new Set(nodes.map(n => n.id));
|
||||
const depth = new Map();
|
||||
let maxDepth = 0;
|
||||
for (const ep of endpoints) {
|
||||
const p = (ep && ep.path) || '';
|
||||
const segs = p.split('/').filter(Boolean);
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
if (!nodeIds.has(seg)) continue;
|
||||
const current = depth.get(seg);
|
||||
if (current === undefined || i < current) {
|
||||
depth.set(seg, i);
|
||||
}
|
||||
if (i > maxDepth) maxDepth = i;
|
||||
}
|
||||
}
|
||||
// Any nodes not appearing in endpoints get depth 0
|
||||
for (const id of nodeIds) {
|
||||
if (!depth.has(id)) depth.set(id, 0);
|
||||
}
|
||||
return { depths: depth, maxDepth };
|
||||
}
|
||||
}
|
||||
36
public/index.html
Normal file
36
public/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>SpecGraph - OpenAPI Endpoint Graph</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="left-pane">
|
||||
<div class="pane-header">
|
||||
<h2>OpenAPI YAML</h2>
|
||||
<button id="render-btn" title="Parse and render graph">Render</button>
|
||||
</div>
|
||||
<textarea id="yaml-input"></textarea>
|
||||
<div id="error" class="error" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="right-pane">
|
||||
<div class="pane-header">
|
||||
<h2>Endpoint Graph</h2>
|
||||
<div id="meta"></div>
|
||||
</div>
|
||||
<div id="graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
|
||||
|
||||
<script src="/graph.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
public/styles.css
Normal file
16
public/styles.css
Normal file
@@ -0,0 +1,16 @@
|
||||
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
|
||||
#app { display: flex; height: 100%; }
|
||||
#left-pane { flex: 0 0 33.333%; display: flex; flex-direction: column; min-width: 0; }
|
||||
#right-pane { flex: 1 1 66.667%; display: flex; flex-direction: column; min-width: 0; }
|
||||
.pane-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }
|
||||
.pane-header h2 { margin: 0; font-size: 16px; }
|
||||
#render-btn { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; }
|
||||
#render-btn:hover { background: #f3f4f6; }
|
||||
#yaml-input { flex: 1; width: 100%; }
|
||||
.CodeMirror { height: 100%; font-size: 13px; }
|
||||
#error { color: #b91c1c; padding: 6px 12px; min-height: 22px; }
|
||||
#graph { flex: 1; position: relative; }
|
||||
svg { width: 100%; height: 100%; display: block; background: #ffffff; }
|
||||
.node circle { fill: #3b82f6; stroke: #1e40af; stroke-width: 1px; }
|
||||
.node text { font-size: 12px; fill: #111827; pointer-events: none; }
|
||||
.link { stroke: #9ca3af; stroke-opacity: 0.8; }
|
||||
80
server.js
Normal file
80
server.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
function buildGraphFromPaths(pathsObject) {
|
||||
const httpMethods = new Set(['get','put','post','delete','options','head','patch','trace']);
|
||||
const nodeIdSet = new Set();
|
||||
const linkKeySet = new Set();
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const endpoints = [];
|
||||
|
||||
for (const pathKey of Object.keys(pathsObject || {})) {
|
||||
const pathItem = pathsObject[pathKey] || {};
|
||||
// Collect endpoints
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
const lower = method.toLowerCase();
|
||||
if (httpMethods.has(lower)) {
|
||||
endpoints.push({ method: lower.toUpperCase(), path: pathKey });
|
||||
}
|
||||
}
|
||||
// Build nodes/links from URL segments
|
||||
const segments = pathKey.split('/').filter(Boolean);
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
// Ensure nodes
|
||||
for (const seg of segments) {
|
||||
if (!nodeIdSet.has(seg)) {
|
||||
nodeIdSet.add(seg);
|
||||
nodes.push({ id: seg });
|
||||
}
|
||||
}
|
||||
|
||||
// Create sequential links along the path
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const source = segments[i];
|
||||
const target = segments[i + 1];
|
||||
const key = `${source}|${target}`;
|
||||
if (!linkKeySet.has(key)) {
|
||||
linkKeySet.add(key);
|
||||
links.push({ source, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links, endpoints };
|
||||
}
|
||||
|
||||
app.post('/api/parse', (req, res) => {
|
||||
try {
|
||||
const { yaml: yamlText } = req.body || {};
|
||||
if (typeof yamlText !== 'string' || yamlText.trim() === '') {
|
||||
return res.status(400).json({ error: 'Missing yaml string in body' });
|
||||
}
|
||||
const spec = yaml.load(yamlText);
|
||||
if (!spec || typeof spec !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid YAML content' });
|
||||
}
|
||||
|
||||
const pathsObject = spec.paths || {};
|
||||
const graph = buildGraphFromPaths(pathsObject);
|
||||
res.json({ graph, info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version } });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message || 'Failed to parse YAML' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user