feat: form and proxy call

This commit is contained in:
Patrick Balsiger
2025-10-31 11:27:44 +01:00
parent c0706c3a2b
commit e19548a205
7 changed files with 647 additions and 42 deletions

71
package-lock.json generated
View File

@@ -10,9 +10,9 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "4.19.2", "express": "4.19.2",
"js-yaml": "4.1.0" "js-yaml": "4.1.0",
}, "node-fetch": "2.7.0"
"devDependencies": {} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
@@ -489,6 +489,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -727,6 +746,11 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -762,6 +786,20 @@
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
} }
}, },
"dependencies": { "dependencies": {
@@ -1112,6 +1150,14 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
}, },
"node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"object-inspect": { "object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1275,6 +1321,11 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
}, },
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"type-is": { "type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1298,6 +1349,20 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
} }
} }
} }

View File

@@ -11,6 +11,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"express": "4.19.2", "express": "4.19.2",
"js-yaml": "4.1.0" "js-yaml": "4.1.0",
"node-fetch": "2.7.0"
} }
} }

View File

@@ -3,6 +3,14 @@
const errorEl = document.getElementById('error'); const errorEl = document.getElementById('error');
const metaEl = document.getElementById('meta'); const metaEl = document.getElementById('meta');
const renderBtn = document.getElementById('render-btn'); const renderBtn = document.getElementById('render-btn');
const formTabBtn = document.getElementById('form-tab');
const serverSelect = document.getElementById('server-select');
const operationsEl = document.getElementById('operations');
const addHeaderBtn = document.getElementById('add-header-btn');
const headersListEl = document.getElementById('headers-list');
let currentTab = 'yaml';
const opIndexByPath = new Map(); // path -> first operation element
const editor = CodeMirror.fromTextArea(textarea, { const editor = CodeMirror.fromTextArea(textarea, {
mode: 'yaml', mode: 'yaml',
@@ -14,24 +22,56 @@
const sample = `openapi: 3.0.0 const sample = `openapi: 3.0.0
info: info:
title: Sample API title: httpbin sample
version: 1.0.0 version: 1.0.0
servers:
- url: https://httpbin.org
paths: paths:
/api/test/bla: /get:
get: get:
summary: Get bla summary: Echo query params
parameters:
- in: query
name: q
schema: { type: string }
responses: responses:
'200': '200': { description: OK }
description: OK /post:
/api/test/foo:
post: post:
summary: Create foo summary: Echo JSON body
requestBody:
required: false
content:
application/json:
schema:
type: object
responses: responses:
'201': '200': { description: OK }
description: Created /put:
/api/users/{id}: put:
get: summary: Echo JSON body (PUT)
summary: Get user requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'200': { description: OK }
/patch:
patch:
summary: Echo JSON body (PATCH)
requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'200': { description: OK }
/delete:
delete:
summary: Delete example
responses: responses:
'200': { description: OK } '200': { description: OK }
`; `;
@@ -60,6 +100,228 @@ paths:
return false; return false;
} }
function setTab(tab) {
currentTab = tab;
const tabs = document.querySelectorAll('.tab');
const panels = document.querySelectorAll('.tab-panel');
tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
panels.forEach(p => p.classList.toggle('hidden', p.dataset.panel !== tab));
}
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.classList.contains('disabled')) return;
setTab(btn.dataset.tab);
});
});
function buildPathParams(path) {
return (path.match(/\{([^}]+)\}/g) || []).map(m => m.slice(1, -1));
}
function addHeaderRow(name = '', value = '') {
const row = document.createElement('div');
row.className = 'header-row';
const nameInput = document.createElement('input');
nameInput.placeholder = 'Header-Name';
nameInput.value = name;
const valueInput = document.createElement('input');
valueInput.placeholder = 'value';
valueInput.value = value;
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => row.remove());
row.appendChild(nameInput);
row.appendChild(valueInput);
row.appendChild(removeBtn);
headersListEl.appendChild(row);
}
if (addHeaderBtn) {
addHeaderBtn.addEventListener('click', () => addHeaderRow());
}
function collectCustomHeaders() {
const headers = {};
headersListEl.querySelectorAll('.header-row').forEach(row => {
const [nameInput, valueInput] = row.querySelectorAll('input');
const key = (nameInput.value || '').trim();
const val = (valueInput.value || '').trim();
if (key) headers[key] = val;
});
return headers;
}
function renderOperations(operations) {
operationsEl.innerHTML = '';
opIndexByPath.clear();
for (const op of operations) {
const opEl = document.createElement('div');
opEl.className = 'operation';
opEl.dataset.path = op.path;
opEl.dataset.method = op.method;
const header = document.createElement('div');
header.className = 'op-header';
const method = document.createElement('span');
method.className = 'method';
method.textContent = op.method;
method.style.background = op.method === 'GET' ? '#d1fae5' : (op.method === 'POST' ? '#dbeafe' : (op.method === 'DELETE' ? '#fee2e2' : '#fef3c7'));
const pathSpan = document.createElement('span');
pathSpan.className = 'path';
pathSpan.textContent = op.path;
// Keep summary for below-the-URL display; do not render inline here
const summary = op.summary || '';
const chev = document.createElement('span');
chev.className = 'chev';
chev.textContent = '▸';
header.appendChild(method);
header.appendChild(pathSpan);
// Chevron at the end of the top row
header.appendChild(chev);
// Info text (operation summary) shown below the URL
if (summary) {
const info = document.createElement('div');
info.className = 'op-info';
info.textContent = summary;
header.appendChild(info);
}
const content = document.createElement('div');
content.className = 'op-content hidden';
const form = document.createElement('form');
form.className = 'op-form';
const pathParams = buildPathParams(op.path);
const queryParams = (op.parameters || []).filter(p => p.in === 'query');
const row1 = document.createElement('div');
row1.className = 'row';
for (const name of pathParams) {
const wrap = document.createElement('div');
wrap.className = 'inline';
const label = document.createElement('label');
label.textContent = `path: ${name}`;
const input = document.createElement('input');
input.name = `path:${name}`;
input.placeholder = name;
wrap.appendChild(label);
wrap.appendChild(input);
row1.appendChild(wrap);
}
for (const p of queryParams) {
const wrap = document.createElement('div');
wrap.className = 'inline';
const label = document.createElement('label');
label.textContent = `query: ${p.name}`;
const input = document.createElement('input');
input.name = `query:${p.name}`;
input.placeholder = p.name;
wrap.appendChild(label);
wrap.appendChild(input);
row1.appendChild(wrap);
}
form.appendChild(row1);
if (op.hasJsonBody) {
const label = document.createElement('label');
label.textContent = 'JSON body';
const ta = document.createElement('textarea');
ta.name = 'body';
ta.rows = 5;
ta.placeholder = '{\n \"example\": true\n}';
form.appendChild(label);
form.appendChild(ta);
}
const submit = document.createElement('button');
submit.type = 'submit';
submit.textContent = `Send ${op.method}`;
submit.style.marginTop = '8px';
form.appendChild(submit);
const result = document.createElement('pre');
result.textContent = '';
result.style.marginTop = '8px';
form.appendChild(result);
form.addEventListener('submit', async (e) => {
e.preventDefault();
const baseUrl = serverSelect.value;
if (!baseUrl) {
result.textContent = 'Select a server first.';
return;
}
try {
const formData = new FormData(form);
let finalPath = op.path;
const query = {};
for (const [k, v] of formData.entries()) {
if (k.startsWith('path:')) {
const name = k.slice(5);
finalPath = finalPath.replace(`{${name}}`, v);
} else if (k.startsWith('query:')) {
const name = k.slice(6);
if (v !== '') query[name] = v;
} else if (k === 'body') {
// handled below
}
}
let body = undefined;
if (op.hasJsonBody) {
const raw = formData.get('body');
body = raw ? JSON.parse(raw) : undefined;
}
const headers = collectCustomHeaders();
result.textContent = 'Loading...';
const resp = await fetch('/api/proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl, method: op.method, path: finalPath, query, body, headers })
});
const data = await resp.json();
result.textContent = JSON.stringify(data, null, 2);
} catch (err) {
result.textContent = `Error: ${err.message}`;
}
});
content.appendChild(form);
opEl.appendChild(header);
opEl.appendChild(content);
operationsEl.appendChild(opEl);
// default collapsed; toggle on header click
header.addEventListener('click', () => {
const isHidden = content.classList.contains('hidden');
content.classList.toggle('hidden', !isHidden);
header.querySelector('.chev').textContent = isHidden ? '▾' : '▸';
});
// index first op per path if not present
if (!opIndexByPath.has(op.path)) {
opIndexByPath.set(op.path, opEl);
}
}
}
function expandOperationByPath(path) {
const el = opIndexByPath.get(path);
if (!el) return false;
const header = el.querySelector('.op-header');
const content = el.querySelector('.op-content');
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
const chev = header.querySelector('.chev');
if (chev) chev.textContent = '▾';
}
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
return true;
}
async function parseAndRender() { async function parseAndRender() {
errorEl.textContent = ''; errorEl.textContent = '';
metaEl.textContent = ''; metaEl.textContent = '';
@@ -76,13 +338,41 @@ 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 } = data; const { graph, info, servers } = data;
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}` : '';
metaEl.textContent = `${title}${version}`; metaEl.textContent = `${title}${version}`;
} }
// Populate servers in dropdown and enable Form tab regardless
serverSelect.innerHTML = '';
formTabBtn.classList.remove('disabled');
if (Array.isArray(servers) && servers.length) {
for (const url of servers) {
const opt = document.createElement('option');
opt.value = url;
opt.textContent = url;
serverSelect.appendChild(opt);
}
serverSelect.disabled = false;
} else {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No servers in spec';
serverSelect.appendChild(opt);
serverSelect.disabled = true;
}
// Reset headers list with one empty row
headersListEl.innerHTML = '';
addHeaderRow();
// Render operations
const operations = graph.operations || [];
renderOperations(operations);
// Graph rendering with node click jump
const endpoints = graph.endpoints || []; const endpoints = graph.endpoints || [];
function onNodeClick(segmentId) { function onNodeClick(segmentId) {
const match = endpoints.find(ep => { const match = endpoints.find(ep => {
@@ -90,14 +380,18 @@ paths:
return parts.includes(segmentId); return parts.includes(segmentId);
}); });
if (match) { if (match) {
if (!jumpToEndpointInYaml(match.path)) { if (currentTab === 'form') {
// Fallback: best-effort contains check expandOperationByPath(match.path);
const lineCount = editor.lineCount(); } else {
for (let i = 0; i < lineCount; i++) { if (!jumpToEndpointInYaml(match.path)) {
const line = editor.getLine(i) || ''; // Fallback: contains check
if (line.includes(match.path)) { const lineCount = editor.lineCount();
selectWholeLine(i); for (let i = 0; i < lineCount; i++) {
break; const line = editor.getLine(i) || '';
if (line.includes(match.path)) {
selectWholeLine(i);
break;
}
} }
} }
} }
@@ -105,6 +399,9 @@ paths:
} }
renderGraph('#graph', graph, { onNodeClick }); renderGraph('#graph', graph, { onNodeClick });
// Auto-switch to Form tab after successful parse
setTab('form');
} catch (e) { } catch (e) {
errorEl.textContent = e.message || String(e); errorEl.textContent = e.message || String(e);
} }
@@ -112,6 +409,20 @@ paths:
renderBtn.addEventListener('click', parseAndRender); renderBtn.addEventListener('click', parseAndRender);
// Auto-parse when YAML is pasted, then switch to Form tab
let pasteParseTimer = null;
editor.on('change', (cm, change) => {
if (change && change.origin === 'paste') {
if (pasteParseTimer) clearTimeout(pasteParseTimer);
pasteParseTimer = setTimeout(() => {
parseAndRender();
}, 150);
}
});
// Tabs: default to YAML
setTab('yaml');
// initial render // initial render
parseAndRender(); parseAndRender();
})(); })();

View File

@@ -2,7 +2,8 @@ function renderGraph(containerSelector, graph, options = {}) {
const container = document.querySelector(containerSelector); const container = document.querySelector(containerSelector);
container.innerHTML = ''; container.innerHTML = '';
const width = container.clientWidth || 800; const width = container.clientWidth || 800;
const height = container.clientHeight || 500; // Use viewport height as baseline; allow large canvases
const height = Math.max(window.innerHeight - 100, container.clientHeight || 500, 600);
const paddingX = 60; const paddingX = 60;
const paddingY = 30; const paddingY = 30;
@@ -10,7 +11,7 @@ function renderGraph(containerSelector, graph, options = {}) {
.append('svg') .append('svg')
.attr('viewBox', [0, 0, width, height]) .attr('viewBox', [0, 0, width, height])
.attr('width', '100%') .attr('width', '100%')
.attr('height', '100%'); .attr('height', height);
const g = svg.append('g'); const g = svg.append('g');
@@ -71,15 +72,87 @@ function renderGraph(containerSelector, graph, options = {}) {
}); });
} }
// Node hover -> highlight connected links
node
.on('mouseenter', (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;
});
})
.on('mouseleave', () => {
link.classed('highlighted', false);
});
// Build adjacency map for each node (which links it's part of)
const nodeLinks = new Map();
for (const n of nodes) {
nodeLinks.set(n.id, new Set());
}
for (const l of links) {
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
nodeLinks.get(sourceId)?.add(l);
nodeLinks.get(targetId)?.add(l);
}
const sim = simulation(); const sim = simulation();
sim.nodes(nodes).on('tick', ticked); sim.nodes(nodes).on('tick', ticked);
sim.force('link').links(links); sim.force('link').links(links);
function distanceToLineSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (len2 === 0) {
const dx2 = px - x1;
const dy2 = py - y1;
return Math.sqrt(dx2 * dx2 + dy2 * dy2);
}
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
const projX = x1 + t * dx;
const projY = y1 + t * dy;
const dx2 = px - projX;
const dy2 = py - projY;
return Math.sqrt(dx2 * dx2 + dy2 * dy2);
}
function ticked() { function ticked() {
// Clamp nodes vertically to avoid drifting too far down/up // Clamp nodes vertically to avoid drifting too far down/up
const minY = paddingY + nodeRadius; const minY = paddingY + nodeRadius;
const maxY = height - paddingY - nodeRadius - labelOffset - 2; const maxY = height - paddingY - nodeRadius - labelOffset - 2;
// Push nodes away from links they don't belong to
const minDistFromLink = nodeRadius + 8; // minimum distance from unrelated links
for (const n of nodes) { for (const n of nodes) {
const connectedLinks = nodeLinks.get(n.id) || new Set();
for (const l of links) {
if (connectedLinks.has(l)) continue; // skip links this node belongs to
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
const sx = typeof l.source === 'object' ? l.source.x : (nodes.find(nn => nn.id === sourceId)?.x || 0);
const sy = typeof l.source === 'object' ? l.source.y : (nodes.find(nn => nn.id === sourceId)?.y || 0);
const tx = typeof l.target === 'object' ? l.target.x : (nodes.find(nn => nn.id === targetId)?.x || 0);
const ty = typeof l.target === 'object' ? l.target.y : (nodes.find(nn => nn.id === targetId)?.y || 0);
const dist = distanceToLineSegment(n.x, n.y, sx, sy, tx, ty);
if (dist < minDistFromLink) {
// Push node perpendicular to the link
const dx = tx - sx;
const dy = ty - sy;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const perpX = -dy / len;
const perpY = dx / len;
const push = (minDistFromLink - dist) * 0.3;
n.vx = (n.vx || 0) + perpX * push;
n.vy = (n.vy || 0) + perpY * push;
}
}
}
if (n.y < minY) n.y = minY; if (n.y < minY) n.y = minY;
else if (n.y > maxY) n.y = maxY; else if (n.y > maxY) n.y = maxY;
} }
@@ -135,22 +208,32 @@ function renderGraph(containerSelector, graph, options = {}) {
const nodeIds = new Set(nodes.map(n => n.id)); const nodeIds = new Set(nodes.map(n => n.id));
const depth = new Map(); const depth = new Map();
let maxDepth = 0; let maxDepth = 0;
// Root "/" is always at depth 0
if (nodeIds.has('/')) {
depth.set('/', 0);
}
for (const ep of endpoints) { for (const ep of endpoints) {
const p = (ep && ep.path) || ''; const p = (ep && ep.path) || '';
const segs = p.split('/').filter(Boolean); const segs = p.split('/').filter(Boolean);
// Each segment is at depth i+1 (since root "/" is at depth 0)
for (let i = 0; i < segs.length; i++) { for (let i = 0; i < segs.length; i++) {
const seg = segs[i]; const seg = segs[i];
if (!nodeIds.has(seg)) continue; if (!nodeIds.has(seg)) continue;
const segDepth = i + 1;
const current = depth.get(seg); const current = depth.get(seg);
if (current === undefined || i < current) { if (current === undefined || segDepth < current) {
depth.set(seg, i); depth.set(seg, segDepth);
} }
if (i > maxDepth) maxDepth = i; if (segDepth > maxDepth) maxDepth = segDepth;
} }
} }
// Any nodes not appearing in endpoints get depth 0 // Any nodes not appearing in endpoints get depth 1 (after root) or 0 if it's the root
for (const id of nodeIds) { for (const id of nodeIds) {
if (!depth.has(id)) depth.set(id, 0); if (!depth.has(id)) {
depth.set(id, id === '/' ? 0 : 1);
}
} }
return { depths: depth, maxDepth }; return { depths: depth, maxDepth };
} }

View File

@@ -10,12 +10,32 @@
<body> <body>
<div id="app"> <div id="app">
<div id="left-pane"> <div id="left-pane">
<div class="pane-header"> <div class="tabs">
<h2>OpenAPI YAML</h2> <button class="tab active" data-tab="yaml">OpenAPI YAML</button>
<button class="tab disabled" data-tab="form" id="form-tab">Form</button>
<div class="spacer"></div>
<button id="render-btn" title="Parse and render graph">Render</button> <button id="render-btn" title="Parse and render graph">Render</button>
</div> </div>
<textarea id="yaml-input"></textarea> <div class="tab-panels">
<div id="error" class="error" aria-live="polite"></div> <div class="tab-panel" data-panel="yaml">
<textarea id="yaml-input"></textarea>
<div id="error" class="error" aria-live="polite"></div>
</div>
<div class="tab-panel hidden" data-panel="form">
<div class="form-header">
<label for="server-select">Server:</label>
<select id="server-select"></select>
</div>
<div class="headers-bar">
<div class="headers-controls">
<span>Headers</span>
<button id="add-header-btn" type="button">Add header</button>
</div>
<div id="headers-list"></div>
</div>
<div id="operations"></div>
</div>
</div>
</div> </div>
<div id="right-pane"> <div id="right-pane">
<div class="pane-header"> <div class="pane-header">

View File

@@ -1,7 +1,7 @@
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
#app { display: flex; height: 100%; } #app { display: flex; height: 100%; }
#left-pane { flex: 0 0 33.333%; display: flex; flex-direction: column; min-width: 0; } #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; } #right-pane { flex: 1 1 66.667%; display: flex; flex-direction: column; min-width: 0; overflow: auto; }
.pane-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; } .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; } .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 { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; }
@@ -9,8 +9,74 @@ html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, Seg
#yaml-input { flex: 1; width: 100%; } #yaml-input { flex: 1; width: 100%; }
.CodeMirror { height: 100%; font-size: 13px; } .CodeMirror { height: 100%; font-size: 13px; }
#error { color: #b91c1c; padding: 6px 12px; min-height: 22px; } #error { color: #b91c1c; padding: 6px 12px; min-height: 22px; }
#graph { flex: 1; position: relative; } #graph { flex: 1; position: relative; overflow: auto; }
svg { width: 100%; height: 100%; display: block; background: #ffffff; } svg { width: 100%; display: block; background: #ffffff; }
.node circle { fill: #3b82f6; stroke: #1e40af; stroke-width: 1px; } .node circle { fill: #3b82f6; stroke: #1e40af; stroke-width: 1px; }
.node text { font-size: 12px; fill: #111827; pointer-events: none; } .node text { font-size: 12px; fill: #111827; pointer-events: none; }
.link { stroke: #9ca3af; stroke-opacity: 0.8; } .link { stroke: #9ca3af; stroke-opacity: 0.8; }
.link.highlighted { stroke: #3b82f6; stroke-opacity: 1; stroke-width: 2.5; }
/* Tabs */
.tabs { display: flex; gap: 6px; align-items: center; padding: 8px 8px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }
.tabs .spacer { flex: 1; }
.tab { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; }
.tab.active { background: #e5f0ff; border-color: #93c5fd; }
.tab.disabled { opacity: 0.5; cursor: not-allowed; }
.tab-panels { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.tab-panel { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.hidden { display: none; }
.form-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; background: #fff; }
.form-header select {
padding: 6px 8px;
padding-right: 36px; /* space for custom chevron */
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: white;
font-family: inherit;
font-size: 13px;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center; /* pushes chevron from right edge */
background-size: 12px;
}
.form-header select::-ms-expand { display: none; }
.form-header select:hover { background-color: #f9fafb; }
/* Custom headers area */
.headers-bar { padding: 8px 12px; border-bottom: 1px solid #e5e7eb; background: #fff; }
.headers-controls { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.headers-controls span { font-size: 13px; color: #374151; }
#add-header-btn { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; }
#add-header-btn:hover { background: #f3f4f6; }
#headers-list { display: flex; flex-direction: column; gap: 6px; }
.header-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; }
.header-row input { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; }
.header-row button { padding: 6px 8px; border: 1px solid #d1d5db; background: white; border-radius: 6px; cursor: pointer; }
.header-row button:hover { background: #f3f4f6; }
#operations { overflow: auto; padding: 6px 10px; display: flex; flex-direction: column; gap: 8px; }
/* Operation (collapsible) */
.operation { border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; /* allow content to define height */ }
.operation .op-header { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px 10px; cursor: pointer; user-select: none; border-bottom: 1px solid #eef2f7; }
.operation .op-header .op-info { width: 100%; margin-left: 56px; /* align under method+path */ color: #6b7280; font-size: 12px; margin-top: 2px; }
.operation .op-header .method { font-weight: 600; font-size: 12px; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; }
.operation .op-header .path { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; color: #111827; }
.operation .op-header .summary { color: #6b7280; font-size: 12px; }
.operation .op-header .chev { margin-left: auto; color: #6b7280; align-self: flex-start; order: 2; }
.operation .op-header .method { order: 1; }
.operation .op-header .path { order: 1; }
.operation .op-header .op-info { order: 3; }
.operation .op-content { padding: 8px 10px; width: 100%; box-sizing: border-box; }
.operation .row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.operation label { font-size: 12px; color: #374151; }
.operation input, .operation select, .operation textarea { width: 100%; max-width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-family: inherit; font-size: 13px; }
.operation .inline { display: flex; flex-direction: column; width: 100%; min-width: 0; }
.operation label { display: block; }
.operation button[type="submit"] { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; font-family: inherit; font-size: 13px; margin-top: 8px; }
.operation button[type="submit"]:hover { background: #f3f4f6; }
.operation pre { background: #0b1021; color: #e5e7eb; padding: 8px; border-radius: 6px; overflow: auto; max-height: 200px; }

View File

@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
const fetch = require('node-fetch');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -19,6 +20,12 @@ function buildGraphFromPaths(pathsObject) {
const nodes = []; const nodes = [];
const links = []; const links = [];
const endpoints = []; const endpoints = [];
const operations = [];
// Add root node "/"
const rootId = '/';
nodeIdSet.add(rootId);
nodes.push({ id: rootId });
for (const pathKey of Object.keys(pathsObject || {})) { for (const pathKey of Object.keys(pathsObject || {})) {
const pathItem = pathsObject[pathKey] || {}; const pathItem = pathsObject[pathKey] || {};
@@ -26,6 +33,13 @@ function buildGraphFromPaths(pathsObject) {
for (const method of Object.keys(pathItem)) { for (const method of Object.keys(pathItem)) {
const lower = method.toLowerCase(); const lower = method.toLowerCase();
if (httpMethods.has(lower)) { if (httpMethods.has(lower)) {
const op = pathItem[method] || {};
const summary = op.summary || '';
const description = op.description || '';
const opParams = [].concat(pathItem.parameters || [], op.parameters || []).filter(Boolean);
const parameters = opParams.map(p => ({ name: p.name, in: p.in, required: !!p.required, schema: p.schema || null }));
const hasJsonBody = !!(op.requestBody && op.requestBody.content && op.requestBody.content['application/json']);
operations.push({ method: lower.toUpperCase(), path: pathKey, summary, description, parameters, hasJsonBody });
endpoints.push({ method: lower.toUpperCase(), path: pathKey }); endpoints.push({ method: lower.toUpperCase(), path: pathKey });
} }
} }
@@ -41,6 +55,16 @@ function buildGraphFromPaths(pathsObject) {
} }
} }
// Link from root "/" to first segment
if (segments.length > 0) {
const firstSeg = segments[0];
const rootKey = `${rootId}|${firstSeg}`;
if (!linkKeySet.has(rootKey)) {
linkKeySet.add(rootKey);
links.push({ source: rootId, target: firstSeg });
}
}
// Create sequential links along the path // Create sequential links along the path
for (let i = 0; i < segments.length - 1; i++) { for (let i = 0; i < segments.length - 1; i++) {
const source = segments[i]; const source = segments[i];
@@ -53,7 +77,7 @@ function buildGraphFromPaths(pathsObject) {
} }
} }
return { nodes, links, endpoints }; return { nodes, links, endpoints, operations };
} }
app.post('/api/parse', (req, res) => { app.post('/api/parse', (req, res) => {
@@ -69,12 +93,47 @@ app.post('/api/parse', (req, res) => {
const pathsObject = spec.paths || {}; const pathsObject = spec.paths || {};
const graph = buildGraphFromPaths(pathsObject); const graph = buildGraphFromPaths(pathsObject);
res.json({ graph, info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version } }); 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 });
} 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' });
} }
}); });
// Simple proxy endpoint to call target server
// Body: { baseUrl, method, path, headers, query, body }
app.post('/api/proxy', async (req, res) => {
try {
const { baseUrl, method, path: relPath, headers = {}, query = {}, body } = req.body || {};
if (!baseUrl || !method || !relPath) {
return res.status(400).json({ error: 'Missing baseUrl, method, or path' });
}
const url = new URL(relPath.replace(/\s+/g, ''), baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
for (const [k, v] of Object.entries(query || {})) {
if (v !== undefined && v !== null && v !== '') url.searchParams.append(k, String(v));
}
const fetchOpts = { method: method.toUpperCase(), headers: { ...headers } };
if (body !== undefined && body !== null && method.toUpperCase() !== 'GET') {
if (!fetchOpts.headers['content-type'] && !fetchOpts.headers['Content-Type']) {
fetchOpts.headers['Content-Type'] = 'application/json';
}
fetchOpts.body = typeof body === 'string' ? body : JSON.stringify(body);
}
const upstream = await fetch(url.toString(), fetchOpts);
const contentType = upstream.headers.get('content-type') || '';
res.status(upstream.status);
if (contentType.includes('application/json')) {
const data = await upstream.json().catch(() => null);
res.json({ status: upstream.status, headers: Object.fromEntries(upstream.headers), data });
} else {
const text = await upstream.text();
res.json({ status: upstream.status, headers: Object.fromEntries(upstream.headers), data: text });
}
} catch (err) {
res.status(502).json({ error: err.message || 'Proxy request failed' });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`); console.log(`Server listening on http://localhost:${PORT}`);
}); });