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",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
355
public/app.js
355
public/app.js
@@ -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();
|
||||||
})();
|
})();
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
63
server.js
63
server.js
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user