568 lines
19 KiB
JavaScript
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();
|
|
})(); |