// ── The OpenAPI document core actually serves ────────────────────────────── // // `swagger-output.json` is core's own routes and only core's own routes: it is // generated by `npm run swagger` on a developer's machine and committed, so it // must come out the same regardless of which modules that developer happened to // have checked out. A module's routes cannot be in it, and not merely because // nobody put them there — a module arrives on a volume long after the image was // built, and core never has its sources to analyse. // // So the document served at `/api/docs.json` is assembled at REQUEST time: core's // committed spec, plus the `swagger-fragment.json` of every started module // (docs/website/MODULE_API.md §2.8 and §6.1a). This file is that assembly. // // **Core always wins a key collision.** `mergeFragment` enforces it and reports // what it dropped. A module cannot redefine a core path, tag or schema by shipping // one with the same name — which is why §6.1a tells modules to namespace the // schemas they define (`UoShardStatus`) while referencing core's shared ones // (`Error`) by core's name: the first would collide and lose, the second resolves // here, in the merged document, which is the only place both exist. // // **Cached, keyed on the loader's state version.** Building the document reads a // file per module and deep-copies a 5,000-line spec; `/api/docs` is a page an // operator opens occasionally and a crawler may hit repeatedly. The cache is // invalidated by any module state CHANGE — which is what "started modules only" // depends on, and the only input here that can move without a restart. const fs = require('fs') const modules = require('../src/modules/loader') const createLogger = require('../src/utils/logger') const { mergeFragment } = require('./mergeSpec') const log = createLogger('swagger') let cached = null let cachedVersion = -1 /** * Core's spec with every started module's fragment merged over it. * * Never throws: `/api/docs.json` answering with core's routes alone is a worse * document than the full one, but it is a document. A fragment that is missing, * unreadable or not JSON costs that module its paths and nothing else — the same * bargain §4.4 makes everywhere else, where one module's failure is never the * site's. * * @param {object} coreSpec the committed swagger-output.json — never mutated * @returns {object} */ function docsSpec(coreSpec) { // Before app.js has called modules.load(), asking is a mis-ordered boot rather // than a core with nothing installed (§7.6) — but this is a request handler, and // 500ing the docs page over it would be the wrong trade. Core's own spec is the // honest answer to "what is documented" at that point anyway. if (!modules.isLoaded()) return coreSpec const version = modules.version() if (cached && cachedVersion === version) return cached // A structural copy, because mergeFragment writes into what it is given and // `coreSpec` is a require()d JSON module: mutating it would make the merge // cumulative across rebuilds and permanent for the life of the process. const spec = JSON.parse(JSON.stringify(coreSpec)) spec.paths = spec.paths || {} spec.tags = spec.tags || [] spec.components = spec.components || {} spec.components.schemas = spec.components.schemas || {} for (const { id, file } of modules.specFragments()) { let fragment try { fragment = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (err) { log.warn('module OpenAPI fragment could not be read — its routes will be undocumented', { module: id, file, error: err.message, }) continue } const before = Object.keys(spec.paths).length mergeFragment(spec, fragment, `module ${id}`) log.debug('merged module OpenAPI fragment', { module: id, paths: Object.keys(spec.paths).length - before, }) } cached = spec cachedVersion = version return spec } /** Test seam: forget the cached document. */ function reset() { cached = null cachedVersion = -1 } module.exports = { docsSpec, reset }