Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions backend/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { specs } from '../src/swagger';
import { collectRoutes, mergeDiscoveredRoutes } from './openapiRouterWalk';

const outputPath = path.resolve(__dirname, '../openapi.json');
const defaultOutputPath = path.resolve(__dirname, '../openapi.json');

try {
console.log('Generating openapi.json...');
const content = JSON.stringify(specs, null, 2);
type Routable = Parameters<typeof collectRoutes>[0];

/**
* Builds the spec, folds in every route reachable from `app` (recursing through
* nested routers) that the hand-written definition does not document, and
* writes it to `outputPath`.
*/
export function generateOpenApi(app?: Routable, outputPath = defaultOutputPath) {
const spec = JSON.parse(JSON.stringify(specs));
const added = app ? mergeDiscoveredRoutes(spec, collectRoutes(app)) : [];

const content = JSON.stringify(spec, null, 2);
const eol = fs.existsSync(outputPath) && fs.readFileSync(outputPath, 'utf8').includes('\r\n') ? '\r\n' : '\n';
fs.writeFileSync(outputPath, content.replace(/\n/g, eol), 'utf8');
console.log(`✅ openapi.json generated successfully at ${outputPath}`);
} catch (error) {
console.error('❌ Failed to generate openapi.json:', error);
process.exit(1);
return { spec, added };
}

async function main() {
console.log('Generating openapi.json...');
process.env.NODE_ENV ??= 'test';
let app: Routable | undefined;
try {
app = (await import('../src/index')).default as unknown as Routable;
} catch (error) {
console.warn('⚠️ Could not load the Express app; routes will NOT be auto-discovered:', error);
}
const { added } = generateOpenApi(app);
if (added.length) {
console.log(`Added ${added.length} undocumented route(s) discovered from the router stack.`);
}
console.log(`✅ openapi.json generated successfully at ${defaultOutputPath}`);
process.exit(0);
}

if (require.main === module) {
main().catch((error) => {
console.error('❌ Failed to generate openapi.json:', error);
process.exit(1);
});
}
134 changes: 134 additions & 0 deletions backend/scripts/openapiRouterWalk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Recursive Express router inspection used by the OpenAPI generator.
*
* Walks `app._router.stack` / `router.stack`, descending into every layer that
* wraps a sub-router (`layer.name === 'router'` or a handle exposing its own
* `stack`), and concatenates mount prefixes so a route registered as
* `GET /_test` on a router mounted at `/v1` is reported as `GET /v1/_test`.
*/

export interface DiscoveredRoute {
/** Lower-case HTTP method, e.g. `get`. */
method: string;
/** Full OpenAPI-style path, e.g. `/api/v1/vault/{id}`. */
path: string;
}

interface LayerKey {
name: string | number;
}

interface Layer {
name?: string;
regexp?: RegExp & { fast_slash?: boolean };
keys?: LayerKey[];
route?: { path: string | string[]; methods: Record<string, boolean> };
handle?: { stack?: Layer[] };
}

interface Routable {
stack?: Layer[];
_router?: { stack?: Layer[] };
}

const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);

/** Recovers the mount prefix of a router layer from Express 4's compiled regexp. */
function layerPrefix(layer: Layer): string {
const { regexp, keys = [] } = layer;
if (!regexp || regexp.fast_slash) return '';

let keyIndex = 0;
const source = regexp.source
.replace(/^\^/, '')
.replace('\\/?(?=\\/|$)', '')
.replace(/\(\?:\\?\/\(\[\^\\?\/\]\+\?\)\)/g, () => `/{${keys[keyIndex++]?.name ?? 'param'}}`)
.replace(/\\\//g, '/');

return source === '/' ? '' : source;
}

/** Converts an Express path pattern (`/:id`) to OpenAPI form (`/{id}`). */
function toOpenApiPath(path: string): string {
return path.replace(/:([A-Za-z0-9_]+)\??/g, '{$1}');
}

function joinPaths(prefix: string, path: string): string {
const joined = `${prefix}${path === '/' ? '' : path}`.replace(/\/{2,}/g, '/');
const trimmed = joined.length > 1 ? joined.replace(/\/$/, '') : joined;
return trimmed === '' ? '/' : trimmed;
}

function walk(stack: Layer[], prefix: string, out: DiscoveredRoute[], seen: Set<object>): void {
for (const layer of stack) {
if (layer.route) {
const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path];
for (const routePath of paths) {
if (typeof routePath !== 'string') continue;
for (const method of Object.keys(layer.route.methods)) {
if (!HTTP_METHODS.has(method)) continue;
out.push({ method, path: toOpenApiPath(joinPaths(prefix, routePath)) });
}
}
continue;
}

const child = layer.handle?.stack;
if (layer.name === 'router' || Array.isArray(child)) {
// Guard against a router mounted inside itself.
if (!child || seen.has(child)) continue;
seen.add(child);
walk(child, joinPaths(prefix, layerPrefix(layer)), out, seen);
seen.delete(child);
}
}
}

