#!/usr/bin/env node // ── §2.8 — the OpenAPI fragment ─────────────────────────────────────────── // // Generates (or checks) `swagger-fragment.json` in the bundle root: the paths, // tags and schemas describing every route this module registers. Core merges the // fragments of *started* modules over its own committed spec at request time and // serves the result at `/api/docs.json` (MODULE_API.md §6.1a). // // ── Why a module has to ship this at all ────────────────────────────────── // // Core's own spec generation is STATIC analysis — swagger-autogen parses core's // `app.js` as text and follows the literal `app.use(...)` chain. Your module // arrives on a volume after core was built, is required by a filesystem loop, and // mounts through `api.registerRoutes()`. There is no literal mount for a parser to // follow, and core does not have your sources anyway. So nothing core can run // will ever describe your routes. // // The failure mode is the dangerous one: swagger-autogen reports success and // emits a spec with the routes simply absent. It happened twice inside core // before anyone noticed, and once to the first module — 417 annotations that // generated nothing at all, for two phases, because nobody had built the // fragment. If you take one thing from this file, take that a green build is not // evidence that anything was described. // // ── Where the prefixes come from ────────────────────────────────────────── // // swagger-autogen is pointed at one router file at a time, so its paths come out // relative to that router (`/status`, not `/api/v1/public/world/status`) — // nothing in the file says where it hangs. §6.1a requires fully-qualified paths, // because core merges the fragment verbatim and never re-derives a prefix. // // So this script **runs your own `register()`** against a recording `api` and // reads the mounts back out. Every prefix is therefore the prefix that router is // actually registered under — the same call an operator's core will make, rather // than a table beside it that drifts the first time a mount moves. Which file a // recorded router object came from is answered by `require.cache`: the module // whose `exports` IS that router. // // The tier base paths are the one thing that cannot be derived here, because they // are core's and not yours. They are §2.4's normative table, quoted below. const fs = require('fs') const os = require('os') const path = require('path') const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const { fakeCtx, fakeApi } = require('../test/_fakes') const doc = require('../swagger/doc') const MODULE_ROOT = path.resolve(__dirname, '..', '..') const SERVER_ROOT = path.join(MODULE_ROOT, 'server') const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json') // MODULE_API.md §2.4. A router registered under a tier sits inside that tier's // router in core, behind its gate; the base path is core's and fixed. const TIER_BASE = { public: '/api/v1/public', admin: '/api/v1/admin', player: '/api/v1/player', } /** * Run `register()` with a recording api and return `[{ file, prefix, what }]`. * * The ctx is the test fakes' — the same one the suite proves the module runs * against — because registration must not touch a database (§2.2), and this * script is exactly the kind of no-database caller that rule exists for. */ function mountedRouters() { const register = require('../index') const api = fakeApi() register(fakeCtx(), api) const fileOf = (router) => { for (const mod of Object.values(require.cache)) { if (mod && mod.exports === router) return mod.filename } return null } const mounts = [] for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) { const base = TIER_BASE[tier] if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`) for (const [prefix, router] of Object.entries(byPrefix)) { mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` }) } } return mounts.map(({ router, prefix, what }) => { const file = fileOf(router) if (!file) { // A router built inline in index.js rather than required from its own // file. swagger-autogen needs a file to read, so there is nothing to // generate from — put the router in its own module. throw new Error(`swagger: cannot find the source file of the router for ${what}`) } return { file, prefix, what } }) } /** * Run swagger-autogen over one router file. Paths come out router-relative. * * **swagger-autogen reports a broken annotation and then succeeds anyway** — it * `console.error`s "Syntax error" or "out of structure", drops that one * annotation, and prints `Success` in green. So its diagnostics are captured here * and made fatal. Nothing else will tell you. */ async function fragmentFor(file) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-')) const out = path.join(dir, 'fragment.json') const complaints = [] const realError = console.error console.error = (...args) => { const line = args.map(String).join(' ') if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim()) else realError(...args) } try { // A DEEP COPY per call, and that is not defensive style. swagger-autogen // writes its result back into the object it was handed, so reusing one `doc` // across several routers re-wraps the previous pass's output every time. The // first module to hit this produced a 484 MB fragment from six routers. await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], { ...JSON.parse(JSON.stringify(doc)), info: { title: 'examplegame fragment', version: '0' }, }) } finally { console.error = realError } if (complaints.length > 0) { throw new Error( `swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` + `swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`, ) } const fragment = JSON.parse(fs.readFileSync(out, 'utf8')) fs.rmSync(dir, { recursive: true, force: true }) return fragment } /** * Re-root a router-relative fragment under the prefix it is mounted at. * * Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging * to the PREFIX is moved to the front of each operation's parameter list — * swagger-autogen orders parameters by where they appeared in the path it saw, * which was only the tail. */ function prefixPaths(fragment, prefix) { const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '') const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1]) const paths = {} for (const [p, item] of Object.entries(fragment.paths || {})) { for (const operation of Object.values(item)) { const params = operation && operation.parameters if (!Array.isArray(params)) continue const rank = (q) => { const i = outer.indexOf(q && q.name) return i === -1 ? outer.length : i } operation.parameters = params .map((q, i) => ({ q, i })) .sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i) .map(({ q }) => q) } // `router.get('/')` under a prefix concatenates to a trailing slash, a URL no // client calls. Core's generator normalises the same way. paths[`${oas}${p}`.replace(/\/$/, '')] = item } return paths } /** * Build the whole fragment: every mounted router, re-rooted and merged. * * Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a * fragment carry. `info`, `servers` and the security schemes belong to the merged * document, which is to say to core. */ async function build() { const spec = { paths: {}, tags: [], components: { schemas: {} } } let shared = false for (const { file, prefix, what } of mountedRouters()) { const generated = await fragmentFor(file) // Tags and schemas are the same on every pass — each was handed the same // `doc` — so take them from whichever ran first. What lands in the fragment // has to be what swagger-autogen PRODUCED and not what it was given: those // two differ (see fragmentFor), and core merges this file verbatim into a // spec whose own schemas went through the same mill. if (!shared) { spec.tags = generated.tags || [] spec.components.schemas = (generated.components || {}).schemas || {} shared = true } const paths = prefixPaths(generated, prefix) const count = Object.keys(paths).length if (count === 0) { // An empty result is precisely what the silent drop looks like, so it is a // hard failure rather than a router that happens to declare no routes. throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`) } for (const [p, item] of Object.entries(paths)) { if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`) spec.paths[p] = item } process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`) } // Sorted, because swagger-autogen emits router-traversal order: without this, // moving a route between files rewrites most of a committed artifact even when // the API is provably unchanged. spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1))) return spec } async function main() { const check = process.argv.includes('--check') const spec = await build() const json = `${JSON.stringify(spec, null, 2)}\n` if (!check) { fs.writeFileSync(FRAGMENT, json) process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`) return } if (!fs.existsSync(FRAGMENT)) { process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n') process.exit(1) } if (fs.readFileSync(FRAGMENT, 'utf8') !== json) { process.stderr.write( '\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' + 'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' + 'so a stale one documents a URL surface this module does not serve.\n', ) process.exit(1) } process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`) } if (require.main === module) { main().catch((err) => { process.stderr.write(`${err.stack}\n`) process.exit(1) }) } module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }