Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.
**The merge (MODULE_API.md §6.1a).** `swagger-output.json` is core's own routes
and cannot be anything else — it is generated on a developer's machine and
committed, so it must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.
Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.
Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.
**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):
- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
not served since slice 1 — 578 lines. They moved to module-uo, namespaced
`Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
`## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
module, not by core, and documented in the module's README instead.
**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.
Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.
- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
(128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
/api/docs renders the module's operations with zero console errors
Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1
Co-Authored-By: Claude <noreply@anthropic.com>
101 lines
4.1 KiB
JavaScript
101 lines
4.1 KiB
JavaScript
// ── 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 }
|