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>
206 lines
8.0 KiB
JavaScript
206 lines
8.0 KiB
JavaScript
// ── /api/docs.json, with a module installed ────────────────────────────────
|
|
//
|
|
// The request-time half of docs/website/MODULE_API.md §6.1's settled decision.
|
|
// `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 has to come out the
|
|
// same regardless of what they had checked out, and a module arrives on the
|
|
// volume long after the image was built. The module's routes therefore reach the
|
|
// document only here, from the fragment it ships (§2.8, §6.1a).
|
|
//
|
|
// This boots the REAL app against a throwaway module directory, because the two
|
|
// things worth locking are properties of the served document rather than of the
|
|
// merge helper: that a started module's paths are IN it, and that core wins.
|
|
//
|
|
// The modules directory is written and MODULES_DIR set BEFORE app.js is required
|
|
// — the scan is synchronous and happens during that require.
|
|
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const fs = require('fs')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const { test, before, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-docs-'))
|
|
const moduleDir = path.join(tmpRoot, 'atlas')
|
|
fs.mkdirSync(path.join(moduleDir, 'client', 'dist'), { recursive: true })
|
|
fs.writeFileSync(path.join(moduleDir, 'client', 'dist', 'entry.js'), 'export const hello = 1\n')
|
|
fs.writeFileSync(
|
|
path.join(moduleDir, 'module.json'),
|
|
JSON.stringify({
|
|
id: 'atlas',
|
|
name: 'Atlas',
|
|
version: '1.0.0',
|
|
coreApi: '^1.0.0',
|
|
client: { entry: 'client/dist/entry.js' },
|
|
}),
|
|
)
|
|
fs.writeFileSync(
|
|
path.join(moduleDir, 'swagger-fragment.json'),
|
|
JSON.stringify({
|
|
paths: {
|
|
'/api/v1/public/atlas/creatures': { get: { tags: ['Public · Atlas'], summary: 'List creatures' } },
|
|
// The collision case, and the one that matters: a module trying to
|
|
// redefine a path core already declares. Core wins and the module's
|
|
// version is dropped (§6.1a) — a module cannot rewrite core's docs.
|
|
'/api/v1/public/settings': { get: { summary: 'MODULE OVERRIDE' } },
|
|
},
|
|
tags: [{ name: 'Public · Atlas', description: 'from the module' }],
|
|
components: {
|
|
schemas: {
|
|
AtlasCreature: { type: 'object' },
|
|
// Same shape of collision, one section down.
|
|
Error: { type: 'string', description: 'MODULE OVERRIDE' },
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
process.env.MODULES_DIR = tmpRoot
|
|
|
|
/* eslint-disable global-require */
|
|
const app = require('../src/app')
|
|
const loader = require('../src/modules/loader')
|
|
const db = require('../src/utils/db')
|
|
const coreSpec = require('../swagger/swagger-output.json')
|
|
const { docsSpec, reset } = require('../swagger/docsSpec')
|
|
/* eslint-enable global-require */
|
|
|
|
let server
|
|
let base
|
|
|
|
before(async () => {
|
|
server = await new Promise((resolve) => {
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
|
})
|
|
base = `http://127.0.0.1:${server.address().port}`
|
|
})
|
|
|
|
after(async () => {
|
|
server.closeAllConnections()
|
|
await new Promise((resolve) => server.close(resolve))
|
|
await db.close()
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
const fetchSpec = async () => {
|
|
const res = await fetch(`${base}/api/docs.json`)
|
|
assert.equal(res.status, 200)
|
|
return res.json()
|
|
}
|
|
|
|
test('a module that is not started contributes nothing', async () => {
|
|
// It is `registered` here: loaded cleanly, onBoot not yet dispatched. Its
|
|
// routes answer 503 in that state, so documenting them would send a client
|
|
// somewhere it cannot go — the same reason the client chunk's <script> tag is
|
|
// withheld until `started`.
|
|
reset()
|
|
const spec = await fetchSpec()
|
|
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
|
|
})
|
|
|
|
test('a started module\'s paths, tags and schemas are in the served document', async () => {
|
|
loader.setState('atlas', 'started')
|
|
const spec = await fetchSpec()
|
|
|
|
assert.equal(spec.paths['/api/v1/public/atlas/creatures'].get.summary, 'List creatures')
|
|
assert.ok(spec.tags.some((t) => t.name === 'Public · Atlas'))
|
|
assert.deepEqual(spec.components.schemas.AtlasCreature, { type: 'object' })
|
|
})
|
|
|
|
test('core wins every collision, in every section', async () => {
|
|
loader.setState('atlas', 'started')
|
|
const spec = await fetchSpec()
|
|
|
|
assert.notEqual(spec.paths['/api/v1/public/settings'].get.summary, 'MODULE OVERRIDE')
|
|
assert.notEqual(spec.components.schemas.Error.description, 'MODULE OVERRIDE')
|
|
assert.deepEqual(spec.components.schemas.Error, coreSpec.components.schemas.Error)
|
|
})
|
|
|
|
test('the committed spec is never mutated by the merge', async () => {
|
|
// `swagger-output.json` is a require()d JSON module, so 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.
|
|
loader.setState('atlas', 'started')
|
|
await fetchSpec()
|
|
assert.equal(coreSpec.paths['/api/v1/public/atlas/creatures'], undefined)
|
|
assert.equal(coreSpec.components.schemas.AtlasCreature, undefined)
|
|
})
|
|
|
|
test('a state change rebuilds the document rather than serving the cached one', async () => {
|
|
loader.setState('atlas', 'started')
|
|
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
|
|
|
|
loader.setState('atlas', 'disabled')
|
|
assert.equal((await fetchSpec()).paths['/api/v1/public/atlas/creatures'], undefined)
|
|
|
|
loader.setState('atlas', 'started')
|
|
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
|
|
})
|
|
|
|
test('an unreadable fragment costs that module its paths and nothing else', async () => {
|
|
// §4.4's bargain, applied here: one module's failure is never the site's. A
|
|
// docs page that 500s is strictly worse than one missing a module's routes.
|
|
loader.setState('atlas', 'started')
|
|
const file = path.join(moduleDir, 'swagger-fragment.json')
|
|
const good = fs.readFileSync(file, 'utf8')
|
|
fs.writeFileSync(file, 'not json {')
|
|
try {
|
|
reset()
|
|
const spec = await fetchSpec()
|
|
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
|
|
assert.ok(spec.paths['/api/v1/public/settings'], 'core\'s own paths must survive')
|
|
} finally {
|
|
fs.writeFileSync(file, good)
|
|
reset()
|
|
}
|
|
})
|
|
|
|
test('a module with no fragment at all is simply absent', async () => {
|
|
// Registering routes without documenting them is checked in the MODULE's CI,
|
|
// where the routes are known. Core cannot tell a module with no routes from
|
|
// one that forgot, so it does not guess.
|
|
loader.setState('atlas', 'started')
|
|
const file = path.join(moduleDir, 'swagger-fragment.json')
|
|
const good = fs.readFileSync(file, 'utf8')
|
|
fs.rmSync(file)
|
|
try {
|
|
reset()
|
|
const spec = await fetchSpec()
|
|
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
|
|
assert.ok(Object.keys(spec.paths).length > 100)
|
|
} finally {
|
|
fs.writeFileSync(file, good)
|
|
reset()
|
|
}
|
|
})
|
|
|
|
test('before the loader has scanned, core\'s own spec is the answer', () => {
|
|
// §7.6 makes every other accessor THROW when asked before modules.load(), so a
|
|
// mis-ordered boot cannot be mistaken for an empty install. A request handler
|
|
// is the exception: 500ing the docs page over it would be the wrong trade, and
|
|
// core's routes are the honest answer to "what is documented" at that point.
|
|
reset()
|
|
const stub = { isLoaded: () => false }
|
|
const original = Object.getOwnPropertyDescriptor(loader, 'isLoaded')
|
|
Object.defineProperty(loader, 'isLoaded', { value: stub.isLoaded, configurable: true })
|
|
try {
|
|
assert.equal(docsSpec(coreSpec), coreSpec)
|
|
} finally {
|
|
Object.defineProperty(loader, 'isLoaded', original)
|
|
reset()
|
|
}
|
|
})
|
|
|
|
test('the interactive UI is rebuilt per request, not bound to boot\'s document', async () => {
|
|
// Bound once at require time, /api/docs would show core's routes for the life
|
|
// of the process while /api/docs.json showed the merged set.
|
|
loader.setState('atlas', 'started')
|
|
reset()
|
|
const withModule = await fetch(`${base}/api/docs/`)
|
|
assert.equal(withModule.status, 200)
|
|
assert.match(await withModule.text(), /swagger/i)
|
|
})
|