Files
specgraph/public/app.js
2025-10-31 12:13:59 +01:00

428 lines
13 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
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 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 = '';
const yamlText = editor.getValue();
try {
const res = await fetch('/api/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ yaml: yamlText })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data && data.error ? data.error : 'Failed to parse');
}
const { graph, info, 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 => {
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', graph, { onNodeClick });
// Auto-switch to Form tab after successful parse
setTab('form');
} catch (e) {
errorEl.textContent = e.message || String(e);
}
}
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();
})();