feat: render all fields in the forms

This commit is contained in:
Patrick Balsiger
2025-10-31 15:33:37 +01:00
parent 9e7505706b
commit cccc152edc
2 changed files with 240 additions and 33 deletions

View File

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

111
server.js
View File

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