feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5) #141

Merged
whitlocktech merged 1 commits from feature/module-openapi-merge into edge 2026-08-12 04:12:10 +00:00
Member

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.

Pairs with Module-uo#6 (merge that first) and docs#….

The merge core owed

MODULE_API.md §6.1a: a module ships swagger-fragment.json, core merges the fragments of started modules into /api/docs.json at request time. Neither half existed. swagger/mergeSpec.js named the request-time caller in its own header and that caller was never written — so the 72 routes module-uo serves were in no OpenAPI spec at all. That is core's standing rule ("never ship a route that isn't in the spec") broken by the extraction rather than by a route, which is why it lands here rather than in phase 4.

swagger/docsSpec.js is the merge. Four things about it are deliberate:

  • swagger-output.json stays core-only. It is generated on a developer's machine and committed, so it has to come out the same regardless of what they had checked out. A module arrives on a volume long after the image was built, and core never has its sources.
  • It is never mutated. It is a require()d JSON module — one in-place merge would be permanent for the life of the process and cumulative across rebuilds, a module's paths surviving its own uninstall.
  • Core wins every key collision, which is what makes §6.1a's naming rule work: a module namespaces the schemas it defines (UoShardStatus) and references core's shared ones (Error) by core's name. The first would collide and lose; the second resolves in the merged document, where both exist.
  • The Swagger UI is built per request too. 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. docsSpec is cached on a new loader state version, so the repeated call costs a comparison.

A fragment that is missing, unreadable or not JSON costs that module its paths and nothing else — §4.4's bargain, where one module's failure is never the site's.

The last of core's UO copy

Slice 4 deferred these, and §5.2's check reads code, not prose, so none of it was caught:

  • 31 UO schemas and 4 UO tags in swagger/swagger.js — 578 lines describing routes core has not served since slice 1. They moved to module-uo, namespaced, and come 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, which is now ## Modules.
  • TOWNCRIER_DURATION_SEC and UOLINK_* in the two .env.examples. Read by the module, never by core; documented in the module's README instead of half-copied here.

Two dropped annotations, and why 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. (The same class turned up four more times in module-uo, whose annotations came from here.)

Auditing the tag list for the four UO orphans also turned up the same defect in the other direction: five tags used by routes and never declared — Admin · Email, Admin · Invites, Admin · Moderation, Admin · Pages, Auth · Me. Declared now.

Verification

  • 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
  • Real boot with module-uo installed (real MariaDB, module started): /api/docs.json serves 197 paths — 128 core + 69 module — with all four module tags, 31 Uo… schemas, no dangling $refs, and /api/docs renders the module's operations grouped under their own tags with zero console errors

AI-assisted contribution. Written with Claude Code (Claude Opus). Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

🤖 Generated with Claude Code

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. Pairs with **Module-uo#6** (merge that first) and `docs#…`. ## The merge core owed `MODULE_API.md` §6.1a: a module ships `swagger-fragment.json`, core merges the fragments of **started** modules into `/api/docs.json` at request time. **Neither half existed.** `swagger/mergeSpec.js` named the request-time caller in its own header and that caller was never written — so the 72 routes module-uo serves were in no OpenAPI spec at all. That is core's standing rule ("never ship a route that isn't in the spec") broken by the extraction rather than by a route, which is why it lands here rather than in phase 4. `swagger/docsSpec.js` is the merge. Four things about it are deliberate: - **`swagger-output.json` stays core-only.** It is generated on a developer's machine and committed, so it has to come out the same regardless of what they had checked out. A module arrives on a volume long after the image was built, and core never has its sources. - **It is never mutated.** It is a `require()`d JSON module — one in-place merge would be permanent for the life of the process *and* cumulative across rebuilds, a module's paths surviving its own uninstall. - **Core wins every key collision**, which is what makes §6.1a's naming rule work: a module namespaces the schemas it defines (`UoShardStatus`) and references core's shared ones (`Error`) by core's name. The first would collide and lose; the second resolves in the merged document, where both exist. - **The Swagger UI is built per request too.** 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. `docsSpec` is cached on a new loader state version, so the repeated call costs a comparison. A fragment that is missing, unreadable or not JSON costs that module its paths and nothing else — §4.4's bargain, where one module's failure is never the site's. ## The last of core's UO copy Slice 4 deferred these, and §5.2's check reads **code, not prose**, so none of it was caught: - **31 UO schemas and 4 UO tags** in `swagger/swagger.js` — 578 lines describing routes core has not served since slice 1. They moved to module-uo, namespaced, and come *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, which is now `## Modules`. - `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s. Read by the module, never by core; documented in the module's README instead of half-copied here. ## Two dropped annotations, and why 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. (The same class turned up four more times in module-uo, whose annotations came from here.) Auditing the tag list for the four UO orphans also turned up the same defect in the other direction: five tags **used** by routes and never declared — `Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`, `Auth · Me`. Declared now. ## Verification - **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** - **Real boot with module-uo installed** (real MariaDB, module `started`): `/api/docs.json` serves **197 paths** — 128 core + 69 module — with all four module tags, 31 `Uo…` schemas, **no dangling `$ref`s**, and `/api/docs` renders the module's operations grouped under their own tags with **zero console errors** --- **AI-assisted contribution.** Written with Claude Code (Claude Opus). Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-12 03:58:11 +00:00
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
adff20be7b
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>
whitlocktech merged commit 2cb549e9e5 into edge 2026-08-12 04:12:09 +00:00
whitlocktech deleted branch feature/module-openapi-merge 2026-08-12 04:12:10 +00:00
Sign in to join this conversation.
No description provided.