From cccc152edc099ccc6cf1eeccd798fe0e20646f7d Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Fri, 31 Oct 2025 15:33:37 +0100 Subject: [PATCH] feat: render all fields in the forms --- public/app.js | 162 +++++++++++++++++++++++++++++++++++++++++++------- server.js | 111 ++++++++++++++++++++++++++++++---- 2 files changed, 240 insertions(+), 33 deletions(-) diff --git a/public/app.js b/public/app.js index fe401e4..f40f958 100644 --- a/public/app.js +++ b/public/app.js @@ -196,20 +196,44 @@ paths: 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 name of pathParams) { + 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}`; - const input = document.createElement('input'); - input.name = `path:${name}`; - input.placeholder = name; - wrap.appendChild(label); - wrap.appendChild(input); + 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) { @@ -217,24 +241,79 @@ paths: 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); + 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) { - 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); + 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'); @@ -272,15 +351,54 @@ paths: } let body = undefined; if (op.hasJsonBody) { - const raw = formData.get('body'); - body = raw ? JSON.parse(raw) : undefined; + 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 }) + 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); diff --git a/server.js b/server.js index 49ffcca..dc9cf71 100644 --- a/server.js +++ b/server.js @@ -13,7 +13,62 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok' }); }); -function buildGraphFromPaths(pathsObject) { +function resolveLocalRef(spec, ref) { + if (typeof ref !== 'string' || !ref.startsWith('#/')) return null; + const parts = ref.slice(2).split('/').map(p => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let cur = spec; + for (const p of parts) { + if (cur && Object.prototype.hasOwnProperty.call(cur, p)) { + cur = cur[p]; + } else { + return null; + } + } + return cur || null; +} + +function resolveSchema(spec, schema, depth = 0) { + if (!schema || typeof schema !== 'object' || depth > 20) return schema || null; + if (schema.$ref) { + const target = resolveLocalRef(spec, schema.$ref); + if (!target) return schema; + return resolveSchema(spec, target, depth + 1); + } + // Handle allOf by merging shallowly (best-effort) + if (Array.isArray(schema.allOf)) { + return schema.allOf.reduce((acc, s) => ({ ...acc, ...resolveSchema(spec, s, depth + 1) }), {}); + } + return schema; +} + +function extractBodyFields(spec, rootSchema) { + const schema = resolveSchema(spec, rootSchema) || {}; + const fields = []; + + function walk(obj, pathPrefix = '', parentRequired = []) { + const resolved = resolveSchema(spec, obj) || {}; + if (resolved.type === 'object' || (resolved.properties && typeof resolved.properties === 'object')) { + const props = resolved.properties || {}; + const requiredSet = new Set(Array.isArray(resolved.required) ? resolved.required : parentRequired); + for (const [name, propSchema] of Object.entries(props)) { + const fullName = pathPrefix ? `${pathPrefix}.${name}` : name; + const rProp = resolveSchema(spec, propSchema) || {}; + const isRequired = requiredSet.has(name); + if ((rProp.type === 'object') || rProp.properties || rProp.$ref) { + walk(rProp, fullName, Array.from(requiredSet)); + } else { + const enumValues = Array.isArray(rProp.enum) ? rProp.enum : null; + fields.push({ name: fullName, type: rProp.type || 'string', required: !!isRequired, description: rProp.description || '', enum: enumValues }); + } + } + } + } + + walk(schema, '', []); + return fields; +} + +function buildGraphFromPaths(pathsObject, spec) { const httpMethods = new Set(['get','put','post','delete','options','head','patch','trace']); const nodeIdSet = new Set(); const linkKeySet = new Set(); @@ -37,9 +92,25 @@ function buildGraphFromPaths(pathsObject) { const summary = op.summary || ''; const description = op.description || ''; const opParams = [].concat(pathItem.parameters || [], op.parameters || []).filter(Boolean); - const parameters = opParams.map(p => ({ name: p.name, in: p.in, required: !!p.required, schema: p.schema || null })); - const hasJsonBody = !!(op.requestBody && op.requestBody.content && op.requestBody.content['application/json']); - operations.push({ method: lower.toUpperCase(), path: pathKey, summary, description, parameters, hasJsonBody }); + const parameters = opParams.map(p => { + const base = { name: p.name, in: p.in, required: !!p.required }; + const sch = p.schema ? resolveSchema(spec, p.schema) : null; + return { ...base, schema: sch || null }; + }); + const jsonContent = op.requestBody && op.requestBody.content && op.requestBody.content['application/json']; + const formContent = op.requestBody && op.requestBody.content && op.requestBody.content['application/x-www-form-urlencoded']; + const bodyContent = jsonContent || formContent; + const hasBody = !!bodyContent; + const bodyContentType = jsonContent ? 'application/json' : (formContent ? 'application/x-www-form-urlencoded' : null); + let bodyFields = []; + if (bodyContent && bodyContent.schema) { + try { + bodyFields = extractBodyFields(spec, bodyContent.schema); + } catch (_) { + bodyFields = []; + } + } + operations.push({ method: lower.toUpperCase(), path: pathKey, summary, description, parameters, hasJsonBody: hasBody, bodyFields, bodyContentType }); endpoints.push({ method: lower.toUpperCase(), path: pathKey }); } } @@ -92,7 +163,7 @@ app.post('/api/parse', (req, res) => { } const pathsObject = spec.paths || {}; - const graph = buildGraphFromPaths(pathsObject); + const graph = buildGraphFromPaths(pathsObject, spec); const servers = Array.isArray(spec.servers) ? spec.servers.map(s => s && s.url).filter(Boolean) : []; res.json({ graph, info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version }, servers }); } catch (err) { @@ -101,10 +172,10 @@ app.post('/api/parse', (req, res) => { }); // Simple proxy endpoint to call target server -// Body: { baseUrl, method, path, headers, query, body } +// Body: { baseUrl, method, path, headers, query, body, contentType } app.post('/api/proxy', async (req, res) => { try { - const { baseUrl, method, path: relPath, headers = {}, query = {}, body } = req.body || {}; + const { baseUrl, method, path: relPath, headers = {}, query = {}, body, contentType } = req.body || {}; if (!baseUrl || !method || !relPath) { return res.status(400).json({ error: 'Missing baseUrl, method, or path' }); } @@ -114,15 +185,33 @@ app.post('/api/proxy', async (req, res) => { } const fetchOpts = { method: method.toUpperCase(), headers: { ...headers } }; if (body !== undefined && body !== null && method.toUpperCase() !== 'GET') { + const contentTypeHeader = contentType || fetchOpts.headers['content-type'] || fetchOpts.headers['Content-Type'] || 'application/json'; if (!fetchOpts.headers['content-type'] && !fetchOpts.headers['Content-Type']) { - fetchOpts.headers['Content-Type'] = 'application/json'; + fetchOpts.headers['Content-Type'] = contentTypeHeader; + } + if (contentTypeHeader === 'application/x-www-form-urlencoded') { + // Flatten body object into URLSearchParams + const params = new URLSearchParams(); + const flatten = (obj, prefix = '') => { + for (const [k, v] of Object.entries(obj || {})) { + const key = prefix ? `${prefix}.${k}` : k; + if (v !== null && v !== undefined && typeof v !== 'object') { + params.append(key, String(v)); + } else if (typeof v === 'object') { + flatten(v, key); + } + } + }; + flatten(body); + fetchOpts.body = params.toString(); + } else { + fetchOpts.body = typeof body === 'string' ? body : JSON.stringify(body); } - fetchOpts.body = typeof body === 'string' ? body : JSON.stringify(body); } const upstream = await fetch(url.toString(), fetchOpts); - const contentType = upstream.headers.get('content-type') || ''; + const upstreamContentType = upstream.headers.get('content-type') || ''; res.status(upstream.status); - if (contentType.includes('application/json')) { + if (upstreamContentType.includes('application/json')) { const data = await upstream.json().catch(() => null); res.json({ status: upstream.status, headers: Object.fromEntries(upstream.headers), data }); } else {