782 lines
28 KiB
JavaScript
782 lines
28 KiB
JavaScript
import { writeFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { Graphviz } from '@hpcc-js/wasm-graphviz';
|
||
import { getOpenApiSpec } from './src/api/openapiSpec.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const outputPath = join(__dirname, 'api-graph.svg');
|
||
const dotOutputPath = join(__dirname, 'api-graph.dot');
|
||
|
||
const COLORS = {
|
||
background: '#1e2430',
|
||
tableBorder: '#3f4c5f',
|
||
headerBg: '#33415c',
|
||
headerText: '#e2e8f0',
|
||
rowBg: '#2a3242',
|
||
rowBgAlt: '#272e3d',
|
||
keyBg: '#2b3a52',
|
||
text: '#d8dee9',
|
||
muted: '#8b95a7',
|
||
clusterFill: '#202a3a',
|
||
};
|
||
|
||
const METHOD_COLORS = {
|
||
GET: { fg: '#38bdf8', bg: '#182b40' },
|
||
POST: { fg: '#4ade80', bg: '#143826' },
|
||
PUT: { fg: '#fbbf24', bg: '#3b2c12' },
|
||
PATCH: { fg: '#c084fc', bg: '#301c44' },
|
||
DELETE: { fg: '#f87171', bg: '#3a181e' },
|
||
};
|
||
|
||
const EDGE_COLORS = [
|
||
'#ff6b6b',
|
||
'#6bdf9a',
|
||
'#5aa7ff',
|
||
'#f5c15c',
|
||
'#c79bff',
|
||
'#ff9f5a',
|
||
'#5cd6d6',
|
||
'#ff7fc2',
|
||
'#8f95ff',
|
||
'#58d18a',
|
||
'#ff8f66',
|
||
'#4fc4e8',
|
||
'#a98cff',
|
||
'#ff6f6f',
|
||
'#4ecb8f',
|
||
'#5aa7e6',
|
||
'#ff7aa8',
|
||
'#e0b25a',
|
||
'#56c8c8',
|
||
'#ff5f9e',
|
||
'#ffb25a',
|
||
'#56d68a',
|
||
'#66b3ff',
|
||
'#c79bff',
|
||
];
|
||
|
||
const CLUSTERS = [
|
||
{
|
||
id: 'identity',
|
||
label: 'Identity & Access',
|
||
resources: ['Auth', 'Company', 'CompanySetting', 'Customergroup', 'Number'],
|
||
},
|
||
{
|
||
id: 'crm',
|
||
label: 'Entities & CRM',
|
||
resources: ['Entity'],
|
||
},
|
||
{
|
||
id: 'catalog',
|
||
label: 'Catalog & Products',
|
||
resources: ['Article', 'Category', 'Brand', 'Unit', 'PublicCatalog'],
|
||
},
|
||
{
|
||
id: 'properties',
|
||
label: 'Properties & Attributes',
|
||
resources: ['Property', 'Attribute'],
|
||
},
|
||
{
|
||
id: 'purchasing',
|
||
label: 'Suppliers & Purchasing',
|
||
resources: ['Supplier', 'ArticleSupplier', 'Delivery'],
|
||
},
|
||
{
|
||
id: 'sales',
|
||
label: 'Sales & Fulfillment',
|
||
resources: ['Quote', 'Order', 'Shipment', 'Shipping', 'Package'],
|
||
},
|
||
{
|
||
id: 'inventory',
|
||
label: 'Inventory & Warehousing',
|
||
resources: ['Stock', 'Ledger', 'Warehouse'],
|
||
},
|
||
{
|
||
id: 'finance',
|
||
label: 'Finance & Payments',
|
||
resources: ['Account', 'Payment'],
|
||
},
|
||
{
|
||
id: 'media',
|
||
label: 'Media & Storage',
|
||
resources: ['Image', 'Serper', 'Vfs'],
|
||
},
|
||
{
|
||
id: 'pos',
|
||
label: 'POS & Hardware',
|
||
resources: ['POSTerminal', 'Printer', 'LabelTemplate', 'POSSync'],
|
||
},
|
||
{
|
||
id: 'ai_mcp',
|
||
label: 'AI & Integrations',
|
||
resources: ['ModelConversation', 'MCP'],
|
||
},
|
||
{
|
||
id: 'system',
|
||
label: 'System & Realtime',
|
||
resources: ['Events', 'Health', 'Telemetry', 'Legend'],
|
||
},
|
||
];
|
||
|
||
function escapeHtml(text) {
|
||
return String(text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
/**
|
||
* Maps an OpenAPI endpoint to its logical API resource card.
|
||
*/
|
||
function assignResource(apiPath, method, tag) {
|
||
if (apiPath.startsWith('/auth') || apiPath.startsWith('/.well-known')) return 'Auth';
|
||
if (apiPath.startsWith('/company/setting') || apiPath.startsWith('/companysetting')) return 'CompanySetting';
|
||
if (apiPath.startsWith('/company')) return 'Company';
|
||
if (apiPath.startsWith('/customergroup')) return 'Customergroup';
|
||
if (apiPath.startsWith('/number')) return 'Number';
|
||
if (apiPath.startsWith('/entity')) return 'Entity';
|
||
if (apiPath.startsWith('/propertyname')) return 'Property';
|
||
if (apiPath.startsWith('/attributename') || apiPath.startsWith('/attributevalue')) return 'Attribute';
|
||
if (apiPath.startsWith('/article/') && apiPath.includes('/supplier')) return 'ArticleSupplier';
|
||
if (apiPath.startsWith('/article/') && apiPath.includes('/property')) return 'Property';
|
||
if (apiPath.startsWith('/article/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||
if (apiPath.startsWith('/article')) return 'Article';
|
||
if (apiPath.startsWith('/category/') && apiPath.includes('/property')) return 'Property';
|
||
if (apiPath.startsWith('/category/') && apiPath.includes('/attributevalue')) return 'Attribute';
|
||
if (apiPath.startsWith('/category')) return 'Category';
|
||
if (apiPath.startsWith('/brand')) return 'Brand';
|
||
if (apiPath.startsWith('/unit')) return 'Unit';
|
||
if (apiPath.startsWith('/public')) return 'PublicCatalog';
|
||
if (apiPath.startsWith('/supplier')) return 'Supplier';
|
||
if (apiPath.startsWith('/delivery')) return 'Delivery';
|
||
if (apiPath.startsWith('/quote')) return 'Quote';
|
||
if (apiPath.startsWith('/order')) return 'Order';
|
||
if (apiPath.startsWith('/shipment')) return 'Shipment';
|
||
if (apiPath.startsWith('/shipping')) return 'Shipping';
|
||
if (apiPath.startsWith('/package')) return 'Package';
|
||
if (apiPath.startsWith('/stock')) return 'Stock';
|
||
if (apiPath.startsWith('/ledger')) return 'Ledger';
|
||
if (apiPath.startsWith('/warehouse')) return 'Warehouse';
|
||
if (apiPath.startsWith('/account')) return 'Account';
|
||
if (apiPath.startsWith('/payment')) return 'Payment';
|
||
if (apiPath.startsWith('/image') || apiPath.startsWith('/thumb') || apiPath.startsWith('/pic')) return 'Image';
|
||
if (apiPath.startsWith('/serper')) return 'Serper';
|
||
if (apiPath.startsWith('/vfs')) return 'Vfs';
|
||
if (apiPath.startsWith('/posterminal')) return 'POSTerminal';
|
||
if (apiPath.startsWith('/printer')) return 'Printer';
|
||
if (apiPath.startsWith('/labeltemplate')) return 'LabelTemplate';
|
||
if (apiPath.startsWith('/v1')) return 'POSSync';
|
||
if (apiPath.startsWith('/modelconversation')) return 'ModelConversation';
|
||
if (apiPath.startsWith('/mcp')) return 'MCP';
|
||
if (apiPath.startsWith('/events')) return 'Events';
|
||
if (apiPath.startsWith('/health')) return 'Health';
|
||
if (apiPath.startsWith('/telemetry')) return 'Telemetry';
|
||
|
||
return tag || 'Other';
|
||
}
|
||
|
||
function getResourceBasePath(resourceId) {
|
||
const map = {
|
||
Auth: '/auth',
|
||
Company: '/company',
|
||
CompanySetting: '/companysetting',
|
||
Customergroup: '/customergroup',
|
||
Number: '/number',
|
||
Entity: '/entity',
|
||
Article: '/article',
|
||
Category: '/category',
|
||
Brand: '/brand',
|
||
Unit: '/unit',
|
||
PublicCatalog: '/public',
|
||
Property: '/propertyname',
|
||
Attribute: '/attributename',
|
||
Supplier: '/supplier',
|
||
ArticleSupplier: '/article/{sku}/supplier',
|
||
Delivery: '/delivery',
|
||
Quote: '/quote',
|
||
Order: '/order',
|
||
Shipment: '/shipment',
|
||
Shipping: '/shipping',
|
||
Package: '/package',
|
||
Stock: '/stock',
|
||
Ledger: '/ledger',
|
||
Warehouse: '/warehouse',
|
||
Account: '/account',
|
||
Payment: '/payment',
|
||
Image: '/image',
|
||
Serper: '/serper',
|
||
Vfs: '/vfs',
|
||
POSTerminal: '/posterminal',
|
||
Printer: '/printer',
|
||
LabelTemplate: '/labeltemplate',
|
||
POSSync: '/v1',
|
||
ModelConversation: '/modelconversation',
|
||
MCP: '/mcp',
|
||
Events: '/events',
|
||
Health: '/health',
|
||
Telemetry: '/telemetry',
|
||
Legend: '',
|
||
};
|
||
return map[resourceId] ?? `/${resourceId.toLowerCase()}`;
|
||
}
|
||
|
||
function cleanSubpath(apiPath, basePath) {
|
||
if (apiPath === basePath) return '/';
|
||
if (apiPath.startsWith(basePath)) {
|
||
const sub = apiPath.slice(basePath.length);
|
||
return sub.startsWith('/') ? sub : `/${sub}`;
|
||
}
|
||
return apiPath;
|
||
}
|
||
|
||
function truncate(str, max = 28) {
|
||
if (!str) return '';
|
||
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
||
}
|
||
|
||
function sanitizePort(name) {
|
||
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
||
}
|
||
|
||
function parseApi(spec) {
|
||
const resources = new Map();
|
||
|
||
for (const [apiPath, pathItem] of Object.entries(spec.paths || {})) {
|
||
for (const [method, op] of Object.entries(pathItem || {})) {
|
||
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) continue;
|
||
if (!op || typeof op !== 'object') continue;
|
||
|
||
const methodUpper = method.toUpperCase();
|
||
const tag = Array.isArray(op.tags) && op.tags[0] ? op.tags[0] : '';
|
||
const resourceId = assignResource(apiPath, methodUpper, tag);
|
||
const basePath = getResourceBasePath(resourceId);
|
||
|
||
if (!resources.has(resourceId)) {
|
||
resources.set(resourceId, {
|
||
id: resourceId,
|
||
basePath,
|
||
endpoints: [],
|
||
});
|
||
}
|
||
|
||
const res = resources.get(resourceId);
|
||
const subpath = cleanSubpath(apiPath, basePath);
|
||
const summary = op.summary || op.description || '';
|
||
const portId = sanitizePort(`ep_${methodUpper.toLowerCase()}_${apiPath}`);
|
||
|
||
res.endpoints.push({
|
||
method: methodUpper,
|
||
path: apiPath,
|
||
subpath,
|
||
portId,
|
||
summary: truncate(summary, 28),
|
||
operation: op,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort endpoints within each resource: root subpath first, then by method rank, then path
|
||
const methodRank = { GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5 };
|
||
for (const res of resources.values()) {
|
||
res.endpoints.sort((a, b) => {
|
||
if (a.subpath === '/' && b.subpath !== '/') return -1;
|
||
if (b.subpath === '/' && a.subpath !== '/') return 1;
|
||
const rankDiff = (methodRank[a.method] ?? 99) - (methodRank[b.method] ?? 99);
|
||
if (rankDiff !== 0) return rankDiff;
|
||
return a.subpath.localeCompare(b.subpath);
|
||
});
|
||
}
|
||
|
||
return resources;
|
||
}
|
||
|
||
function findEndpointPort(res, method, pathSnippet) {
|
||
if (!res || !res.endpoints) return null;
|
||
const match = res.endpoints.find((ep) => {
|
||
if (method && ep.method !== method.toUpperCase()) return false;
|
||
if (pathSnippet) {
|
||
return ep.path.includes(pathSnippet) || ep.subpath.includes(pathSnippet);
|
||
}
|
||
return true;
|
||
});
|
||
return match?.portId ?? null;
|
||
}
|
||
|
||
/**
|
||
* Builds high-fidelity relationships between API resources.
|
||
*/
|
||
function buildApiEdges(resources) {
|
||
const edgeDefs = [
|
||
// Sales & Fulfillment
|
||
{ from: 'Order', to: 'Entity', label: 'entityId', method: 'GET', path: '/order/{id}' },
|
||
{ from: 'Order', to: 'Article', label: 'lines.sku', method: 'POST', path: '/order' },
|
||
{ from: 'Order', to: 'Shipment', label: 'shipments', method: 'GET', path: '/order/{id}' },
|
||
{ from: 'Order', to: 'Payment', label: 'payments', method: 'GET', path: '/order/{id}' },
|
||
{ from: 'Order', to: 'Shipping', label: 'shippings', method: 'GET', path: '/order/{id}' },
|
||
{ from: 'Quote', to: 'Entity', label: 'entityId', method: 'GET', path: '/quote/{id}' },
|
||
{ from: 'Quote', to: 'Article', label: 'lines.sku', method: 'POST', path: '/quote' },
|
||
{ from: 'Quote', to: 'Order', label: 'convert order', method: 'POST', path: '/quote' },
|
||
{ from: 'Shipment', to: 'Order', label: 'manifestId', method: 'GET', path: '/shipment/{id}' },
|
||
{ from: 'Shipment', to: 'Article', label: 'lines.sku', method: 'GET', path: '/shipment/{id}' },
|
||
{ from: 'Shipment', to: 'Shipping', label: 'shippings', method: 'GET', path: '/shipment/{id}/shipping' },
|
||
{ from: 'Shipping', to: 'Package', label: 'packages', method: 'GET', path: '/shipping/{id}' },
|
||
{ from: 'Package', to: 'Shipping', label: 'shippingId', method: 'POST', path: '/package' },
|
||
{ from: 'Package', to: 'Entity', label: 'addressId', method: 'POST', path: '/package' },
|
||
|
||
// Purchasing & Goods receipt
|
||
{ from: 'Delivery', to: 'Supplier', label: 'supplierId', method: 'POST', path: '/delivery' },
|
||
{ from: 'Delivery', to: 'Article', label: 'lines.sku', method: 'POST', path: '/delivery' },
|
||
{ from: 'Delivery', to: 'Ledger', label: 'ledger', method: 'GET', path: '/delivery/{id}' },
|
||
{ from: 'ArticleSupplier', to: 'Supplier', label: 'entityId', method: 'POST', path: '/supplier' },
|
||
{ from: 'ArticleSupplier', to: 'Article', label: 'sku', method: 'GET', path: '/supplier' },
|
||
{ from: 'Supplier', to: 'Entity', label: 'entityId', method: 'GET', path: '/supplier/{id}' },
|
||
|
||
// Inventory & Warehousing
|
||
{ from: 'Stock', to: 'Article', label: 'sku', method: 'POST', path: '/stock' },
|
||
{ from: 'Stock', to: 'Warehouse', label: 'warehouseId', method: 'POST', path: '/stock' },
|
||
{ from: 'Stock', to: 'Ledger', label: 'ledger', method: 'POST', path: '/stock' },
|
||
{ from: 'Ledger', to: 'Article', label: 'sku', method: 'GET', path: '/ledger' },
|
||
{ from: 'Ledger', to: 'Warehouse', label: 'warehouseId', method: 'GET', path: '/ledger' },
|
||
|
||
// Catalog & Products
|
||
{ from: 'Article', to: 'Category', label: 'categoryId', method: 'POST', path: '/article' },
|
||
{ from: 'Article', to: 'Brand', label: 'brand', method: 'PUT', path: '/brand' },
|
||
{ from: 'Article', to: 'Unit', label: 'unitSi', method: 'PATCH', path: '/article/{sku}' },
|
||
{ from: 'Article', to: 'ArticleSupplier', label: 'suppliers', method: 'GET', path: '/article/{sku}' },
|
||
{ from: 'Article', to: 'Image', label: 'photos', method: 'POST', path: '/image' },
|
||
{ from: 'Article', to: 'Customergroup', label: 'prices', method: 'PUT', path: '/prices' },
|
||
{ from: 'Article', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||
{ from: 'Article', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||
{ from: 'Article', to: 'Article', label: 'bundles & variants', method: 'GET', path: '/children' },
|
||
|
||
{ from: 'Category', to: 'Category', label: 'parentId', method: 'GET', path: '/category' },
|
||
{ from: 'Category', to: 'Article', label: 'articles', method: 'GET', path: '/category/{id}/article' },
|
||
{ from: 'Category', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||
{ from: 'Category', to: 'Property', label: 'properties', method: 'GET', path: '/property' },
|
||
{ from: 'Category', to: 'Attribute', label: 'attributes', method: 'GET', path: '/attributevalue' },
|
||
|
||
{ from: 'Brand', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||
|
||
// Properties & Attributes
|
||
{ from: 'Property', to: 'Company', label: 'companyId', method: 'GET', path: '/propertyname' },
|
||
{ from: 'Attribute', to: 'Image', label: 'image', method: 'POST', path: '/image' },
|
||
|
||
// Finance & Payments
|
||
{ from: 'Payment', to: 'Account', label: 'accountId', method: 'POST', path: '/payment' },
|
||
{ from: 'Payment', to: 'Order', label: 'manifestId', method: 'POST', path: '/payment' },
|
||
{ from: 'Account', to: 'Company', label: 'companyId', method: 'GET', path: '/account' },
|
||
|
||
// CRM & Company
|
||
{ from: 'Entity', to: 'Customergroup', label: 'customergroupId', method: 'POST', path: '/entity' },
|
||
{ from: 'Company', to: 'Customergroup', label: 'poscustomergroup', method: 'GET', path: '/company/{id}' },
|
||
{ from: 'Company', to: 'Category', label: 'root_category', method: 'GET', path: '/company/{id}' },
|
||
{ from: 'Company', to: 'CompanySetting', label: 'settings', method: 'GET', path: '/company/{id}' },
|
||
{ from: 'Number', to: 'Company', label: 'companyId', method: 'GET', path: '/number' },
|
||
|
||
// POS & Hardware
|
||
{ from: 'POSTerminal', to: 'Company', label: 'companyId', method: 'GET', path: '/posterminal' },
|
||
{ from: 'POSTerminal', to: 'Category', label: 'root_category', method: 'GET', path: '/posterminal' },
|
||
{ from: 'Printer', to: 'Company', label: 'companyId', method: 'GET', path: '/printer' },
|
||
{ from: 'Printer', to: 'LabelTemplate', label: 'templates', method: 'GET', path: '/printer' },
|
||
{ from: 'LabelTemplate', to: 'Company', label: 'companyId', method: 'GET', path: '/labeltemplate' },
|
||
{ from: 'POSSync', to: 'POSTerminal', label: 'client bind', method: 'POST', path: '/client' },
|
||
{ from: 'POSSync', to: 'Article', label: 'product sync', method: 'GET', path: '/product' },
|
||
{ from: 'POSSync', to: 'Category', label: 'category sync', method: 'GET', path: '/category' },
|
||
|
||
// AI, Tools & Public
|
||
{ from: 'PublicCatalog', to: 'Article', label: 'articles', method: 'GET', path: '/product' },
|
||
{ from: 'PublicCatalog', to: 'Category', label: 'category', method: 'GET', path: '/category' },
|
||
{ from: 'ModelConversation', to: 'Vfs', label: 'vfs workspace', method: 'GET', path: '/modelconversation' },
|
||
{ from: 'MCP', to: 'Article', label: 'article tools', method: 'POST', path: '/mcp' },
|
||
{ from: 'MCP', to: 'Order', label: 'order tools', method: 'POST', path: '/mcp' },
|
||
{ from: 'Events', to: 'Stock', label: 'stock sse', method: 'GET', path: '/events' },
|
||
{ from: 'Events', to: 'Ledger', label: 'ledger sse', method: 'GET', path: '/events' },
|
||
{ from: 'Events', to: 'Article', label: 'article sse', method: 'GET', path: '/events' },
|
||
];
|
||
|
||
const edges = [];
|
||
for (const def of edgeDefs) {
|
||
if (!resources.has(def.from) || !resources.has(def.to)) continue;
|
||
const resFrom = resources.get(def.from);
|
||
const resolvedPort = findEndpointPort(resFrom, def.method, def.path);
|
||
|
||
edges.push({
|
||
from: def.from,
|
||
to: def.to,
|
||
label: def.label,
|
||
port: resolvedPort,
|
||
tooltip: `${def.from} → ${def.to} (${def.label})`,
|
||
inferred: false,
|
||
});
|
||
}
|
||
|
||
return edges;
|
||
}
|
||
|
||
function edgeKey(edge) {
|
||
return `${edge.from}|${edge.to}|${edge.label}`;
|
||
}
|
||
|
||
function assignEdgeColors(edges) {
|
||
const colors = new Map();
|
||
let next = 0;
|
||
for (const edge of edges) {
|
||
const key = edgeKey(edge);
|
||
if (!colors.has(key)) {
|
||
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
|
||
next++;
|
||
}
|
||
}
|
||
return colors;
|
||
}
|
||
|
||
/**
|
||
* Spanning ranking forest: only one primary outgoing edge per resource
|
||
* is used for rank constraints. Other edges get constraint=false to avoid
|
||
* vertical over-stretching.
|
||
*/
|
||
function selectRankingEdgeKeys(edges) {
|
||
const ranking = new Set();
|
||
const bySource = new Map();
|
||
|
||
for (const edge of edges) {
|
||
if (edge.from === edge.to) continue;
|
||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||
bySource.get(edge.from).push(edge);
|
||
}
|
||
|
||
for (const group of bySource.values()) {
|
||
group.sort((a, b) => a.label.localeCompare(b.label));
|
||
ranking.add(edgeKey(group[0]));
|
||
}
|
||
|
||
return ranking;
|
||
}
|
||
|
||
function buildLegendLabel() {
|
||
const methodRow = (method, desc) => {
|
||
const c = METHOD_COLORS[method];
|
||
return (
|
||
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="38">` +
|
||
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${method}</B></FONT></TD>` +
|
||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
|
||
);
|
||
};
|
||
const row = (swatch, text) =>
|
||
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
|
||
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
|
||
|
||
return (
|
||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
|
||
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
|
||
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
|
||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>API Graph Legend</B></FONT></TD></TR>` +
|
||
methodRow('GET', 'Safe retrieval / query') +
|
||
methodRow('POST', 'Create resource / action') +
|
||
methodRow('PUT', 'Full replace / collection update') +
|
||
methodRow('PATCH', 'Partial entity update') +
|
||
methodRow('DELETE', 'Remove resource') +
|
||
row(
|
||
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
|
||
'Inbound arrival port on resource'
|
||
) +
|
||
row(
|
||
`<FONT COLOR="#5aa7ff">▪—▶</FONT>`,
|
||
'Relational reference / dataflow'
|
||
) +
|
||
`</TABLE>>`
|
||
);
|
||
}
|
||
|
||
function renderResourceNode(res, inboundCount = 0) {
|
||
if (res.id === 'Legend') {
|
||
return buildLegendLabel();
|
||
}
|
||
|
||
const titleFont =
|
||
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(res.id)}</B></FONT>` +
|
||
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(res.basePath)}</FONT>`;
|
||
|
||
let headerInner;
|
||
if (inboundCount > 0) {
|
||
const portCells = [];
|
||
for (let i = 0; i < inboundCount; i++) {
|
||
portCells.push(
|
||
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
|
||
);
|
||
}
|
||
headerInner =
|
||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||
`<TR>${portCells.join('')}</TR>` +
|
||
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
|
||
`</TABLE>`;
|
||
} else {
|
||
headerInner = titleFont;
|
||
}
|
||
|
||
const rows = [
|
||
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
|
||
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
|
||
];
|
||
|
||
for (const [i, ep] of res.endpoints.entries()) {
|
||
const mColor = METHOD_COLORS[ep.method] ?? { fg: COLORS.text, bg: COLORS.rowBg };
|
||
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
|
||
|
||
rows.push(
|
||
`<TR><TD PORT="${escapeHtml(ep.portId)}" COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
|
||
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
|
||
`<TR>` +
|
||
`<TD BGCOLOR="${mColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="38">` +
|
||
`<FONT COLOR="${mColor.fg}" POINT-SIZE="9"><B>${escapeHtml(ep.method)}</B></FONT></TD>` +
|
||
`<TD ALIGN="LEFT" CELLPADDING="4">` +
|
||
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(ep.subpath)}</B></FONT></TD>` +
|
||
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
|
||
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(ep.summary)}</FONT></TD>` +
|
||
`</TR></TABLE></TD></TR>`
|
||
);
|
||
}
|
||
|
||
return (
|
||
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
|
||
rows.join('') +
|
||
`</TABLE>>`
|
||
);
|
||
}
|
||
|
||
function assignEdgeAnchors(edges, positions) {
|
||
const headPort = new Map();
|
||
const tailCompass = new Map();
|
||
const inboundCounts = new Map();
|
||
|
||
const byTarget = new Map();
|
||
const bySource = new Map();
|
||
|
||
for (const edge of edges) {
|
||
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
|
||
byTarget.get(edge.to).push(edge);
|
||
|
||
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
|
||
bySource.get(edge.from).push(edge);
|
||
}
|
||
|
||
for (const [target, group] of byTarget) {
|
||
group.sort((a, b) => {
|
||
const ax = positions?.get(a.from)?.x ?? 0;
|
||
const bx = positions?.get(b.from)?.x ?? 0;
|
||
if (ax !== bx) return ax - bx;
|
||
return a.label.localeCompare(b.label);
|
||
});
|
||
inboundCounts.set(target, group.length);
|
||
group.forEach((edge, i) => {
|
||
headPort.set(edgeKey(edge), `in${i}`);
|
||
});
|
||
}
|
||
|
||
for (const group of bySource.values()) {
|
||
group.forEach((edge, i) => {
|
||
if (edge.from === edge.to) {
|
||
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
|
||
return;
|
||
}
|
||
const from = positions?.get(edge.from);
|
||
const to = positions?.get(edge.to);
|
||
const fromX = from?.x ?? 0;
|
||
const toX = to?.x ?? fromX;
|
||
const goEast = toX >= fromX;
|
||
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
|
||
});
|
||
}
|
||
|
||
return { headPort, tailCompass, inboundCounts };
|
||
}
|
||
|
||
function buildDot(resources, edges, positions, anchors) {
|
||
const edgeColors = assignEdgeColors(edges);
|
||
const rankingKeys = selectRankingEdgeKeys(edges);
|
||
const { headPort, tailCompass, inboundCounts } =
|
||
anchors ?? assignEdgeAnchors(edges, positions);
|
||
|
||
const lines = [
|
||
'digraph api {',
|
||
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
|
||
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
|
||
' compound=true, mclimit=1.0];',
|
||
' node [shape=plaintext, fontname="Helvetica"];',
|
||
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
|
||
'',
|
||
];
|
||
|
||
const clusteredResources = new Set();
|
||
|
||
for (const cluster of CLUSTERS) {
|
||
const presentInCluster = cluster.resources.filter((id) => resources.has(id));
|
||
if (presentInCluster.length === 0) continue;
|
||
|
||
lines.push(` subgraph cluster_${cluster.id} {`);
|
||
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
|
||
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
|
||
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
|
||
|
||
for (const resId of presentInCluster) {
|
||
clusteredResources.add(resId);
|
||
const res = resources.get(resId);
|
||
const inCount = inboundCounts.get(resId) ?? 0;
|
||
const label = renderResourceNode(res, inCount);
|
||
lines.push(` ${resId} [label=${label}];`);
|
||
}
|
||
lines.push(' }');
|
||
}
|
||
|
||
// Any unclustered resources
|
||
for (const [resId, res] of resources) {
|
||
if (clusteredResources.has(resId)) continue;
|
||
const inCount = inboundCounts.get(resId) ?? 0;
|
||
const label = renderResourceNode(res, inCount);
|
||
lines.push(` ${resId} [label=${label}];`);
|
||
}
|
||
|
||
lines.push('');
|
||
|
||
for (const edge of edges) {
|
||
const key = edgeKey(edge);
|
||
const color = edgeColors.get(key) ?? '#5aa7ff';
|
||
const isRanking = rankingKeys.has(key);
|
||
const hp = headPort.get(key) ?? 'in0';
|
||
const tc = tailCompass.get(key) ?? 'e';
|
||
|
||
const attrs = [
|
||
`color="${color}"`,
|
||
`fontcolor="${color}"`,
|
||
`tooltip="${escapeHtml(edge.tooltip)}"`,
|
||
`label=" ${escapeHtml(edge.label)} "`,
|
||
];
|
||
|
||
if (!isRanking && edge.from !== edge.to) {
|
||
attrs.push('constraint=false', 'weight=1');
|
||
} else {
|
||
attrs.push('weight=3');
|
||
}
|
||
|
||
// Source port from endpoint if available, else exit compass
|
||
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
|
||
const toPort = `:${hp}:n`;
|
||
|
||
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
|
||
}
|
||
|
||
lines.push('}');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function parsePlainPositions(plain) {
|
||
const positions = new Map();
|
||
for (const line of plain.split('\n')) {
|
||
if (!line.startsWith('node ')) continue;
|
||
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
|
||
if (!match) continue;
|
||
positions.set(match[1], {
|
||
x: Number(match[2]),
|
||
y: Number(match[3]),
|
||
width: Number(match[4]),
|
||
height: Number(match[5]),
|
||
});
|
||
}
|
||
return positions;
|
||
}
|
||
|
||
function fixSelfLoopSvg(svg) {
|
||
return svg.replace(
|
||
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?->\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
|
||
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
|
||
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
|
||
if (pts.length < 2) return match;
|
||
|
||
const [x0, y0] = pts[0];
|
||
const [x1, y1] = pts[pts.length - 1];
|
||
const east = Math.max(x0, x1) + 36;
|
||
const north = Math.min(y0, y1) - 28;
|
||
|
||
const tx = x1 - east;
|
||
const ty = y1 - north;
|
||
const len = Math.hypot(tx, ty) || 1;
|
||
const dx = tx / len;
|
||
const dy = ty / len;
|
||
const arrowLen = 9;
|
||
const arrowHalf = 3.5;
|
||
const baseX = x1 - dx * arrowLen;
|
||
const baseY = y1 - dy * arrowLen;
|
||
const px = -dy * arrowHalf;
|
||
const py = dx * arrowHalf;
|
||
|
||
const fmt = (n) => (Math.round(n * 100) / 100).toString();
|
||
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
|
||
const newPointsFmt =
|
||
`${fmt(x1)},${fmt(y1)} ` +
|
||
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
|
||
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
|
||
`${fmt(x1)},${fmt(y1)}`;
|
||
|
||
return (
|
||
`<g id="${edgeId}" class="edge">\n` +
|
||
`<title>${_table}->${_table}</title>\n` +
|
||
`<g id="${aId}"><a ${aAttrs}>\n` +
|
||
`<path${pathAttrs} d="${newDFmt}"/>\n` +
|
||
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
|
||
`</a>\n` +
|
||
`</g>\n` +
|
||
`</g>`
|
||
);
|
||
}
|
||
);
|
||
}
|
||
|
||
console.log('Generating Dash API Graph…');
|
||
const spec = getOpenApiSpec(true);
|
||
const resources = parseApi(spec);
|
||
|
||
// Add Legend pseudo-resource
|
||
resources.set('Legend', {
|
||
id: 'Legend',
|
||
basePath: '',
|
||
endpoints: [],
|
||
});
|
||
|
||
const edges = buildApiEdges(resources);
|
||
|
||
console.log(`Parsed ${resources.size - 1} API resources and ${edges.length} relationships`);
|
||
|
||
const graphviz = await Graphviz.load();
|
||
|
||
// Pass 1: Initial layout to learn coordinates
|
||
console.log('Layout pass 1/2: computing node positions…');
|
||
const initialDot = buildDot(resources, edges, null, null);
|
||
const plain = graphviz.layout(initialDot, 'plain', 'dot');
|
||
const positions = parsePlainPositions(plain);
|
||
|
||
// Pass 2: Anchor assignment using learned positions
|
||
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
|
||
const anchors = assignEdgeAnchors(edges, positions);
|
||
const finalDot = buildDot(resources, edges, positions, anchors);
|
||
|
||
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
|
||
const fixedSvg = fixSelfLoopSvg(rawSvg);
|
||
|
||
// Ensure background color is set on svg tag
|
||
const finalSvg = fixedSvg.replace(
|
||
/<svg([^>]*?)>/,
|
||
`<svg$1 style="background-color:${COLORS.background};">`
|
||
);
|
||
|
||
writeFileSync(dotOutputPath, finalDot, 'utf8');
|
||
writeFileSync(outputPath, finalSvg, 'utf8');
|
||
|
||
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
|
||
console.log(`Written ${dotOutputPath}`);
|
||
console.log(`Written ${outputPath}`);
|
||
if (dim) {
|
||
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
|
||
}
|