#!/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` (docs/website/MODULE_API.md §6.1a). // // **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC // analysis — swagger-autogen parses `src/app.js` as text and follows the literal // `app.use(...)` chain. A 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 our sources // anyway, so nothing core can run will ever describe these routes. The failure // mode is the dangerous one: swagger-autogen reports success and emits a spec // with the routes simply absent (§6.1, and core hit it twice — the spike's atlas // paths and PR 4's 407 deleted lines). // // ── 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/shard/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 the module's own `register()`** against a recording `api` // and reads the mounts back out of it. The prefix of every router is therefore the // prefix that router is actually registered under — the same call an operator's // core will make, not a table beside it that drifts the first time a mount moves. // Which router a recorded object came from is answered by `require.cache`: the // file whose `module.exports` IS this router. // // The two things that cannot be derived here are the tier base paths and the // extension slot's mount, because they are core's, not ours. They are §2.4's // normative table, quoted below — and they are not taken on trust: the frozen // route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real // core with this module loaded, and fails if a fragment path is not among them. // That check is where a wrong constant here dies. 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 tier's own base path is core's and fixed // by §1.2's frozen URL surface. const TIER_BASE = { public: '/api/v1/public', admin: '/api/v1/admin', player: '/api/v1/player', } // MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core // may declare one — so a module filling it has to be told where it landed. const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/:id', } /** * Run `register()` with a recording api and return `[{ file, prefix }]`. * * 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 rule 1) 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}` }) } } for (const { slot, router } of api.record.extensions) { const mount = SLOT_MOUNT[slot] if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`) mounts.push({ router, prefix: mount, what: `slot ${slot}` }) } 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. 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. Four of the annotations that came * across in slice 1 were broken that way and had been for as long as they had * existed in core: two `requestBody` literals a brace short, and two descriptions * whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to * `'` before evaluating, so either inside a single-quoted description ends the * string early). The visible result was a documented route missing its body, or a * typed query parameter demoted to an untyped one. * * So its diagnostics are captured and made fatal. This is the same class as every * other failure in this seam — a generator that reports success while silently * dropping what it was asked to describe (§6.1) — and the only difference is that * here the tool does say something. Nothing was listening. */ async function fragmentFor(file) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-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 // renders `components.schemas` from an EXAMPLE object rather than treating it // as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object', // properties: { type: { type: 'string', example: 'object' } } }`, a // meta-description of itself. That shape is uniform across core's committed // spec and is the house shape, so it is matched rather than fought. What is // NOT survivable is that it writes the result back into the object it was // handed: reusing one `doc` across six routers re-wraps the previous pass's // output five more times, and the fragment came out at 484 MB. await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], { ...JSON.parse(JSON.stringify(doc)), info: { title: 'module-uo 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 the prefix's own * params are 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, so `/{id}/shard/link/{account}` would otherwise document (account, id). */ 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 `/api/v1/public/shard/`, // a URL no client calls. Core's swagger.js normalizes 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 allows * a fragment to carry. `info`, `servers` and the security schemes are the merged * document's, which is to say core's. */ async function build() { const spec = { paths: {}, tags: [], components: { schemas: {} } } let shared = false for (const { file, prefix, what } of mountedRouters()) { const generated = await fragmentFor(file) // The tags and schemas are the SAME on every pass — each was handed the same // `doc` — so they are taken from whichever ran first rather than from `doc` // itself. What lands in the fragment has to be what swagger-autogen produced, // 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 fragment 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, for the reason core sorts: swagger-autogen emits router-traversal // order, so moving a route between files would rewrite most of this 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, SLOT_MOUNT, FRAGMENT }