feat: form and proxy call

This commit is contained in:
Patrick Balsiger
2025-10-31 11:27:44 +01:00
parent c0706c3a2b
commit e19548a205
7 changed files with 647 additions and 42 deletions

View File

@@ -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();
})();
})();

View File

@@ -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 };
}

View File

@@ -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">

View File

@@ -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; }