Files
website/server/swagger/docsSpec.js
wtclaude adff20be7b
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 31s
feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
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>
2026-08-11 22:57:37 -05:00

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 }