diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index ac14858c8..6e372685c 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -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[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); + }); } diff --git a/backend/scripts/openapiRouterWalk.ts b/backend/scripts/openapiRouterWalk.ts new file mode 100644 index 000000000..a0ee911b2 --- /dev/null +++ b/backend/scripts/openapiRouterWalk.ts @@ -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 }; + 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): 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(); + for (const route of out) unique.set(`${route.method} ${route.path}`, route); + return [...unique.values()]; +} + +type PathItem = Record; + +/** + * 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 }, + 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; +} diff --git a/backend/src/__tests__/openapiRouterWalk.test.ts b/backend/src/__tests__/openapiRouterWalk.test.ts new file mode 100644 index 000000000..4ed93392f --- /dev/null +++ b/backend/src/__tests__/openapiRouterWalk.test.ts @@ -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 }; + 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(); + }); +});