const express = require('express'); const path = require('path'); const yaml = require('js-yaml'); const fetch = require('node-fetch'); const app = express(); const PORT = process.env.PORT || 3000; app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json({ limit: '2mb' })); app.get('/api/health', (req, res) => { res.json({ status: 'ok' }); }); 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(); const nodes = []; const links = []; const endpoints = []; const operations = []; // Add root node "/" const rootId = '/'; nodeIdSet.add(rootId); nodes.push({ id: rootId }); for (const pathKey of Object.keys(pathsObject || {})) { const pathItem = pathsObject[pathKey] || {}; // Collect endpoints for (const method of Object.keys(pathItem)) { const lower = method.toLowerCase(); if (httpMethods.has(lower)) { const op = pathItem[method] || {}; const summary = op.summary || ''; const description = op.description || ''; const opParams = [].concat(pathItem.parameters || [], op.parameters || []).filter(Boolean); 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 }); } } // Build nodes/links from URL segments const segments = pathKey.split('/').filter(Boolean); if (segments.length === 0) continue; // Ensure nodes for (const seg of segments) { if (!nodeIdSet.has(seg)) { nodeIdSet.add(seg); nodes.push({ id: seg }); } } // Link from root "/" to first segment if (segments.length > 0) { const firstSeg = segments[0]; const rootKey = `${rootId}|${firstSeg}`; if (!linkKeySet.has(rootKey)) { linkKeySet.add(rootKey); links.push({ source: rootId, target: firstSeg }); } } // Create sequential links along the path for (let i = 0; i < segments.length - 1; i++) { const source = segments[i]; const target = segments[i + 1]; const key = `${source}|${target}`; if (!linkKeySet.has(key)) { linkKeySet.add(key); links.push({ source, target }); } } } return { nodes, links, endpoints, operations }; } function buildERDGraph(spec) { const components = spec.components || {}; const schemas = components.schemas || {}; const nodes = []; const links = []; const nodeMap = new Map(); // Create nodes for each schema for (const [schemaName, schema] of Object.entries(schemas)) { const resolved = resolveSchema(spec, schema) || {}; const properties = resolved.properties || {}; const requiredSet = new Set(Array.isArray(resolved.required) ? resolved.required : []); const fields = []; for (const [propName, propSchema] of Object.entries(properties)) { const resolvedProp = resolveSchema(spec, propSchema) || {}; const isRequired = requiredSet.has(propName); const propType = resolvedProp.type || 'object'; let typeInfo = propType; // Handle $ref in properties if (propSchema.$ref) { const refPath = propSchema.$ref.replace('#/components/schemas/', ''); typeInfo = refPath; } else if (resolvedProp.$ref) { const refPath = resolvedProp.$ref.replace('#/components/schemas/', ''); typeInfo = refPath; } else if (resolvedProp.items && resolvedProp.items.$ref) { const refPath = resolvedProp.items.$ref.replace('#/components/schemas/', ''); typeInfo = `[${refPath}]`; } else if (resolvedProp.items && resolvedProp.items.type) { typeInfo = `[${resolvedProp.items.type}]`; } else if (Array.isArray(resolvedProp.enum)) { typeInfo = `enum(${resolvedProp.enum.length})`; } fields.push({ name: propName, type: typeInfo, required: isRequired }); } nodes.push({ id: schemaName, name: schemaName, fields: fields, description: resolved.description || '' }); nodeMap.set(schemaName, nodes[nodes.length - 1]); } // Create links based on $ref relationships for (const node of nodes) { for (const field of node.fields) { // Check if field type references another schema const typeStr = String(field.type); if (typeStr && !typeStr.startsWith('[') && !typeStr.includes('enum') && nodeMap.has(typeStr)) { // Check if link already exists (avoid duplicates) const linkExists = links.some(l => (l.source === node.id && l.target === typeStr) || (l.target === node.id && l.source === typeStr) ); if (!linkExists) { links.push({ source: node.id, target: typeStr, field: field.name }); } } // Handle array types like [SchemaName] const arrayMatch = typeStr.match(/^\[(.+)\]$/); if (arrayMatch && nodeMap.has(arrayMatch[1])) { const targetSchema = arrayMatch[1]; const linkExists = links.some(l => (l.source === node.id && l.target === targetSchema) || (l.target === node.id && l.source === targetSchema) ); if (!linkExists) { links.push({ source: node.id, target: targetSchema, field: field.name, relationship: 'array' }); } } } } return { nodes, links }; } app.post('/api/parse', (req, res) => { try { const { yaml: yamlText } = req.body || {}; if (typeof yamlText !== 'string' || yamlText.trim() === '') { return res.status(400).json({ error: 'Missing yaml string in body' }); } const spec = yaml.load(yamlText); if (!spec || typeof spec !== 'object') { return res.status(400).json({ error: 'Invalid YAML content' }); } const pathsObject = spec.paths || {}; const graph = buildGraphFromPaths(pathsObject, spec); const erdGraph = buildERDGraph(spec); const servers = Array.isArray(spec.servers) ? spec.servers.map(s => s && s.url).filter(Boolean) : []; res.json({ graph, erdGraph, info: { title: spec.info && spec.info.title, version: spec.info && spec.info.version }, servers }); } catch (err) { res.status(400).json({ error: err.message || 'Failed to parse YAML' }); } }); // Simple proxy endpoint to call target server // Body: { baseUrl, method, path, headers, query, body, contentType } app.post('/api/proxy', async (req, res) => { try { 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' }); } const url = new URL(relPath.replace(/\s+/g, ''), baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'); for (const [k, v] of Object.entries(query || {})) { if (v !== undefined && v !== null && v !== '') url.searchParams.append(k, String(v)); } 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'] = 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); } } const upstream = await fetch(url.toString(), fetchOpts); const upstreamContentType = upstream.headers.get('content-type') || ''; res.status(upstream.status); if (upstreamContentType.includes('application/json')) { const data = await upstream.json().catch(() => null); res.json({ status: upstream.status, headers: Object.fromEntries(upstream.headers), data }); } else { const text = await upstream.text(); res.json({ status: upstream.status, headers: Object.fromEntries(upstream.headers), data: text }); } } catch (err) { res.status(502).json({ error: err.message || 'Proxy request failed' }); } }); app.listen(PORT, () => { console.log(`Server listening on http://localhost:${PORT}`); });