Files
specgraph/public/app.js
2025-10-31 15:49:53 +01:00

568 lines
19 KiB
JavaScript

(function() {
const textarea = document.getElementById('yaml-input');
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
let currentGraphData = null;
let currentERDData = null;
const graphTypeSelect = document.getElementById('graph-type-select');
const editor = CodeMirror.fromTextArea(textarea, {
mode: 'yaml',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
indentUnit: 2,
});
const sample = `openapi: 3.0.0
info:
title: httpbin sample
version: 1.0.0
servers:
- url: https://httpbin.org
paths:
/get:
get:
summary: Echo query params
parameters:
- in: query
name: q
schema: { type: string }
responses:
'200': { description: OK }
/post:
post:
summary: Echo JSON body
requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'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 }
`;
editor.setValue(sample);
function selectWholeLine(lineNumber) {
const lineText = editor.getLine(lineNumber) || '';
const from = { line: lineNumber, ch: 0 };
const to = { line: lineNumber, ch: lineText.length };
editor.setSelection(from, to);
editor.scrollIntoView({ from, to }, 100);
editor.focus();
}
function jumpToEndpointInYaml(path) {
const lineCount = editor.lineCount();
const target = `${path}:`;
for (let i = 0; i < lineCount; i++) {
const line = editor.getLine(i);
if (!line) continue;
if (line.trimStart().startsWith(target)) {
selectWholeLine(i);
return true;
}
}
return false;
}
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 allParams = (op.parameters || []).filter(p => p.in === 'path' || p.in === 'query');
const pathParamsWithSchema = pathParams.map(name => {
const param = allParams.find(p => p.in === 'path' && p.name === name);
return { name, schema: param ? (param.schema || null) : null };
});
const queryParams = (op.parameters || []).filter(p => p.in === 'query');
const row1 = document.createElement('div');
row1.className = 'row';
for (const paramInfo of pathParamsWithSchema) {
const name = paramInfo.name;
const schema = paramInfo.schema || {};
const wrap = document.createElement('div');
wrap.className = 'inline';
const label = document.createElement('label');
label.textContent = `path: ${name}`;
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const select = document.createElement('select');
select.name = `path:${name}`;
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '-- select --';
select.appendChild(emptyOpt);
for (const val of schema.enum) {
const opt = document.createElement('option');
opt.value = String(val);
opt.textContent = String(val);
select.appendChild(opt);
}
wrap.appendChild(label);
wrap.appendChild(select);
} else {
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 schema = p.schema || {};
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const select = document.createElement('select');
select.name = `query:${p.name}`;
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '-- select --';
select.appendChild(emptyOpt);
for (const val of schema.enum) {
const opt = document.createElement('option');
opt.value = String(val);
opt.textContent = String(val);
select.appendChild(opt);
}
wrap.appendChild(label);
wrap.appendChild(select);
} else {
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) {
if (Array.isArray(op.bodyFields) && op.bodyFields.length > 0) {
const label = document.createElement('label');
label.textContent = 'Body fields';
form.appendChild(label);
for (const f of op.bodyFields) {
const wrap = document.createElement('div');
wrap.className = 'inline';
const l = document.createElement('label');
l.textContent = `${f.required ? '*' : ''}${f.name}`;
if (Array.isArray(f.enum) && f.enum.length > 0) {
const select = document.createElement('select');
select.name = `body:${f.name}`;
if (!f.required) {
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '-- select --';
select.appendChild(emptyOpt);
}
for (const val of f.enum) {
const opt = document.createElement('option');
opt.value = String(val);
opt.textContent = String(val);
select.appendChild(opt);
}
wrap.appendChild(l);
wrap.appendChild(select);
} else {
const input = document.createElement('input');
input.name = `body:${f.name}`;
input.placeholder = f.type || 'string';
wrap.appendChild(l);
wrap.appendChild(input);
}
form.appendChild(wrap);
}
} else {
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) {
if (Array.isArray(op.bodyFields) && op.bodyFields.length > 0) {
const obj = {};
const isFormUrlEncoded = op.bodyContentType === 'application/x-www-form-urlencoded';
if (isFormUrlEncoded) {
// For form-urlencoded, keep it flat (no nesting)
for (const [k, v] of formData.entries()) {
if (k.startsWith('body:')) {
const name = k.slice(5);
// For form-urlencoded, only use the top-level field name (ignore dots)
const topLevelName = name.split('.')[0];
if (v !== '') obj[topLevelName] = v;
}
}
} else {
// For JSON, allow nesting via dot notation
const setDeep = (o, path, value) => {
const parts = path.split('.');
let cur = o;
for (let i = 0; i < parts.length; i++) {
const key = parts[i];
const isLast = i === parts.length - 1;
if (isLast) {
cur[key] = value;
} else {
if (typeof cur[key] !== 'object' || cur[key] === null) cur[key] = {};
cur = cur[key];
}
}
};
for (const [k, v] of formData.entries()) {
if (k.startsWith('body:')) {
const name = k.slice(5);
if (v !== '') setDeep(obj, name, v);
}
}
}
body = Object.keys(obj).length ? obj : undefined;
} else {
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, contentType: op.bodyContentType || null })
});
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 = '';
const yamlText = editor.getValue();
try {
const res = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yaml: yamlText })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data && data.error ? data.error : 'Failed to parse');
}
const { graph, erdGraph, info, servers } = data;
currentGraphData = graph;
currentERDData = erdGraph || { nodes: [], links: [] };
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);
// Render the selected graph type
renderSelectedGraph();
// Auto-switch to Form tab after successful parse
setTab('form');
} catch (e) {
errorEl.textContent = e.message || String(e);
}
}
function renderSelectedGraph() {
if (!currentGraphData && !currentERDData) return;
const graphType = graphTypeSelect ? graphTypeSelect.value : 'endpoints';
if (graphType === 'erd') {
renderERDGraph('#graph', currentERDData);
} else {
// Endpoint graph
const endpoints = currentGraphData.endpoints || [];
function onNodeClick(segmentId) {
const match = endpoints.find(ep => {
const parts = (ep.path || '').split('/').filter(Boolean);
return parts.includes(segmentId);
});
if (match) {
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;
}
}
}
}
}
}
renderGraph('#graph', currentGraphData, { onNodeClick });
}
}
if (graphTypeSelect) {
graphTypeSelect.addEventListener('change', renderSelectedGraph);
}
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();
})();