// ── Merging an OpenAPI fragment into a spec ──────────────────────────────── // // The merge half of docs/website/MODULE_API.md §6.1's settled decision: routes // that reach the app through something swagger-autogen cannot statically follow // contribute a FRAGMENT, and core merges it. // // Two callers, one function, deliberately: // • build time — swagger/slotSpecs.js, for core's own extension-slot routers // (§1.9). Those are core routes that a static parse of app.js cannot see, // because the slot's router is created by registries.declareSlot() and filled // later. They belong in the committed `swagger-output.json`. // • request time (Phase 2 PR 6+) — an installed module's `swagger-fragment.json`, // merged over the committed spec for `/api/docs.json`. // // **Core always wins a key collision** (§6.1a). A fragment cannot redefine a path, // a tag or a schema core already declares; the collision is reported and the // fragment's version dropped. Merging is shallow-per-section — `paths`, `tags` // and `components.schemas` — because those are the only three sections a fragment // is allowed to carry, and a deeper merge would let a fragment reach into // `info`, `servers` or the security schemes. /** * Merge `fragment` into `spec`, in place, with core winning every collision. * * @param {object} spec the base spec — mutated * @param {object} fragment `{ paths?, tags?, components?: { schemas? } }` * @param {string} source who the fragment came from, for the collision message * @returns {string[]} the collisions that were dropped (empty when clean) */ function mergeFragment(spec, fragment, source) { const dropped = [] for (const [path, item] of Object.entries(fragment.paths || {})) { if (spec.paths[path]) { // Not a merge of the two path items: a fragment adding a METHOD to a core // path is the same overreach as replacing it, and the extension-slot // contract already says core owns the resource (§2.4). dropped.push(`path ${path}`) continue } spec.paths[path] = item } const tagNames = new Set((spec.tags || []).map((t) => t.name)) for (const tag of fragment.tags || []) { if (tagNames.has(tag.name)) continue // same tag, not a collision worth reporting spec.tags.push(tag) tagNames.add(tag.name) } const schemas = (fragment.components && fragment.components.schemas) || {} for (const [name, schema] of Object.entries(schemas)) { if (spec.components.schemas[name]) { dropped.push(`schema ${name}`) continue } spec.components.schemas[name] = schema } if (dropped.length > 0) { process.stderr.write( `swagger: dropped ${dropped.length} colliding key(s) from ${source} — core wins: ${dropped.join(', ')}\n`, ) } return dropped } /** * Re-root a fragment's paths under the prefix its router is actually mounted at. * * A fragment generated by pointing swagger-autogen at a router file alone has * paths relative to that router (`/shard/accounts`), because nothing in the file * says where it hangs. The prefix comes from the LIVE express stack rather than a * table, so it cannot drift the way a hand-written mount list would. * * Express path params (`:id`) become OpenAPI's (`{id}`) — swagger-autogen already * does that for the paths it generates, so the prefix has to match. */ 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 || {})) { paths[`${oas}${p}`] = orderParams(item, outer) } return { ...fragment, paths } } /** * Put the prefix's own path parameters first, in prefix order. * * swagger-autogen orders parameters by where they appear in the path it saw, and * the fragment's path is only the tail — so `/{id}/shard/link/{account}` comes * out as (account, id) rather than (id, account). Re-rooting the path has to * re-root the parameter order with it, or every slot route churns the committed * spec by a reorder that means nothing. */ function orderParams(item, outer) { for (const operation of Object.values(item)) { const params = operation && operation.parameters if (!Array.isArray(params)) continue const rank = (p) => { const i = outer.indexOf(p && p.name) return i === -1 ? outer.length : i } // Stable: only the prefix params move, and only ahead of the rest. operation.parameters = params .map((p, i) => ({ p, i })) .sort((a, b) => rank(a.p) - rank(b.p) || a.i - b.i) .map(({ p }) => p) } return item } module.exports = { mergeFragment, prefixPaths }