feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
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

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>
This commit is contained in:
2026-08-11 22:57:37 -05:00
parent 87230c879a
commit adff20be7b
11 changed files with 643 additions and 5227 deletions

View File

@@ -111,15 +111,22 @@ app.use(
)
// ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/).
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own
// routes are generated from their annotations by `npm run swagger`
// (server/swagger/) and committed; an installed module's routes cannot be —
// swagger-autogen is static analysis and a module arrives on the volume after the
// image was built — so each module ships its own fragment and they are merged
// HERE, per request, over core's committed spec (docs/website/MODULE_API.md §6.1a).
// Loaded lazily and guarded so a missing spec never crashes the server.
try {
// eslint-disable-next-line global-require
/* eslint-disable global-require */
const swaggerSpec = require('../swagger/swagger-output.json')
const { docsSpec } = require('../swagger/docsSpec')
/* eslint-enable global-require */
app.get('/api/docs.json', (req, res) => {
// #swagger.ignore = true
res.json(swaggerSpec)
res.json(docsSpec(swaggerSpec))
})
// swagger-ui-express injects an inline bootstrap script and inline styles, which
// the global 'self'-only script-src would block — relax CSP for this route only.
@@ -132,10 +139,18 @@ try {
'upgrade-insecure-requests': null,
},
})
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
// `setup()` is called PER REQUEST rather than once here, because the document it
// renders is not fixed at boot: a module reaching `started` (or failing to) adds
// or removes paths, and a UI bound to the spec as it looked while app.js was
// still being required would show core's routes for the life of the process
// while /api/docs.json showed the merged set. `docsSpec` is cached on the
// loader's state version, so the repeated call costs a comparison.
const swaggerOpts = {
customSiteTitle: `${brand.name} API docs`,
swaggerOptions: { persistAuthorization: true },
}))
}
app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) =>
swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next))
} catch (err) {
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
message: err.message,