feat: form and proxy call
This commit is contained in:
71
package-lock.json
generated
71
package-lock.json
generated
@@ -10,9 +10,9 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "4.19.2",
|
||||
"js-yaml": "4.1.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"js-yaml": "4.1.0",
|
||||
"node-fetch": "2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
@@ -489,6 +489,25 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -727,6 +746,11 @@
|
||||
"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": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -762,6 +786,20 @@
|
||||
"engines": {
|
||||
"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": {
|
||||
@@ -1112,6 +1150,14 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1298,6 +1349,20 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "4.19.2",
|
||||
"js-yaml": "4.1.0"
|
||||
"js-yaml": "4.1.0",
|
||||
"node-fetch": "2.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
355
public/app.js
355
public/app.js
@@ -3,6 +3,14 @@
|
||||
const errorEl = document.getElementById('error');
|
||||
const metaEl = document.getElementById('meta');
|
||||
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, {
|
||||
mode: 'yaml',
|
||||
@@ -14,24 +22,56 @@
|
||||
|
||||
const sample = `openapi: 3.0.0
|
||||
info:
|
||||
title: Sample API
|
||||
title: httpbin sample
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://httpbin.org
|
||||
paths:
|
||||
/api/test/bla:
|
||||
/get:
|
||||
get:
|
||||
summary: Get bla
|
||||
summary: Echo query params
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/api/test/foo:
|
||||
'200': { description: OK }
|
||||
/post:
|
||||
post:
|
||||
summary: Create foo
|
||||
summary: Echo JSON body
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/api/users/{id}:
|
||||
get:
|
||||
summary: Get user
|
||||
'200': { description: OK }
|
||||
/put:
|
||||
put:
|
||||
summary: Echo JSON body (PUT)
|
||||
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:
|
||||
'200': { description: OK }
|
||||
`;
|
||||
@@ -60,6 +100,228 @@ paths:
|
||||
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() {
|
||||
errorEl.textContent = '';
|
||||
metaEl.textContent = '';
|
||||
@@ -76,13 +338,41 @@ paths:
|
||||
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)) {
|
||||
const title = info.title || 'Untitled';
|
||||
const version = info.version ? ` v${info.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 || [];
|
||||
function onNodeClick(segmentId) {
|
||||
const match = endpoints.find(ep => {
|
||||
@@ -90,14 +380,18 @@ paths:
|
||||
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;
|
||||
if (currentTab === 'form') {
|
||||
expandOperationByPath(match.path);
|
||||
} else {
|
||||
if (!jumpToEndpointInYaml(match.path)) {
|
||||
// Fallback: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +399,9 @@ paths:
|
||||
}
|
||||
|
||||
renderGraph('#graph', graph, { onNodeClick });
|
||||
|
||||
// Auto-switch to Form tab after successful parse
|
||||
setTab('form');
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message || String(e);
|
||||
}
|
||||
@@ -112,6 +409,20 @@ paths:
|
||||
|
||||
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
|
||||
parseAndRender();
|
||||
})();
|
||||
})();
|
||||
@@ -2,7 +2,8 @@ function renderGraph(containerSelector, graph, options = {}) {
|
||||
const container = document.querySelector(containerSelector);
|
||||
container.innerHTML = '';
|
||||
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 paddingY = 30;
|
||||
|
||||
@@ -10,7 +11,7 @@ function renderGraph(containerSelector, graph, options = {}) {
|
||||
.append('svg')
|
||||
.attr('viewBox', [0, 0, width, height])
|
||||
.attr('width', '100%')
|
||||
.attr('height', '100%');
|
||||
.attr('height', height);
|
||||
|
||||
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();
|
||||
sim.nodes(nodes).on('tick', ticked);
|
||||
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() {
|
||||
// Clamp nodes vertically to avoid drifting too far down/up
|
||||
const minY = paddingY + nodeRadius;
|
||||
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) {
|
||||
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;
|
||||
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 depth = new Map();
|
||||
let maxDepth = 0;
|
||||
|
||||
// Root "/" is always at depth 0
|
||||
if (nodeIds.has('/')) {
|
||||
depth.set('/', 0);
|
||||
}
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const p = (ep && ep.path) || '';
|
||||
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++) {
|
||||
const seg = segs[i];
|
||||
if (!nodeIds.has(seg)) continue;
|
||||
const segDepth = i + 1;
|
||||
const current = depth.get(seg);
|
||||
if (current === undefined || i < current) {
|
||||
depth.set(seg, i);
|
||||
if (current === undefined || segDepth < current) {
|
||||
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) {
|
||||
if (!depth.has(id)) depth.set(id, 0);
|
||||
if (!depth.has(id)) {
|
||||
depth.set(id, id === '/' ? 0 : 1);
|
||||
}
|
||||
}
|
||||
return { depths: depth, maxDepth };
|
||||
}
|
||||
|
||||
@@ -10,12 +10,32 @@
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="left-pane">
|
||||
<div class="pane-header">
|
||||
<h2>OpenAPI YAML</h2>
|
||||
<div class="tabs">
|
||||
<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>
|
||||
</div>
|
||||
<textarea id="yaml-input"></textarea>
|
||||
<div id="error" class="error" aria-live="polite"></div>
|
||||
<div class="tab-panels">
|
||||
<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 id="right-pane">
|
||||
<div class="pane-header">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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; }
|
||||
#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 h2 { margin: 0; font-size: 16px; }
|
||||
#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%; }
|
||||
.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; }
|
||||
#graph { flex: 1; position: relative; overflow: auto; }
|
||||
svg { width: 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; }
|
||||
.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; }
|
||||
|
||||
63
server.js
63
server.js
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -19,6 +20,12 @@ function buildGraphFromPaths(pathsObject) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const endpoints = [];
|
||||
const operations = [];
|
||||
|
||||
// Add root node "/"
|
||||
const rootId = '/';
|
||||
nodeIdSet.add(rootId);
|
||||
nodes.push({ id: rootId });
|
||||
|
||||
for (const pathKey of Object.keys(pathsObject || {})) {
|
||||
const pathItem = pathsObject[pathKey] || {};
|
||||
@@ -26,6 +33,13 @@ function buildGraphFromPaths(pathsObject) {
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
const lower = method.toLowerCase();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
for (let i = 0; i < segments.length - 1; 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) => {
|
||||
@@ -69,12 +93,47 @@ app.post('/api/parse', (req, res) => {
|
||||
|
||||
const pathsObject = spec.paths || {};
|
||||
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) {
|
||||
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, () => {
|
||||
console.log(`Server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user