diff --git a/.changeset/lazy-buttons-invite.md b/.changeset/lazy-buttons-invite.md new file mode 100644 index 0000000000..27a2505699 --- /dev/null +++ b/.changeset/lazy-buttons-invite.md @@ -0,0 +1,27 @@ +--- +'@objectstack/rest': minor +--- + +REST 的 9 条 direct-mount 路由现在对 `RestServer` 可枚举,并随之进入 `GET {apiPath}/openapi.json` + +`package-routes.ts`(4 条 `packages.*`)与 `external-datasource-routes.ts`(5 条 +`datasources/:name/external/*`)一直绕过 `RouteManager`、直接挂在宿主 `IHttpServer` 上, +`RestServer` 因此不持有「这 9 条本次 boot 是否挂载」的事实。#5588(PR #5821)把 +`/openapi.json` 的 built-in 段改成服务器自身路由表的投影之后,这 9 条(其中 8 条在 +`rest-route-ledger.ts` 里是 `disposition: 'sdk'` 的真实能力)就不在生成的文档里 —— +用 `/openapi.json` 生成客户端的 consumer 拿不到它们,任何基于 `getRoutes()` 的自省也看不见。 + +现在两个 registrar 各自把「实际挂载的那一个数组」原样返回,由组合步骤 +(`mountAndRecordDirectRoutes`,`rest-api-plugin.ts` 调用)登记到 `RestServer` 上: + +- `RestServer.getRoutes()` 返回本次 boot 的**全部**已挂载路由,每条带 `source` + (`'route-manager' | 'direct-mount'`),类型为新导出的 `MountedRoute`; +- `/openapi.json` 的 built-in 段随之覆盖这 9 条,带各自的 summary / tags / 路径参数; +- 描述与挂载**同源**:返回的数组就是用来挂载的那个数组,不存在第二份手工清单。 + +诚实性两个方向都保持不变:某次 boot 没有 `package` 服务 ⇒ `packages.*` 既没挂载、 +也不出现在 `getRoutes()` 与文档里;federation 那 5 条无条件挂载(服务缺席时按请求答 503), +所以它们始终出现 —— 文档说的仍然只是「什么被挂载了」。 + +对使用者的影响:`getRoutes()` 的返回值多了 9 条(服务在场时)以及每条上的 `source` +字段;既有的 `method` / `path` / `handler` / `metadata` 读法不变。 diff --git a/packages/rest/src/direct-mount-composition.ts b/packages/rest/src/direct-mount-composition.ts new file mode 100644 index 0000000000..38566c0746 --- /dev/null +++ b/packages/rest/src/direct-mount-composition.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The composition step that mounts `@objectstack/rest`'s direct-mount + * registrars and records what they mounted (#5822). + * + * ## Why this is a module and not four blocks inside `rest-api-plugin.ts` + * + * It is the one place that knows WHICH registrars bypass `RouteManager` and + * under which conditions each is called. Before #5822 that knowledge existed + * twice: once in the plugin's `start()`, and once — copied by hand — in + * `rest-route-ledger.conformance.test.ts`, which re-invoked the two registrars + * against a mock server to enumerate them. A third registrar added to the + * plugin would have been mounted, undocumented and unguarded, with every test + * still green. Now the guard drives THIS function, so the set of registrars is + * declared once and the ledger sees whatever production mounts. + * + * ## The honesty contract, in both directions + * + * Each registrar returns the array it iterated to mount, and that array is what + * gets recorded on the `RestServer`. So: + * + * - a registrar this boot called ⇒ its routes are enumerable through + * `getRoutes()` and appear in `GET {apiPath}/openapi.json`; + * - a registrar this boot skipped (no `package` service) ⇒ nothing is + * recorded, nothing is documented, and the 404 a caller would get from that + * deployment is what the document says too. + * + * The service gate stays exactly where it was — here, at composition — and the + * record follows it rather than restating it. What is deliberately NOT recorded + * is any verdict about a service that a later phase could still contradict: the + * federation routes mount unconditionally and decide per request whether the + * `external-datasource` service is there (503 if not), so this file records + * them as mounted and says nothing about federation being available. + */ + +import type { PluginContext } from '@objectstack/core'; +import type { IHttpServer } from '@objectstack/spec/contracts'; +import type { PackageService } from '@objectstack/service-package'; +import { registerPackageRoutes, type PackageRoutesOptions } from './package-routes.js'; +import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; +import type { DirectMountRecorder } from './direct-mount.js'; + +export interface DirectMountComposition { + /** The host server the registrars mount on — the same one `RestServer` wraps. */ + server: IHttpServer; + /** Where the mounted facts land, so `getRoutes()` reports them. */ + recorder: DirectMountRecorder; + /** Service lookups (`package`) and the logger this step reports through. */ + ctx: PluginContext; + /** The configured API base, e.g. `/api/v1`. */ + versionedBase: string; + /** The `protocol` slice the package routes read registry packages through. */ + protocol?: PackageRoutesOptions['protocol']; + /** ADR-0006 project scoping — mirrors the package routes under the scoped base. */ + enableProjectScoping?: boolean; + /** `'auto'` (both bases) or `'required'` (scoped only). */ + projectResolution?: string; +} + +/** + * Mount the direct-mount registrars for this boot and record every route they + * mounted on {@link DirectMountComposition.recorder}. + */ +export function mountAndRecordDirectRoutes(composition: DirectMountComposition): void { + const { server, recorder, ctx, versionedBase, protocol } = composition; + const enableProjectScoping = composition.enableProjectScoping ?? false; + const projectResolution = composition.projectResolution ?? 'auto'; + + // Package management routes — only when the service backing them exists. + try { + const packageService = ctx.getService('package'); + if (packageService) { + // `required` scoping serves ONLY the scoped variant; `auto` serves + // both. Unchanged from the pre-#5822 plugin — expressed as the list + // of bases so the mount and the record cannot disagree about it. + const scopedBase = `${versionedBase}/environments/:environmentId`; + const bases = enableProjectScoping + ? (projectResolution === 'required' ? [scopedBase] : [versionedBase, scopedBase]) + : [versionedBase]; + for (const base of bases) { + recorder.recordDirectMountedRoutes( + registerPackageRoutes(server, packageService, base, { protocol }), + ); + } + ctx.logger.info('Package management routes registered'); + } + } catch (e) { + // Package service not available, skip + ctx.logger.debug('Package service not available, package routes skipped'); + } + + // External Datasource Federation routes (ADR-0015): catalog / draft / + // import / validate. Registered unconditionally — they degrade gracefully + // (503) when the `external-datasource` service is absent. + // NOTE: the datasource *lifecycle* routes (ADR-0015 Addendum: + // list / test / create / update / remove) moved to the private + // `@objectstack/datasource-admin` package, which registers its own. + try { + recorder.recordDirectMountedRoutes( + registerExternalDatasourceRoutes(server, ctx, versionedBase), + ); + ctx.logger.info('Datasource federation routes registered'); + } catch (e: any) { + // Nothing is recorded on this path: a registrar that threw part-way + // may have mounted some routes, and under-claiming a mounted route is + // the safe direction — a document that omits a live route is visibly + // incomplete, one that invents a dead route is not. + ctx.logger.warn('Datasource federation routes registration failed', { error: e?.message }); + } +} diff --git a/packages/rest/src/direct-mount-introspection.test.ts b/packages/rest/src/direct-mount-introspection.test.ts new file mode 100644 index 0000000000..f4727070f4 --- /dev/null +++ b/packages/rest/src/direct-mount-introspection.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DIRECT-MOUNT ROUTES ARE ENUMERABLE — and only when they are mounted (#5822). + * + * The nine routes `package-routes.ts` and `external-datasource-routes.ts` mount + * straight on the host `IHttpServer` used to be invisible to the server that + * owns the surface: `RestServer.getRoutes()` reported `RouteManager`'s table + * alone, so `GET {apiPath}/openapi.json` — which #5588 / PR #5821 made a + * projection of that table — could not describe them, and eight + * `disposition: 'sdk'` capabilities were missing from every generated client. + * + * What this file pins is the pair of directions that make the fix honest rather + * than merely complete. They are equally load-bearing: the first was the + * omission being repaired, the second is the reason #5821 chose the omission in + * the first place, and losing it would turn this change into the phantom-row + * defect #5588 fixed. + * + * mounted ⇒ enumerable, and documented + * not mounted ⇒ absent from both + * + * The second direction has a real trigger: the package registrar is gated on + * the `package` service, so a deployment without it serves no `packages.*` + * route — and must not document one. The federation registrar is NOT gated (it + * mounts always and answers 503 per request), so "mounted" is unconditional + * there and the document says so. + * + * Both are driven through the REAL composition: `mountAndRecordDirectRoutes` + * for the server-level facts, and `createRestApiPlugin().start()` for the + * end-to-end one, because the plugin is where a wiring regression would + * actually happen. + */ + +// Relative imports carry their `.js` extension: under `moduleResolution: +// nodenext` an extension-less one does not resolve, every symbol it names +// becomes `any`, and the callbacks over those symbols then report TS7006 — the +// pile that dominates this package's TEST_DEBT entry (AGENTS.md, Build & Test). +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; +import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; +import { registerPackageRoutes } from './package-routes.js'; +import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; +import { createRestApiPlugin } from './rest-api-plugin.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; +import { toTemplatePath } from './openapi-builtin-paths.js'; + +// --------------------------------------------------------------------------- +// harness +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createCapableProtocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({}), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + getData: vi.fn().mockResolvedValue({}), + createData: vi.fn().mockResolvedValue({ id: '1' }), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({ success: true }), + batchData: vi.fn().mockResolvedValue({}), + createManyData: vi.fn().mockResolvedValue([]), + updateManyData: vi.fn().mockResolvedValue([]), + deleteManyData: vi.fn().mockResolvedValue([]), + }; +} + +/** A `package` service stub — presence is what the composition gates on. */ +function packageServiceStub() { + return { list: vi.fn(), get: vi.fn(), publish: vi.fn(), delete: vi.fn() }; +} + +function createCtx(services: Record) { + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + }; +} + +/** The ledger's own list of the nine, split by registrar. */ +const LEDGER_DIRECT_MOUNT = REST_ROUTE_LEDGER.filter((e) => e.source === 'direct-mount').map((e) => e.route); +const PACKAGE_ROUTES = LEDGER_DIRECT_MOUNT.filter((r) => r.includes('/packages')); +const FEDERATION_ROUTES = LEDGER_DIRECT_MOUNT.filter((r) => r.includes('/external')); + +/** `VERB /path` for every route the server reports as mounted. */ +function mountedKeys(rest: RestServer): string[] { + return rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`); +} + +/** Boot a server + the direct-mount composition, with the given services. */ +function bootWith( + services: Record, + composition: { enableProjectScoping?: boolean; projectResolution?: string } = {}, +) { + const server = createMockServer(); + const rest = new RestServer(server as any, createCapableProtocol() as any, {} as any); + rest.registerRoutes(); + mountAndRecordDirectRoutes({ + server: server as any, + recorder: rest, + ctx: createCtx(services) as any, + versionedBase: '/api/v1', + ...composition, + }); + return { rest, server }; +} + +/** Drive the mounted `GET {base}/openapi.json` handler and read the body. */ +async function serveOpenApi(server: ReturnType, base = '/api/v1') { + const call = server.get.mock.calls.find((args: unknown[]) => args[0] === `${base}/openapi.json`); + expect(call, `GET ${base}/openapi.json must be mounted for this pin to mean anything`).toBeDefined(); + let body: any; + const res: any = { + status: () => res, + json: (b: any) => { body = b; }, + header: () => res, + send: () => {}, + }; + await (call as any)[1]({ headers: { host: 'example.test' }, params: {}, query: {}, path: `${base}/openapi.json` }, res); + return body; +} + +/** Is `VERB /wire/path` described in the document? */ +function documented(body: any, route: string): boolean { + const [method, wirePath] = route.split(' '); + return Boolean(body?.paths?.[toTemplatePath(wirePath)]?.[method.toLowerCase()]); +} + +// --------------------------------------------------------------------------- +// same source — the description IS the mount +// --------------------------------------------------------------------------- + +describe('#5822 — a registrar describes exactly what it mounted', () => { + it('package routes: the returned array matches the registration calls, one for one', () => { + const server = createMockServer(); + const returned = registerPackageRoutes(server as any, packageServiceStub() as any, '/api/v1'); + + const mounted: string[] = []; + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const [path] of (server[verb] as any).mock.calls) mounted.push(`${verb.toUpperCase()} ${path}`); + } + expect(returned.map((r) => `${r.method} ${r.path}`).sort()).toEqual(mounted.sort()); + // …and the handler in the description is the one that was mounted, not a + // look-alike: same function identity. + for (const route of returned) { + const verb = route.method.toLowerCase() as 'get' | 'post' | 'delete'; + const call = (server[verb] as any).mock.calls.find(([path]: [string]) => path === route.path); + expect(call?.[1]).toBe(route.handler); + } + }); + + it('federation routes: the returned array matches the registration calls, one for one', () => { + const server = createMockServer(); + const returned = registerExternalDatasourceRoutes( + server as any, + createCtx({}) as any, + '/api/v1', + ); + + const mounted: string[] = []; + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const [path] of (server[verb] as any).mock.calls) mounted.push(`${verb.toUpperCase()} ${path}`); + } + expect(returned.map((r) => `${r.method} ${r.path}`).sort()).toEqual(mounted.sort()); + }); +}); + +// --------------------------------------------------------------------------- +// direction 1 — mounted ⇒ enumerable ⇒ documented +// --------------------------------------------------------------------------- + +describe('#5822 — mounted direct-mount routes are enumerable and documented', () => { + it('getRoutes() reports all nine, marked as direct-mount', () => { + const { rest } = bootWith({ package: packageServiceStub() }); + const keys = mountedKeys(rest); + for (const route of LEDGER_DIRECT_MOUNT) { + expect(keys, `${route} is mounted but not enumerable`).toContain(route); + } + const marked = rest.getRoutes().filter((r) => r.source === 'direct-mount'); + expect(marked.map((r) => `${r.method} ${r.path}`).sort()).toEqual([...LEDGER_DIRECT_MOUNT].sort()); + // The RouteManager rows keep their own source, so the two stay auditable + // separately from one table. + expect(rest.getRoutes().some((r) => r.source === 'route-manager')).toBe(true); + }); + + it('the openapi built-in section carries all nine, ledger row by ledger row', async () => { + const { server } = bootWith({ package: packageServiceStub() }); + const body = await serveOpenApi(server); + for (const route of LEDGER_DIRECT_MOUNT) { + expect(documented(body, route), `${route} is mounted but not documented`).toBe(true); + } + // The registration's summary and tags travel with them, exactly as they do + // for a RouteManager route. + const list = body.paths['/api/v1/packages'].get; + expect(list.summary).toBe('List packages (registry + published)'); + expect(list.tags).toEqual(['packages']); + expect(body.paths['/api/v1/datasources/{name}/external/tables'].get.parameters.map((p: any) => p.name)) + .toEqual(['name']); + }); + + it('still publishes nothing the server does not mount', async () => { + // #5588's set relation, re-proven with the nine added: growing the document + // must not loosen the rule that produced it. + const { rest, server } = bootWith({ package: packageServiceStub() }); + const body = await serveOpenApi(server); + const mounted = new Set(rest.getRoutes().map((r) => toTemplatePath(r.path))); + for (const path of Object.keys(body.paths)) { + expect(mounted.has(path), `documented path '${path}' is not a mounted route`).toBe(true); + } + expect(Object.keys(body.paths).length).toBe(mounted.size); + }); + + it('files the project-scoped package mirror in the scoped document, not the unscoped one', async () => { + const server = createMockServer(); + const rest = new RestServer( + server as any, + createCapableProtocol() as any, + { api: { enableProjectScoping: true, projectResolution: 'auto' } } as any, + ); + rest.registerRoutes(); + mountAndRecordDirectRoutes({ + server: server as any, + recorder: rest, + ctx: createCtx({ package: packageServiceStub() }) as any, + versionedBase: '/api/v1', + enableProjectScoping: true, + projectResolution: 'auto', + }); + + expect(mountedKeys(rest)).toContain('GET /api/v1/environments/:environmentId/packages'); + + const unscoped = await serveOpenApi(server, '/api/v1'); + const scoped = await serveOpenApi(server, '/api/v1/environments/:environmentId'); + expect(unscoped.paths['/api/v1/environments/{environmentId}/packages']).toBeUndefined(); + expect(scoped.paths['/api/v1/environments/{environmentId}/packages'].get).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// direction 2 — not mounted ⇒ not enumerable ⇒ not documented +// --------------------------------------------------------------------------- + +describe('#5822 — an unmounted registrar is reported by nothing', () => { + it('a boot without the `package` service enumerates and documents no packages route', async () => { + const { rest, server } = bootWith({}); + const keys = mountedKeys(rest); + for (const route of PACKAGE_ROUTES) { + expect(keys, `${route} is not mounted on this boot and must not be enumerable`).not.toContain(route); + } + // Not merely absent from the table: absent from the wire too — the gate + // itself is unchanged, this pins that the record follows it. + const mountedPaths = server.get.mock.calls.map((args: unknown[]) => args[0]); + expect(mountedPaths).not.toContain('/api/v1/packages'); + + const body = await serveOpenApi(server); + for (const route of PACKAGE_ROUTES) { + expect(documented(body, route), `${route} is not mounted but is documented`).toBe(false); + } + }); + + it('the federation routes are still there — they mount unconditionally', async () => { + // The gate that does NOT exist, stated so the previous test cannot be + // "fixed" by dropping every direct-mount row when a service is missing: + // these five answer 503 per request instead, so they ARE mounted and are + // correctly documented even with no `external-datasource` service. + const { rest, server } = bootWith({}); + const keys = mountedKeys(rest); + for (const route of FEDERATION_ROUTES) { + expect(keys, `${route} is mounted unconditionally`).toContain(route); + } + const body = await serveOpenApi(server); + for (const route of FEDERATION_ROUTES) { + expect(documented(body, route)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// end to end — through the plugin that composes it in production +// --------------------------------------------------------------------------- + +describe('#5822 — the REST plugin records what it mounts', () => { + async function bootPlugin(services: Record) { + const server = createMockServer(); + const ctx = createCtx({ 'http.server': server, protocol: createCapableProtocol(), ...services }); + await createRestApiPlugin().start!(ctx as any); + return { server, ctx }; + } + + it('publishes the nine when the package service is there', async () => { + const { server } = await bootPlugin({ package: packageServiceStub() }); + const body = await serveOpenApi(server); + for (const route of LEDGER_DIRECT_MOUNT) { + expect(documented(body, route), `${route} is mounted by the plugin but not documented`).toBe(true); + } + }); + + it('publishes the five, and only the five, when it is not', async () => { + const { server } = await bootPlugin({}); + const body = await serveOpenApi(server); + for (const route of FEDERATION_ROUTES) expect(documented(body, route)).toBe(true); + for (const route of PACKAGE_ROUTES) expect(documented(body, route)).toBe(false); + }); +}); diff --git a/packages/rest/src/direct-mount.ts b/packages/rest/src/direct-mount.ts new file mode 100644 index 0000000000..5512be97a0 --- /dev/null +++ b/packages/rest/src/direct-mount.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DIRECT-MOUNT ROUTES — the ones a registrar puts straight on the host + * `IHttpServer`, and the seam that lets the server which owns the surface know + * they are there (#5822). + * + * ## What was wrong + * + * `packages/rest` mounts routes two ways. Most go through `RouteManager`, whose + * table is what `RestServer.getRoutes()` reports and what + * `openapi-builtin-paths.ts` turns into the published built-in section. Two + * registrars bypass it and call `server.get/post/...` directly + * (`package-routes.ts`, `external-datasource-routes.ts`) — nine routes, eight of + * them `disposition: 'sdk'` capabilities in `rest-route-ledger.ts`. + * + * They bypass it for a reason that is structural rather than deliberate, and + * worth recording because the ledger noted the question as unanswered: both are + * free functions over `IHttpServer`, composed by the PLUGIN + * (`rest-api-plugin.ts`) after `RestServer` has already registered its own + * routes, and each is service-gated at that composition step. `RouteManager` is + * a private field of `RestServer`; at the plugin's call site there was simply + * no manager in scope to register with — the `RestServer` instance itself was + * declared inside the `try` block that builds it. Nothing about these routes + * needs to avoid the manager, and nothing about the manager rejects them. + * + * The consequence surfaced when #5588 (PR #5821) made the OpenAPI built-in + * section a projection of the server's own route table: those nine could not be + * documented, because the server held no fact about whether they were mounted + * for this boot — and inventing one is exactly the phantom-row defect #5588 was + * repairing. Honest, but incomplete. + * + * ## The rule this module encodes + * + * A registrar declares its routes ONCE, as data, and hands that array to + * {@link mountDirectRoutes}, which mounts each row and returns the same array. + * What a caller records is therefore the very list that was iterated to mount — + * never a parallel table kept in sync by hand, which is the second source of + * truth #5822 rejected up front. `RestServer.recordDirectMountedRoutes` then + * folds those facts into `getRoutes()`, so one enumeration answers "what is + * mounted" for introspection, for the OpenAPI document, and for the route-ledger + * conformance guard. + * + * Both directions hold by construction, and both are pinned + * (`direct-mount-introspection.test.ts`): + * + * - **mounted ⇒ enumerable.** The array that mounted the routes is the array + * that is returned; a registrar cannot mount a route it does not describe. + * - **not mounted ⇒ not enumerable.** A registrar that is never called returns + * nothing, so a boot without `packageService` reports no `packages.*` route + * and publishes none. The service gate stays where it was — at the + * composition step — and the record follows it. + * + * One deliberate asymmetry: if the host server throws part-way through mounting, + * the exception propagates and the caller records NOTHING, even though some rows + * did mount. That under-claims rather than over-claims, the same direction + * `openapi-builtin-paths.ts` chose for per-operation `security` — absence is + * visible, fiction is not. + */ + +import type { IHttpServer, RouteHandler } from '@objectstack/core'; +import type { RouteHandlerMetadata } from '@objectstack/spec/system'; +import type { HttpMethod } from '@objectstack/spec/shared'; + +/** + * How a route reached the host server. + * + * Same vocabulary as `rest-route-ledger.ts`'s `source` column, restated here + * because that file is deliberately import-free (the client-side guard reads it + * as a relative source file). The ledger records the AUDITED disposition of a + * route; this records how a LIVE one got mounted. The conformance test is what + * holds the two spellings together. + */ +export type MountedRouteSource = 'route-manager' | 'direct-mount'; + +/** + * One route a registrar mounts straight on the host `IHttpServer`. + * + * Structurally the `RouteEntry` `RouteManager` keeps, so both kinds of route + * flow through the same introspection and the same OpenAPI projection without + * a translation step. + */ +export interface DirectMountedRoute { + /** HTTP verb as registered — `PATCH` is `PATCH`, never normalised to `PUT`. */ + method: HttpMethod; + /** Full wire path, with `:param` segments, under the base it was mounted at. */ + path: string; + handler: RouteHandler; + /** Carried into the OpenAPI document exactly like a RouteManager registration's. */ + metadata?: RouteHandlerMetadata['metadata']; +} + +/** + * What a direct-mount registrar's routes are reported to — implemented by + * `RestServer`, and narrow on purpose so a registrar's composition step depends + * on the recording seam rather than on the whole server. + */ +export interface DirectMountRecorder { + recordDirectMountedRoutes(routes: readonly DirectMountedRoute[]): void; +} + +/** + * Mount every route in `routes` on `server`, and return that same array. + * + * The identity of the returned value is the point: the caller's description of + * what is mounted cannot drift from what was mounted, because it IS what was + * mounted. + */ +export function mountDirectRoutes( + server: IHttpServer, + routes: T, +): T { + for (const route of routes) { + switch (route.method) { + case 'GET': + server.get(route.path, route.handler); + break; + case 'POST': + server.post(route.path, route.handler); + break; + case 'PUT': + server.put(route.path, route.handler); + break; + case 'PATCH': + server.patch(route.path, route.handler); + break; + case 'DELETE': + server.delete(route.path, route.handler); + break; + default: + // Same refusal as RouteManager.registerWithServer: a verb the + // host cannot mount must not be silently dropped into a + // description of what is mounted. + throw new Error(`Unsupported HTTP method: ${route.method}`); + } + } + return routes; +} diff --git a/packages/rest/src/external-datasource-routes.ts b/packages/rest/src/external-datasource-routes.ts index a8cdba48ac..1052c2b366 100644 --- a/packages/rest/src/external-datasource-routes.ts +++ b/packages/rest/src/external-datasource-routes.ts @@ -4,6 +4,7 @@ import type { PluginContext } from '@objectstack/core'; import type { IExternalDatasourceService, IHttpServer } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; +import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js'; /** * External Datasource Federation REST routes (ADR-0015 §6.2). @@ -24,6 +25,10 @@ import { sendOk, sendError } from '@objectstack/types'; * into the private `@objectstack/datasource-admin` package, which registers * them via its own `registerDatasourceAdminRoutes`. * + * Returns the routes it mounted so the caller can record them on the + * `RestServer` that owns the surface (#5822): the returned array IS the array + * that was iterated to mount. See `direct-mount.ts`. + * * Every body is built by the shared `sendOk` / `sendError`, in the envelope * `BaseResponseSchema` declares (#3843, consolidated in #3973). Before #3843 * this module emitted the pre-#3675 `{ error: '' }`, with the message a @@ -59,7 +64,7 @@ export function registerExternalDatasourceRoutes( server: IHttpServer, ctx: PluginContext, basePath = '/api/v1', -): void { +): readonly DirectMountedRoute[] { const ext = `${basePath}/datasources/:name/external`; /** @@ -91,81 +96,123 @@ export function registerExternalDatasourceRoutes( const refused = (res: any, err: unknown) => sendError(res, 400, 'EXTERNAL_DATASOURCE_ERROR', err instanceof Error ? err.message : String(err)); - // List remote tables (optionally filtered by ?schema=). - server.get(`${ext}/tables`, async (req: any, res: any) => { - const svc = externalService(); - if (!svc?.listRemoteTables) return unavailable(res); - try { - const schema = typeof req.query?.schema === 'string' ? req.query.schema : undefined; - const tables = await svc.listRemoteTables(req.params.name, { schema }); - sendOk(res, { tables }); - } catch (err) { - refused(res, err); - } - }); + /** + * ONE declaration of this registrar's surface (#5822): the array below is + * what gets mounted on the host server AND what is handed back as the + * description of what was mounted — no second table to keep in sync. See + * `direct-mount.ts`. + * + * Note what this list does NOT claim: it says these five routes are MOUNTED, + * which they unconditionally are (the plugin registers them whether or not + * federation is wired in), never that the `external-datasource` service is + * present. That verdict stays per-request, inside each handler, where it can + * still answer 503 — the boot must not record it (AGENTS.md, "never record a + * verdict the boot can still contradict"). + */ + const routes: readonly DirectMountedRoute[] = [ + // List remote tables (optionally filtered by ?schema=). + { + method: 'GET', + path: `${ext}/tables`, + metadata: { summary: 'List remote tables on an external datasource', tags: ['datasources'] }, + handler: async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.listRemoteTables) return unavailable(res); + try { + const schema = typeof req.query?.schema === 'string' ? req.query.schema : undefined; + const tables = await svc.listRemoteTables(req.params.name, { schema }); + sendOk(res, { tables }); + } catch (err) { + refused(res, err); + } + }, + }, - // Generate an Object draft (structured + *.object.ts source) from a table. - server.post(`${ext}/tables/:remote/draft`, async (req: any, res: any) => { - const svc = externalService(); - if (!svc?.generateObjectDraft) return unavailable(res); - try { - const draft = await svc.generateObjectDraft( - req.params.name, - req.params.remote, - (req.body as Record) ?? {}, - ); - sendOk(res, { draft }); - } catch (err) { - refused(res, err); - } - }); + // Generate an Object draft (structured + *.object.ts source) from a table. + { + method: 'POST', + path: `${ext}/tables/:remote/draft`, + metadata: { summary: 'Generate an Object draft from a remote table', tags: ['datasources'] }, + handler: async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.generateObjectDraft) return unavailable(res); + try { + const draft = await svc.generateObjectDraft( + req.params.name, + req.params.remote, + (req.body as Record) ?? {}, + ); + sendOk(res, { draft }); + } catch (err) { + refused(res, err); + } + }, + }, - // Import a remote table as a live (runtime-origin) federated object so it's - // immediately queryable — the "Import as Object" action (ADR-0015 Addendum). - // 503 when the service is absent; 400 when import is refused (e.g. read-only - // metadata store) or the remote table is missing. - server.post(`${ext}/tables/:remote/import`, async (req: any, res: any) => { - const svc = externalService(); - if (!svc?.importObject) return unavailable(res); - try { - const result = await svc.importObject( - req.params.name, - req.params.remote, - (req.body as Record) ?? {}, - ); - sendOk(res, { object: result }, 201); - } catch (err) { - sendError( - res, - 400, - 'EXTERNAL_IMPORT_ERROR', - err instanceof Error ? err.message : String(err), - ); - } - }); + // Import a remote table as a live (runtime-origin) federated object so it's + // immediately queryable — the "Import as Object" action (ADR-0015 Addendum). + // 503 when the service is absent; 400 when import is refused (e.g. read-only + // metadata store) or the remote table is missing. + { + method: 'POST', + path: `${ext}/tables/:remote/import`, + metadata: { summary: 'Import a remote table as a federated object', tags: ['datasources'] }, + handler: async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.importObject) return unavailable(res); + try { + const result = await svc.importObject( + req.params.name, + req.params.remote, + (req.body as Record) ?? {}, + ); + sendOk(res, { object: result }, 201); + } catch (err) { + sendError( + res, + 400, + 'EXTERNAL_IMPORT_ERROR', + err instanceof Error ? err.message : String(err), + ); + } + }, + }, - // Refresh and return the cached catalog snapshot. - server.post(`${ext}/refresh-catalog`, async (req: any, res: any) => { - const svc = externalService(); - if (!svc?.refreshCatalog) return unavailable(res); - try { - const catalog = await svc.refreshCatalog(req.params.name); - sendOk(res, { catalog }); - } catch (err) { - refused(res, err); - } - }); + // Refresh and return the cached catalog snapshot. + { + method: 'POST', + path: `${ext}/refresh-catalog`, + metadata: { summary: 'Refresh the external datasource catalog snapshot', tags: ['datasources'] }, + handler: async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.refreshCatalog) return unavailable(res); + try { + const catalog = await svc.refreshCatalog(req.params.name); + sendOk(res, { catalog }); + } catch (err) { + refused(res, err); + } + }, + }, - // Validate the federated objects on this datasource. - server.post(`${ext}/validate`, async (req: any, res: any) => { - const svc = externalService(); - if (!svc?.validateAll) return unavailable(res); - try { - const report = await svc.validateAll(); - const results = (report.results ?? []).filter((r) => r.datasource === req.params.name); - sendOk(res, { ok: results.every((r) => r.ok), results }); - } catch (err) { - refused(res, err); - } - }); + // Validate the federated objects on this datasource. + { + method: 'POST', + path: `${ext}/validate`, + metadata: { summary: 'Validate the federated objects on a datasource', tags: ['datasources'] }, + handler: async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.validateAll) return unavailable(res); + try { + const report = await svc.validateAll(); + const results = (report.results ?? []).filter((r) => r.datasource === req.params.name); + sendOk(res, { ok: results.every((r) => r.ok), results }); + } catch (err) { + refused(res, err); + } + }, + }, + ]; + + return mountDirectRoutes(server, routes); } diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index d385646d89..cbb66d0d00 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -8,6 +8,11 @@ export type { RestProtocol } from './rest-server.js'; // Route Management export { RouteManager, RouteGroupBuilder } from './route-manager.js'; export type { RouteEntry } from './route-manager.js'; +// What `RestServer.getRoutes()` answers with (#5822): every route mounted for +// this boot, RouteManager's and the direct-mount registrars' alike, each +// carrying the `source` that says which. +export type { MountedRoute } from './rest-server.js'; +export type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; // REST API Plugin export { createRestApiPlugin } from './rest-api-plugin.js'; diff --git a/packages/rest/src/openapi-builtin-paths.ts b/packages/rest/src/openapi-builtin-paths.ts index 6639ce43b6..c920ddd8f2 100644 --- a/packages/rest/src/openapi-builtin-paths.ts +++ b/packages/rest/src/openapi-builtin-paths.ts @@ -38,12 +38,18 @@ * * ## The source of truth, and why it is this one * - * `RouteManager.getAll()` — the very table the router matches requests + * `RestServer.getRoutes()` — the very tables the router matches requests * against, read at REQUEST time. That is what makes phantom rows structurally - * impossible rather than merely unlikely: a route absent from the table is + * impossible rather than merely unlikely: a route absent from the tables is * absent from the document, and vice versa. It is also what makes the prefix - * correct for free, since the paths in that table are the wire paths the - * server registered under its configured base. + * correct for free, since the paths there are the wire paths the server + * registered under its configured base. + * + * Since #5822 that answer covers both ways this package mounts a route: + * `RouteManager`'s own table, and the routes the bypassing registrars reported + * after mounting them (`direct-mount.ts`). The second half kept the same + * standard rather than relaxing it — a registrar reports the array it iterated + * to mount, so the document still describes only what a boot actually mounted. * * The four batch routes are the standing demonstration: they register only * when the protocol implements `batchData` / `createManyData` / … , so on a @@ -52,26 +58,32 @@ * * ## COVERAGE — what is in the section and what is not (#5588 ⑤) * - * IN: every route this `RestServer` mounted through its own `RouteManager` - * under the base being served — all families in `rest-route-ledger.ts` whose - * `source` is `route-manager` (discovery, openapi, metadata, ui, crud, - * data-actions, search, email, forms, sharing, reports, approvals, analytics, - * security, batch) — regardless of ledger `disposition`. Disposition records - * whether the SDK expresses a route, which is a different question from - * whether the HTTP surface exists; `server-only` and `public` routes are - * served and therefore documented. On a default boot that is 78 routes, - * against the 10 operations the old section described. + * IN: every route this `RestServer` knows is mounted under the base being + * served — regardless of ledger `disposition`. Disposition records whether the + * SDK expresses a route, which is a different question from whether the HTTP + * surface exists; `server-only` and `public` routes are served and therefore + * documented. Two ways in, both real: + * + * - Everything registered through its own `RouteManager` — all families in + * `rest-route-ledger.ts` whose `source` is `route-manager` (discovery, + * openapi, metadata, ui, crud, data-actions, search, email, forms, sharing, + * reports, approvals, analytics, security, batch). On a default boot that is + * 78 routes, against the 10 operations the old section described. + * - Since #5822, the `direct-mount` registrars' routes (`package-routes.ts`, + * `external-datasource-routes.ts`, 9 ledger rows, 8 of them SDK + * capabilities) — but ONLY the ones this boot actually mounted. They + * register straight on `IHttpServer`, bypassing `RouteManager`, and the + * package registrar is service-gated (`packageService`) by + * `rest-api-plugin.ts`; each registrar now returns the array it iterated to + * mount and the composition step records it on this server, so the fact + * comes from the actual call rather than from a second, hand-kept table. A + * deployment with no `package` service documents no `packages.*` route, + * which is the same standard the rest of this document is held to — see + * `direct-mount.ts`, and `direct-mount-introspection.test.ts` for both + * directions pinned. * * OUT, deliberately and namedly: * - * - The two `direct-mount` registrars (`package-routes.ts`, - * `external-datasource-routes.ts`, 9 ledger rows). They register straight - * on `IHttpServer`, bypassing `RouteManager`, and each is service-gated - * (`packageService` / the external-datasource service) by - * `rest-api-plugin.ts`. This server therefore holds NO fact about whether - * they are mounted for this boot — and inventing one is precisely the - * defect being repaired. Making them enumerable is a change to those - * registrars, not to this document. * - Everything mounted by other packages: the runtime dispatcher's root * routes (including the `/.well-known/objectstack` the old section * misfiled under `/api`), `service-storage`, `service-i18n`. Not rest's diff --git a/packages/rest/src/package-routes.ts b/packages/rest/src/package-routes.ts index 9e85dcb5a5..f20c0f6184 100644 --- a/packages/rest/src/package-routes.ts +++ b/packages/rest/src/package-routes.ts @@ -4,6 +4,7 @@ import { IHttpServer } from '@objectstack/core'; import type { PackageService } from '@objectstack/service-package'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; +import { mountDirectRoutes, type DirectMountedRoute } from './direct-mount.js'; /** * Options for package route registration. @@ -33,6 +34,13 @@ export interface PackageRoutesOptions { * Register package management API routes * * Provides endpoints for publishing, retrieving, and managing packages. + * + * Returns the routes it mounted, so the caller can record them on the + * `RestServer` that owns the surface (#5822) — the returned array IS the array + * that was iterated to mount, never a second, hand-kept table. A boot without a + * `package` service never calls this registrar, so nothing is mounted and + * nothing is reported; see `direct-mount.ts`. + * * Routes: * - POST /api/v1/packages/publish - Publish a package to the marketplace registry * - GET /api/v1/packages - List all packages (merges registry + database) @@ -78,11 +86,22 @@ export function registerPackageRoutes( packageService: PackageService, basePath: string = '/api/v1', options: PackageRoutesOptions = {}, -) { +): readonly DirectMountedRoute[] { const packagesPath = `${basePath}/packages`; + /** + * ONE declaration of this registrar's surface (#5822): the array below is + * what gets mounted on the host server AND what is handed back as the + * description of what was mounted. There is no second table to keep in sync — + * see `direct-mount.ts` for why that identity is the whole point. + */ + const routes: readonly DirectMountedRoute[] = [ // POST /api/v1/packages/publish - Publish a package to the marketplace - server.post(`${packagesPath}/publish`, async (req, res) => { + { + method: 'POST', + path: `${packagesPath}/publish`, + metadata: { summary: 'Publish a package to the marketplace registry', tags: ['packages'] }, + handler: async (req, res) => { try { const { manifest, metadata } = req.body || {}; @@ -113,10 +132,15 @@ export function registerPackageRoutes( } catch (error) { sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message); } - }); + }, + }, // GET /api/v1/packages - List all packages (merges registry + database) - server.get(packagesPath, async (_req, res) => { + { + method: 'GET', + path: packagesPath, + metadata: { summary: 'List packages (registry + published)', tags: ['packages'] }, + handler: async (_req, res) => { try { // Merge two sources: // 1. Registry packages (in-memory, loaded at boot via defineStack/AppPlugin) @@ -166,10 +190,15 @@ export function registerPackageRoutes( } catch (error) { sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message); } - }); + }, + }, // GET /api/v1/packages/:id - Get a specific package - server.get(`${packagesPath}/:id`, async (req, res) => { + { + method: 'GET', + path: `${packagesPath}/:id`, + metadata: { summary: 'Get a package by id', tags: ['packages'] }, + handler: async (req, res) => { try { const packageId = req.params.id; const version = req.query?.version || 'latest'; @@ -201,10 +230,15 @@ export function registerPackageRoutes( } catch (error) { sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message); } - }); + }, + }, // DELETE /api/v1/packages/:id - Delete a package - server.delete(`${packagesPath}/:id`, async (req, res) => { + { + method: 'DELETE', + path: `${packagesPath}/:id`, + metadata: { summary: 'Delete a package', tags: ['packages'] }, + handler: async (req, res) => { try { const packageId = req.params.id; const version = req.query?.version; @@ -261,5 +295,9 @@ export function registerPackageRoutes( } catch (error) { sendError(res, 500, 'INTERNAL_ERROR', (error as Error).message); } - }); + }, + }, + ]; + + return mountDirectRoutes(server, routes); } diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 6f9a086301..8160eedfff 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -3,9 +3,7 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver, RestEnvRegistry } from './rest-server.js'; import { RestServerConfig } from '@objectstack/spec/api'; -import { registerPackageRoutes } from './package-routes.js'; -import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; -import type { PackageService } from '@objectstack/service-package'; +import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; import type { ResolvedSettingValue } from '@objectstack/spec/system'; // [#4251 B4] Every slot this composition root resolves, named by its contract. // The lookup already returns the slot's contract; annotating the result `any` @@ -349,8 +347,15 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { const serviceExistsProvider = (name: string): boolean => { try { return ctx.getService(name) != null; } catch { return false; } }; + // Declared out here, not inside the `try`: the direct-mount + // composition below reports the routes it mounts back to this + // instance (#5822), and the manager it needs to reach lives on it. + // That scoping — the RestServer being invisible at the registrars' + // call site — is the whole reason those nine routes bypassed + // `RouteManager` in the first place. + let restServer: RestServer | undefined; try { - const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider); + restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); @@ -380,41 +385,23 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false; const projectResolution = config.api?.api?.projectResolution ?? 'auto'; - // Register package management routes if the service is available. - try { - const packageService = ctx.getService('package'); - if (packageService) { - if (enableProjectScoping && projectResolution === 'required') { - // Only register the scoped variant - registerPackageRoutes(server, packageService, `${versionedBase}/environments/:environmentId`, { - protocol, - }); - } else { - registerPackageRoutes(server, packageService, versionedBase, { protocol }); - if (enableProjectScoping) { - registerPackageRoutes(server, packageService, `${versionedBase}/environments/:environmentId`, { - protocol, - }); - } - } - ctx.logger.info('Package management routes registered'); - } - } catch (e) { - // Package service not available, skip - ctx.logger.debug('Package service not available, package routes skipped'); - } - - // External Datasource Federation routes (ADR-0015): catalog / draft / - // import / validate. Registered unconditionally — they degrade - // gracefully (503) when the `external-datasource` service is absent. - // NOTE: the datasource *lifecycle* routes (ADR-0015 Addendum: - // list / test / create / update / remove) moved to the private - // `@objectstack/datasource-admin` package, which registers its own. - try { - registerExternalDatasourceRoutes(server, ctx, versionedBase); - ctx.logger.info('Datasource federation routes registered'); - } catch (e: any) { - ctx.logger.warn('Datasource federation routes registration failed', { error: e?.message }); + // The RouteManager-bypassing registrars (package management, + // external-datasource federation), mounted AND recorded in one + // step (#5822) so `restServer.getRoutes()` — and therefore + // `GET {apiPath}/openapi.json` — describes this boot's whole + // surface. `direct-mount-composition.ts` owns which registrars + // those are; the route-ledger conformance guard drives the same + // function, so a registrar added there cannot slip past it. + if (restServer) { + mountAndRecordDirectRoutes({ + server, + recorder: restServer, + ctx, + versionedBase, + protocol, + enableProjectScoping, + projectResolution, + }); } } }; diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts index cd02ffa037..ef0e24d78d 100644 --- a/packages/rest/src/rest-route-ledger.conformance.test.ts +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -12,10 +12,20 @@ * 2. A ledger entry for a route the server no longer mounts — the ledger * went stale. * - * Enumeration is real on BOTH sources: `route-manager` rows against - * `RestServer.getRoutes()` (the introspection seam RouteManager already - * provides), and `direct-mount` rows against the registration calls the two - * bypass registrars make on a mock `IHttpServer` — no pinned-by-hand list. + * Enumeration is real, and since #5822 there is exactly ONE of it: + * `RestServer.getRoutes()`, asked of a server booted the way production boots + * it — its own `registerRoutes()` plus `mountAndRecordDirectRoutes`, the same + * composition step `rest-api-plugin.ts` calls. Each row carries the `source` + * that says how it was mounted, so both ledger sources are still audited + * separately, from one table. + * + * That replaced a second enumeration: `direct-mount` rows used to be captured + * by re-invoking the two bypass registrars against a mock `IHttpServer` here, + * because the server held no record of them. Two consequences of the merge are + * worth knowing — the guard now fails if a direct-mount route is mounted but + * NOT recorded (previously invisible), and a third bypass registrar added to + * the composition step is enumerated here automatically instead of needing this + * file to be taught about it. * * The third direction — "every `sdk` row names a client method that exists" — * lives in `packages/client/src/rest-route-ledger-coverage.test.ts`, next to @@ -25,11 +35,13 @@ * dependency closure). */ +// `.js` on the relative imports: without it `moduleResolution: nodenext` does +// not resolve them, every imported symbol degrades to `any`, and the callbacks +// below turn into a TS7006 pile in this package's TEST_DEBT entry. import { describe, it, expect, vi } from 'vitest'; -import { RestServer } from './rest-server'; -import { registerPackageRoutes } from './package-routes'; -import { registerExternalDatasourceRoutes } from './external-datasource-routes'; -import { REST_ROUTE_LEDGER } from './rest-route-ledger'; +import { RestServer } from './rest-server.js'; +import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; +import { REST_ROUTE_LEDGER } from './rest-route-ledger.js'; /** Minimal IHttpServer mock that records registrations. */ function createMockServer() { @@ -67,25 +79,49 @@ function createCapableProtocol() { }; } -/** `VERB /path` keys for every route RouteManager holds at default config. */ -function enumerateRouteManagerRoutes(): Set { - const rest = new RestServer(createMockServer() as any, createCapableProtocol() as any, {} as any); - rest.registerRoutes(); - return new Set(rest.getRoutes().map((r) => `${r.method.toUpperCase()} ${r.path}`)); +/** + * A plugin context with the services the direct-mount composition gates on. + * + * `package` is present, so the package registrar runs — this guard audits the + * ledger's whole direct-mount surface, and a boot without the service mounts a + * strict subset of it (that direction is pinned in + * `direct-mount-introspection.test.ts`, not here). + */ +function createDirectMountCtx() { + return { + getService: (name: string) => { + if (name === 'package') return { list: vi.fn(), get: vi.fn(), publish: vi.fn(), delete: vi.fn() }; + return undefined; + }, + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; } -/** `VERB /path` keys captured from the two RouteManager-bypassing registrars. */ -function enumerateDirectMountRoutes(): Set { +/** + * A server booted the way production boots it: its own routes, then the + * direct-mount composition — the SAME function `rest-api-plugin.ts` calls. + */ +function bootRestServer(): RestServer { const server = createMockServer(); - registerPackageRoutes(server as any, {} as any); - registerExternalDatasourceRoutes(server as any, { getService: () => undefined } as any); - const keys = new Set(); - for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { - for (const call of (server[verb] as any).mock.calls) { - keys.add(`${verb.toUpperCase()} ${call[0]}`); - } - } - return keys; + const rest = new RestServer(server as any, createCapableProtocol() as any, {} as any); + rest.registerRoutes(); + mountAndRecordDirectRoutes({ + server: server as any, + recorder: rest, + ctx: createDirectMountCtx() as any, + versionedBase: '/api/v1', + }); + return rest; +} + +/** `VERB /path` keys for every route the booted server reports for one source. */ +function enumerateMountedRoutes(source: 'route-manager' | 'direct-mount'): Set { + return new Set( + bootRestServer() + .getRoutes() + .filter((r) => r.source === source) + .map((r) => `${r.method.toUpperCase()} ${r.path}`), + ); } function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set { @@ -95,7 +131,7 @@ function ledgerKeys(source: 'route-manager' | 'direct-mount'): Set { describe('REST route ledger ↔ RouteManager enumeration', () => { it('every RouteManager-registered route has a ledger entry', () => { const ledger = ledgerKeys('route-manager'); - const missing = [...enumerateRouteManagerRoutes()].filter((k) => !ledger.has(k)); + const missing = [...enumerateMountedRoutes('route-manager')].filter((k) => !ledger.has(k)); expect( missing, `REST routes with no rest-route-ledger entry: ${missing.join(', ')}. ` + @@ -104,7 +140,7 @@ describe('REST route ledger ↔ RouteManager enumeration', () => { }); it('every route-manager ledger entry is a live RouteManager route', () => { - const live = enumerateRouteManagerRoutes(); + const live = enumerateMountedRoutes('route-manager'); const stale = [...ledgerKeys('route-manager')].filter((k) => !live.has(k)); expect( stale, @@ -117,7 +153,7 @@ describe('REST route ledger ↔ RouteManager enumeration', () => { describe('REST route ledger ↔ direct-mount registrars', () => { it('every directly-mounted route has a ledger entry', () => { const ledger = ledgerKeys('direct-mount'); - const missing = [...enumerateDirectMountRoutes()].filter((k) => !ledger.has(k)); + const missing = [...enumerateMountedRoutes('direct-mount')].filter((k) => !ledger.has(k)); expect( missing, `Directly-mounted routes with no rest-route-ledger entry: ${missing.join(', ')}.`, @@ -125,13 +161,25 @@ describe('REST route ledger ↔ direct-mount registrars', () => { }); it('every direct-mount ledger entry is really registered by its registrar', () => { - const live = enumerateDirectMountRoutes(); + const live = enumerateMountedRoutes('direct-mount'); const stale = [...ledgerKeys('direct-mount')].filter((k) => !live.has(k)); expect( stale, `direct-mount rest-route-ledger entries no registrar mounts: ${stale.join(', ')}.`, ).toEqual([]); }); + + it('reports them through the server, not through a parallel enumeration', () => { + // The #5822 convergence, asserted rather than described: what the ledger is + // compared against is the SERVER's own answer. Before, this suite ran a + // second enumeration (mock-server registration capture) precisely because + // `getRoutes()` could not see these nine — so a regression that stopped + // recording them would have kept this file green while emptying the + // OpenAPI document. + const directMounted = bootRestServer().getRoutes().filter((r) => r.source === 'direct-mount'); + expect(directMounted.length).toBe(ledgerKeys('direct-mount').size); + expect(directMounted.every((r) => typeof r.handler === 'function')).toBe(true); + }); }); describe('REST route ledger hygiene', () => { diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 37b5aa031d..05b459a879 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -22,11 +22,17 @@ * `/api/v1/environments/:environmentId` (rest-server.ts registerRoutes); the * mirror is a mechanical duplication and is deliberately not re-ledgered. * - * SOURCES. `route-manager` rows are enumerable via `RestServer.getRoutes()`. - * `direct-mount` rows come from the two registrars that bypass RouteManager - * and register straight on `IHttpServer` (`package-routes.ts`, - * `external-datasource-routes.ts`) — the conformance test enumerates those by - * capturing a mock server's registration calls, so they are guarded too. + * SOURCES. Both are enumerable through `RestServer.getRoutes()`, and since + * #5822 that is the ONLY enumeration the conformance test runs. `route-manager` + * rows are the ones this server registered itself; `direct-mount` rows come + * from the two registrars that bypass RouteManager and register straight on + * `IHttpServer` (`package-routes.ts`, `external-datasource-routes.ts`), which + * now return what they mounted so the composition step can record it (see + * `direct-mount.ts`). Each mounted route reports its own `source`, so the two + * halves of this ledger stay audited separately from one table. Before that, + * the direct-mount half was enumerated by capturing a mock server's + * registration calls — a second enumeration that guarded the ledger but left + * the routes out of `/openapi.json` and every other runtime introspection. * * NOT COVERED HERE (the third surface): services that autonomously mount * routes on the host `IHttpServer` — `service-storage` (`storage-routes.ts`, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bc07aaf9f2..d006ac22fc 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11,7 +11,8 @@ import { INTERNAL_ERROR_MESSAGE, } from '@objectstack/types'; import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; -import { RouteManager } from './route-manager.js'; +import { RouteManager, type RouteEntry } from './route-manager.js'; +import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; import type { FieldErrorCode } from '@objectstack/spec/api'; @@ -1601,10 +1602,33 @@ export interface RestRequestEnvResolver { resolveRequestEnvironmentId(req: unknown): Promise; } +/** + * One route this server knows is mounted, and how it got there (#5822). + * + * `RouteEntry` plus `source`: `route-manager` for the routes this server + * registered itself, `direct-mount` for the ones a bypassing registrar mounted + * on the same host server and reported through + * {@link RestServer.recordDirectMountedRoutes}. Both are equally mounted and + * equally documented; the column exists because the route ledger audits them + * per source, and because a debugging reader deserves to know which registrar + * to look in. + */ +export interface MountedRoute extends RouteEntry { + readonly source: MountedRouteSource; +} + export class RestServer { private protocol: RestProtocol; private config: NormalizedRestServerConfig; private routeManager: RouteManager; + /** + * Routes mounted on the SAME host server by a registrar that bypasses + * `RouteManager`, as reported by the composition step that called it + * (#5822). Facts, not intentions: a registrar the boot never called + * contributes nothing here, so `getRoutes()` and the OpenAPI document stay + * silent about it. See `direct-mount.ts`. + */ + private readonly directMountedRoutes: MountedRoute[] = []; private kernelManager?: RestKernelManager; private envRegistry?: RestEnvRegistry; /** @@ -3287,11 +3311,13 @@ export class RestServer { // route the router will match: same table, read at request // time, so the prefix follows `apiPath`, the verbs are the // registered ones, and a route that is not mounted cannot be - // described. `routeManager.getAll()` is the whole surface — - // the filtering to THIS base (and away from the project- + // described. `getRoutes()` is the whole surface — since + // #5822 that includes the direct-mount registrars' routes, + // but only the ones this boot actually mounted and reported. + // The filtering to THIS base (and away from the project- // scoped mirror, which gets its own document) is // `buildBuiltinPaths`'s. - const builtin = buildBuiltinPaths(this.routeManager.getAll(), basePath); + const builtin = buildBuiltinPaths(this.getRoutes(), basePath); enriched.paths = builtin.paths; // The tag list describes that same section, so it is produced // with it rather than inherited from the artifact — otherwise a @@ -8683,11 +8709,38 @@ export class RestServer { getRouteManager(): RouteManager { return this.routeManager; } - + + /** + * Record routes a bypassing registrar mounted on this server's host + * `IHttpServer` (#5822). + * + * Called by the composition step that invoked the registrar + * (`mountAndRecordDirectRoutes`), with the array the registrar returned — + * which is the array it iterated to mount, so this records what happened + * rather than what was intended. Nothing here re-derives, re-checks or + * re-orders that fact; a registrar that was never called reports nothing, + * which is how "not mounted ⇒ not enumerable" survives. + */ + recordDirectMountedRoutes(routes: readonly DirectMountedRoute[]): void { + for (const route of routes) { + this.directMountedRoutes.push({ ...route, source: 'direct-mount' }); + } + } + /** - * Get all registered routes + * Get all routes mounted for this boot — the whole surface this server + * knows about, RouteManager's table and the recorded direct mounts alike. + * + * This is the introspection seam: the OpenAPI built-in section + * (`buildBuiltinPaths`), the route-ledger conformance guard and every + * debugging reader ask exactly this one question. Before #5822 it answered + * only for `routeManager`, so nine mounted routes — eight of them SDK + * capabilities — were invisible to all three. */ - getRoutes() { - return this.routeManager.getAll(); + getRoutes(): MountedRoute[] { + return [ + ...this.routeManager.getAll().map((route): MountedRoute => ({ ...route, source: 'route-manager' })), + ...this.directMountedRoutes, + ]; } }