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