// ── OpenAPI for core's extension-slot routers ────────────────────────────── // // The second half of `npm run swagger`. It exists because of a failure mode that // announces itself as a success. // // `swagger/swagger.js` is STATIC analysis: swagger-autogen parses `src/app.js` as // text and follows the literal `app.use(...)` mount chain. An extension slot // (MODULE_SYSTEM.md §1.9) breaks that chain on purpose — the slot's router is // created by `registries.declareSlot()` and filled later, so there is no literal // require for the parser to follow. When PR 4 moved the six `/admin/users/:id/shard/*` // routes behind the `admin.users.detail` slot, regenerating the spec printed // `Swagger-autogen: Success` and deleted 407 lines. Nothing failed. The spike hit // the identical thing (MODULE_API.md §7.4) and it is why the fragment merge is // the settled answer (§6.1). // // So: generate a fragment per filled slot by pointing swagger-autogen at that // router's own file, re-root its paths at the prefix the router is ACTUALLY // mounted at in the live app, and merge. Two things are deliberately derived // rather than written down, because a written-down copy is a copy that drifts: // // • WHICH slots — from `registries.filledSlots()`, not a list here. // • WHERE each hangs — by finding the slot's own router object in the live // express stack and accumulating the mount prefixes above it, using // `scripts/routeManifest.js`'s `mountPath` so the manifest and the spec can // never disagree about what a mount decodes to. // // This is core's own slot fill only. A MODULE ships a prebuilt // `swagger-fragment.json` in its bundle and core merges it at request time // (§6.1a) — core never has a module's sources to analyse. const fs = require('fs') const os = require('os') const path = require('path') const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const { mergeFragment, prefixPaths } = require('./mergeSpec') const { mountPath } = require('../scripts/routeManifest') const SERVER_ROOT = path.join(__dirname, '..') /** * Find `target` in an express stack and return the path prefix it is mounted at. * * Depth-first, accumulating each enclosing mount. Returns null when the router is * not on the stack at all — which for a filled slot means core declared it and * never mounted it, a bug worth failing the build over rather than papering over * with an unprefixed path. */ function findMountPrefix(stack, target, prefix = '') { for (const layer of stack || []) { if (!layer.handle || !Array.isArray(layer.handle.stack)) continue const here = prefix + mountPath(layer) if (layer.handle === target) return here const found = findMountPrefix(layer.handle.stack, target, here) if (found !== null) return found } return null } /** * Generate one fragment by running swagger-autogen over a single router file. * * Its paths come out relative to that router (`/shard/accounts`) because nothing * in the file says where it hangs; `prefixPaths` supplies the rest. */ async function fragmentFor(specFile) { const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rg-swagger-')), 'fragment.json') await swaggerAutogen(out, [path.relative(SERVER_ROOT, specFile).split(path.sep).join('/')], { info: { title: 'slot fragment', version: '0' }, }) const fragment = JSON.parse(fs.readFileSync(out, 'utf8')) fs.rmSync(path.dirname(out), { recursive: true, force: true }) return fragment } /** * Merge every filled core slot's routes into the generated spec file, in place. * * @param {string} outputFile the swagger-output.json swagger.js just wrote * @returns {Promise} how many paths were added */ async function mergeSlotSpecs(outputFile) { /* eslint-disable global-require */ const app = require('../src/app') // builds the app: declares and fills the slots const registries = require('../src/modules/registries') /* eslint-enable global-require */ const filled = registries.filledSlots().filter((s) => s.specFile) if (filled.length === 0) return 0 const spec = JSON.parse(fs.readFileSync(outputFile, 'utf8')) let added = 0 for (const slot of filled) { const prefix = findMountPrefix(app._router.stack, slot.router) if (prefix === null) { throw new Error( `swagger: extension slot "${slot.slot}" is filled but its router is not mounted on the app — ` + 'declareSlot() returned a router nobody use()d.', ) } const fragment = prefixPaths(await fragmentFor(slot.specFile), prefix) const paths = Object.keys(fragment.paths || {}).length if (paths === 0) { throw new Error( `swagger: extension slot "${slot.slot}" generated an EMPTY fragment from ${slot.specFile}. ` + 'That is the silent-drop failure this step exists to catch, not a slot with no routes.', ) } mergeFragment(spec, fragment, `slot ${slot.slot}`) added += paths process.stdout.write(`merged ${paths} path(s) from slot ${slot.slot} at ${prefix}\n`) } fs.writeFileSync(outputFile, `${JSON.stringify(spec, null, 2)}\n`) return added } module.exports = { mergeSlotSpecs, findMountPrefix }