diff --git a/package-lock.json b/package-lock.json index cf5182a..38bcf67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "license": "ISC", "dependencies": { "express": "4.19.2", - "js-yaml": "4.1.0" - }, - "devDependencies": {} + "js-yaml": "4.1.0", + "node-fetch": "2.7.0" + } }, "node_modules/accepts": { "version": "1.3.8", @@ -489,6 +489,25 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -727,6 +746,11 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -762,6 +786,20 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } }, "dependencies": { @@ -1112,6 +1150,14 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1275,6 +1321,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1298,6 +1349,20 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } } } diff --git a/package.json b/package.json index 51a3d7d..58195b5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "license": "ISC", "dependencies": { "express": "4.19.2", - "js-yaml": "4.1.0" + "js-yaml": "4.1.0", + "node-fetch": "2.7.0" } } diff --git a/public/app.js b/public/app.js index c894d52..fe401e4 100644 --- a/public/app.js +++ b/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(); -})(); +})(); \ No newline at end of file diff --git a/public/graph.js b/public/graph.js index c220432..e4cb0bc 100644 --- a/public/graph.js +++ b/public/graph.js @@ -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 }; } diff --git a/public/index.html b/public/index.html index cc81cb4..d678e76 100644 --- a/public/index.html +++ b/public/index.html @@ -10,12 +10,32 @@