feat: form and proxy call
This commit is contained in:
355
public/app.js
355
public/app.js
@@ -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();
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user