feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.
**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.
`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.
**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.
**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.
Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.
Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.
- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
into a real core and regenerating the manifest
Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
189
server/scripts/frozenManifest.js
Normal file
189
server/scripts/frozenManifest.js
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
|
||||
//
|
||||
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
|
||||
// live Express stack and committing the result; a PR that moves a URL has to
|
||||
// commit the new manifest, which puts the change in front of a reviewer. After
|
||||
// phase 3 the seventy URLs this module serves are no longer in that file. They
|
||||
// are here, frozen the same way and by the same generator.
|
||||
//
|
||||
// **The module's routes are DERIVED, never listed.** This script is handed two
|
||||
// manifests generated from the SAME core at the pinned ref — one without this
|
||||
// module on the volume, one with — and the difference is what this module serves.
|
||||
// Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly
|
||||
// one place, `server/index.js`'s `registerRoutes` call, which is where an operator's
|
||||
// core reads it from too.
|
||||
//
|
||||
// Taking the difference rather than filtering by prefix buys the other half of
|
||||
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
|
||||
// A module that shadowed a core route, or whose mount displaced one, shows up
|
||||
// here as a removal or a change, not merely as an addition somewhere else. That
|
||||
// is the promise §1.2 makes to the shipped Android app and the Discord bot.
|
||||
//
|
||||
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
|
||||
// is generated from the module's own registrations against §2.4's stated tier
|
||||
// bases — the one place a constant could be wrong. Here there is ground truth: a
|
||||
// real core with this module loaded, reporting the URLs it actually serves. Every
|
||||
// route must have a documented operation and every documented operation must be a
|
||||
// route. That is the per-module form of core's standing rule, never ship a route
|
||||
// that isn't in the spec — and it is what stops a wrong constant in the generator
|
||||
// from producing a fragment that is internally consistent and describes nothing
|
||||
// core will ever serve.
|
||||
//
|
||||
// Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml):
|
||||
// node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json
|
||||
// node scripts/frozenManifest.js --before … --after … --check
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
const COMMENT =
|
||||
'Generated inventory of the URLs module-uo serves - the module half of the freeze ' +
|
||||
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
|
||||
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
|
||||
'Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.'
|
||||
|
||||
const key = (r) => `${r.method} ${r.path}`
|
||||
|
||||
/**
|
||||
* The module's routes, plus proof that core's own surface did not move.
|
||||
*
|
||||
* @param {object} before routes.manifest.json from core alone
|
||||
* @param {object} after routes.manifest.json from the same core with this module
|
||||
* @returns {{ added: object[], removed: string[] }}
|
||||
*/
|
||||
function diffManifests(before, after) {
|
||||
const added = []
|
||||
const removed = []
|
||||
|
||||
for (const tier of ['public', 'internal']) {
|
||||
const was = new Set((before[tier] || []).map(key))
|
||||
for (const route of after[tier] || []) {
|
||||
if (!was.has(key(route))) added.push({ ...route, tier })
|
||||
was.delete(key(route))
|
||||
}
|
||||
for (const gone of was) removed.push(`${tier} ${gone}`)
|
||||
}
|
||||
|
||||
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the module's routes the fragment fails to document, and vice versa.
|
||||
*
|
||||
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
|
||||
* because that is what core merges, so the manifest's paths are converted here
|
||||
* rather than the other way round.
|
||||
*/
|
||||
function coverage(added, fragment) {
|
||||
const documented = new Set()
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
|
||||
}
|
||||
|
||||
const undocumented = []
|
||||
for (const route of added) {
|
||||
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
|
||||
if (documented.has(oas)) documented.delete(oas)
|
||||
else undocumented.push(oas)
|
||||
}
|
||||
|
||||
// Whatever is left is documented and not served: a route that moved or was
|
||||
// deleted while its annotation stayed behind. Core's spec has no equivalent
|
||||
// check and grew four orphan tags and thirty-three orphan schemas because of it.
|
||||
return { undocumented, unserved: [...documented].sort() }
|
||||
}
|
||||
|
||||
function serialize(routes) {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$comment: COMMENT,
|
||||
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const arg = (name) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
const beforePath = arg('--before')
|
||||
const afterPath = arg('--after')
|
||||
if (!beforePath || !afterPath) {
|
||||
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
|
||||
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
|
||||
let failed = false
|
||||
|
||||
if (removed.length > 0) {
|
||||
process.stderr.write(
|
||||
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
|
||||
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (added.length === 0) {
|
||||
process.stderr.write(
|
||||
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
|
||||
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(added, fragment)
|
||||
if (undocumented.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
|
||||
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
if (unserved.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
|
||||
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A documented URL nobody serves is a client following the docs into a 404.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
|
||||
const contents = serialize(added)
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
|
||||
if (current !== contents) {
|
||||
process.stderr.write(
|
||||
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
|
||||
'commit the result so the move is reviewed rather than merged as mechanical.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST, contents)
|
||||
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }
|
||||
281
server/scripts/swaggerFragment.js
Normal file
281
server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §2.8 — the OpenAPI fragment ────────────────────────────────────────────
|
||||
//
|
||||
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||
// tags and schemas describing every route this module registers. Core merges the
|
||||
// fragments of *started* modules over its own committed spec at request time and
|
||||
// serves the result at `/api/docs.json` (docs/website/MODULE_API.md §6.1a).
|
||||
//
|
||||
// **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC
|
||||
// analysis — swagger-autogen parses `src/app.js` as text and follows the literal
|
||||
// `app.use(...)` chain. A module arrives on a volume after core was built, is
|
||||
// required by a filesystem loop, and mounts through `api.registerRoutes()`. There
|
||||
// is no literal mount for a parser to follow and core does not have our sources
|
||||
// anyway, so nothing core can run will ever describe these routes. The failure
|
||||
// mode is the dangerous one: swagger-autogen reports success and emits a spec
|
||||
// with the routes simply absent (§6.1, and core hit it twice — the spike's atlas
|
||||
// paths and PR 4's 407 deleted lines).
|
||||
//
|
||||
// ── Where the prefixes come from ───────────────────────────────────────────
|
||||
//
|
||||
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||
// relative to that router (`/status`, not `/api/v1/public/shard/status`) — nothing
|
||||
// in the file says where it hangs. §6.1a requires fully-qualified paths, because
|
||||
// core merges the fragment verbatim and never re-derives a prefix.
|
||||
//
|
||||
// So this script **runs the module's own `register()`** against a recording `api`
|
||||
// and reads the mounts back out of it. The prefix of every router is therefore the
|
||||
// prefix that router is actually registered under — the same call an operator's
|
||||
// core will make, not a table beside it that drifts the first time a mount moves.
|
||||
// Which router a recorded object came from is answered by `require.cache`: the
|
||||
// file whose `module.exports` IS this router.
|
||||
//
|
||||
// The two things that cannot be derived here are the tier base paths and the
|
||||
// extension slot's mount, because they are core's, not ours. They are §2.4's
|
||||
// normative table, quoted below — and they are not taken on trust: the frozen
|
||||
// route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real
|
||||
// core with this module loaded, and fails if a fragment path is not among them.
|
||||
// That check is where a wrong constant here dies.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||
const doc = require('../swagger/doc')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||
// router in core, behind its gate; the tier's own base path is core's and fixed
|
||||
// by §1.2's frozen URL surface.
|
||||
const TIER_BASE = {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
}
|
||||
|
||||
// MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core
|
||||
// may declare one — so a module filling it has to be told where it landed.
|
||||
const SLOT_MOUNT = {
|
||||
'admin.users.detail': '/api/v1/admin/users/:id',
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `register()` with a recording api and return `[{ file, prefix }]`.
|
||||
*
|
||||
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||
* against — because registration must not touch a database (§2.2 rule 1) and this
|
||||
* script is exactly the kind of no-database caller that rule exists for.
|
||||
*/
|
||||
function mountedRouters() {
|
||||
const register = require('../index')
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const fileOf = (router) => {
|
||||
for (const mod of Object.values(require.cache)) {
|
||||
if (mod && mod.exports === router) return mod.filename
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mounts = []
|
||||
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||
const base = TIER_BASE[tier]
|
||||
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||
}
|
||||
}
|
||||
for (const { slot, router } of api.record.extensions) {
|
||||
const mount = SLOT_MOUNT[slot]
|
||||
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
|
||||
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
|
||||
}
|
||||
|
||||
return mounts.map(({ router, prefix, what }) => {
|
||||
const file = fileOf(router)
|
||||
if (!file) {
|
||||
// A router built inline in index.js rather than required from its own file.
|
||||
// swagger-autogen needs a file to read, so there is nothing to generate from.
|
||||
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||
}
|
||||
return { file, prefix, what }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||
*
|
||||
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||
* annotation, and prints `Success` in green. Four of the annotations that came
|
||||
* across in slice 1 were broken that way and had been for as long as they had
|
||||
* existed in core: two `requestBody` literals a brace short, and two descriptions
|
||||
* whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to
|
||||
* `'` before evaluating, so either inside a single-quoted description ends the
|
||||
* string early). The visible result was a documented route missing its body, or a
|
||||
* typed query parameter demoted to an untyped one.
|
||||
*
|
||||
* So its diagnostics are captured and made fatal. This is the same class as every
|
||||
* other failure in this seam — a generator that reports success while silently
|
||||
* dropping what it was asked to describe (§6.1) — and the only difference is that
|
||||
* here the tool does say something. Nothing was listening.
|
||||
*/
|
||||
async function fragmentFor(file) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-swagger-'))
|
||||
const out = path.join(dir, 'fragment.json')
|
||||
|
||||
const complaints = []
|
||||
const realError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||
else realError(...args)
|
||||
}
|
||||
try {
|
||||
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||
// renders `components.schemas` from an EXAMPLE object rather than treating it
|
||||
// as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object',
|
||||
// properties: { type: { type: 'string', example: 'object' } } }`, a
|
||||
// meta-description of itself. That shape is uniform across core's committed
|
||||
// spec and is the house shape, so it is matched rather than fought. What is
|
||||
// NOT survivable is that it writes the result back into the object it was
|
||||
// handed: reusing one `doc` across six routers re-wraps the previous pass's
|
||||
// output five more times, and the fragment came out at 484 MB.
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||
...JSON.parse(JSON.stringify(doc)),
|
||||
info: { title: 'module-uo fragment', version: '0' },
|
||||
})
|
||||
} finally {
|
||||
console.error = realError
|
||||
}
|
||||
if (complaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`), and the prefix's own
|
||||
* params are moved to the FRONT of each operation's parameter list: swagger-autogen
|
||||
* orders parameters by where they appeared in the path it saw, which was only the
|
||||
* tail, so `/{id}/shard/link/{account}` would otherwise document (account, id).
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (q) => {
|
||||
const i = outer.indexOf(q && q.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
operation.parameters = params
|
||||
.map((q, i) => ({ q, i }))
|
||||
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||
.map(({ q }) => q)
|
||||
}
|
||||
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`,
|
||||
// a URL no client calls. Core's swagger.js normalizes the same way.
|
||||
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||
*
|
||||
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a allows
|
||||
* a fragment to carry. `info`, `servers` and the security schemes are the merged
|
||||
* document's, which is to say core's.
|
||||
*/
|
||||
async function build() {
|
||||
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||
let shared = false
|
||||
|
||||
for (const { file, prefix, what } of mountedRouters()) {
|
||||
const generated = await fragmentFor(file)
|
||||
// The tags and schemas are the SAME on every pass — each was handed the same
|
||||
// `doc` — so they are taken from whichever ran first rather than from `doc`
|
||||
// itself. What lands in the fragment has to be what swagger-autogen produced,
|
||||
// not what it was given: those two differ (see fragmentFor), and core merges
|
||||
// this file verbatim into a spec whose own schemas went through the same mill.
|
||||
if (!shared) {
|
||||
spec.tags = generated.tags || []
|
||||
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||
shared = true
|
||||
}
|
||||
const paths = prefixPaths(generated, prefix)
|
||||
const count = Object.keys(paths).length
|
||||
if (count === 0) {
|
||||
// An empty fragment is precisely what the silent drop looks like, so it is
|
||||
// a hard failure rather than a router that happens to declare no routes.
|
||||
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||
}
|
||||
for (const [p, item] of Object.entries(paths)) {
|
||||
if (spec.paths[p]) {
|
||||
throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||
}
|
||||
spec.paths[p] = item
|
||||
}
|
||||
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||
}
|
||||
|
||||
// Sorted, for the reason core sorts: swagger-autogen emits router-traversal
|
||||
// order, so moving a route between files would rewrite most of this committed
|
||||
// artifact even when the API is provably unchanged.
|
||||
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
return spec
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const spec = await build()
|
||||
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(FRAGMENT, json)
|
||||
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(FRAGMENT)) {
|
||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
|
||||
process.stderr.write(
|
||||
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
||||
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
||||
'so a stale one documents a URL surface this module does not serve.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${err.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, SLOT_MOUNT, FRAGMENT }
|
||||
Reference in New Issue
Block a user