feat: spec graph

This commit is contained in:
Patrick Balsiger
2025-10-31 10:41:43 +01:00
commit c0706c3a2b
8 changed files with 1726 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

1303
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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}`);
});