/** Returns every concrete route reachable from an Express app or Router. */
export function collectRoutes(root: Routable, basePath = ''): DiscoveredRoute[] {
const stack = root._router?.stack ?? root.stack ?? [];
const out: DiscoveredRoute[] = [];
walk(stack, basePath, out, new Set());

const unique = new Map<string, DiscoveredRoute>();
for (const route of out) unique.set(`${route.method} ${route.path}`, route);
return [...unique.values()];
}

type PathItem = Record<string, unknown>;

/**
* Adds a minimal operation for each discovered route the spec does not already
* document. Existing hand-written operations are never overwritten. Returns
* the routes that were added so callers can report them.
*/
export function mergeDiscoveredRoutes(
spec: { paths?: Record<string, PathItem> },
routes: DiscoveredRoute[],
): DiscoveredRoute[] {
const paths = (spec.paths ??= {});
const added: DiscoveredRoute[] = [];

for (const route of routes) {
const item = (paths[route.path] ??= {});
if (item[route.method]) continue;

const tag = route.path.split('/').find((s) => s && !s.startsWith('{')) ?? 'default';
const params = [...route.path.matchAll(/\{([^}]+)\}/g)].map((m) => ({
name: m[1],
in: 'path',
required: true,
schema: { type: 'string' },
}));

item[route.method] = {
tags: [tag],
summary: `${route.method.toUpperCase()} ${route.path}`,
...(params.length ? { parameters: params } : {}),
responses: { '200': { description: 'Successful response' } },
};
added.push(route);
}

return added;
}
64 changes: 64 additions & 0 deletions backend/src/__tests__/openapiRouterWalk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
import { collectRoutes, mergeDiscoveredRoutes } from '../../scripts/openapiRouterWalk';
import { generateOpenApi } from '../../scripts/generate-openapi';

describe('openapi router traversal', () => {
it('concatenates mount prefixes for nested routers', () => {
const app = express();
const v1 = express.Router();
const inner = express.Router();
inner.get('/_test', (_req, res) => res.end());
inner.post('/items/:id', (_req, res) => res.end());
v1.use('/nested', inner);
v1.get('/_test', (_req, res) => res.end());
app.use('/v1', v1);
app.get('/health', (_req, res) => res.end());

const found = collectRoutes(app).map((r) => `${r.method} ${r.path}`).sort();
expect(found).toEqual([
'get /health',
'get /v1/_test',
'get /v1/nested/_test',
'post /v1/nested/items/{id}',
]);
});

it('handles parameterised mount points and root-mounted routers', () => {
const app = express();
const r = express.Router({ mergeParams: true });
r.get('/', (_req, res) => res.end());
app.use('/wallets/:wallet', r);
const root = express.Router();
root.get('/ping', (_req, res) => res.end());
app.use('/', root);

const found = collectRoutes(app).map((x) => `${x.method} ${x.path}`).sort();
expect(found).toEqual(['get /ping', 'get /wallets/{wallet}']);
});

it('does not overwrite documented operations', () => {
const spec = { paths: { '/health': { get: { summary: 'kept' } } } as Record<string, any> };
const added = mergeDiscoveredRoutes(spec, [
{ method: 'get', path: '/health' },
{ method: 'get', path: '/v1/_test' },
]);
expect(added).toEqual([{ method: 'get', path: '/v1/_test' }]);
expect(spec.paths['/health'].get.summary).toBe('kept');
});

it('writes /v1/_test into the generated openapi.json', () => {
const app = express();
const router = express.Router();
router.get('/_test', (_req, res) => res.end());
app.use('/v1', router);

const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-')), 'openapi.json');
generateOpenApi(app, out);
const written = JSON.parse(fs.readFileSync(out, 'utf8'));
expect(written.paths['/v1/_test'].get).toBeDefined();
expect(written.paths['/health']).toBeDefined();
});
});
Loading