329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
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}`);
|
|
});
|