|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The composition step that mounts `@objectstack/rest`'s direct-mount |
| 5 | + * registrars and records what they mounted (#5822). |
| 6 | + * |
| 7 | + * ## Why this is a module and not four blocks inside `rest-api-plugin.ts` |
| 8 | + * |
| 9 | + * It is the one place that knows WHICH registrars bypass `RouteManager` and |
| 10 | + * under which conditions each is called. Before #5822 that knowledge existed |
| 11 | + * twice: once in the plugin's `start()`, and once — copied by hand — in |
| 12 | + * `rest-route-ledger.conformance.test.ts`, which re-invoked the two registrars |
| 13 | + * against a mock server to enumerate them. A third registrar added to the |
| 14 | + * plugin would have been mounted, undocumented and unguarded, with every test |
| 15 | + * still green. Now the guard drives THIS function, so the set of registrars is |
| 16 | + * declared once and the ledger sees whatever production mounts. |
| 17 | + * |
| 18 | + * ## The honesty contract, in both directions |
| 19 | + * |
| 20 | + * Each registrar returns the array it iterated to mount, and that array is what |
| 21 | + * gets recorded on the `RestServer`. So: |
| 22 | + * |
| 23 | + * - a registrar this boot called ⇒ its routes are enumerable through |
| 24 | + * `getRoutes()` and appear in `GET {apiPath}/openapi.json`; |
| 25 | + * - a registrar this boot skipped (no `package` service) ⇒ nothing is |
| 26 | + * recorded, nothing is documented, and the 404 a caller would get from that |
| 27 | + * deployment is what the document says too. |
| 28 | + * |
| 29 | + * The service gate stays exactly where it was — here, at composition — and the |
| 30 | + * record follows it rather than restating it. What is deliberately NOT recorded |
| 31 | + * is any verdict about a service that a later phase could still contradict: the |
| 32 | + * federation routes mount unconditionally and decide per request whether the |
| 33 | + * `external-datasource` service is there (503 if not), so this file records |
| 34 | + * them as mounted and says nothing about federation being available. |
| 35 | + */ |
| 36 | + |
| 37 | +import type { PluginContext } from '@objectstack/core'; |
| 38 | +import type { IHttpServer } from '@objectstack/spec/contracts'; |
| 39 | +import type { PackageService } from '@objectstack/service-package'; |
| 40 | +import { registerPackageRoutes, type PackageRoutesOptions } from './package-routes.js'; |
| 41 | +import { registerExternalDatasourceRoutes } from './external-datasource-routes.js'; |
| 42 | +import type { DirectMountRecorder } from './direct-mount.js'; |
| 43 | + |
| 44 | +export interface DirectMountComposition { |
| 45 | + /** The host server the registrars mount on — the same one `RestServer` wraps. */ |
| 46 | + server: IHttpServer; |
| 47 | + /** Where the mounted facts land, so `getRoutes()` reports them. */ |
| 48 | + recorder: DirectMountRecorder; |
| 49 | + /** Service lookups (`package`) and the logger this step reports through. */ |
| 50 | + ctx: PluginContext; |
| 51 | + /** The configured API base, e.g. `/api/v1`. */ |
| 52 | + versionedBase: string; |
| 53 | + /** The `protocol` slice the package routes read registry packages through. */ |
| 54 | + protocol?: PackageRoutesOptions['protocol']; |
| 55 | + /** ADR-0006 project scoping — mirrors the package routes under the scoped base. */ |
| 56 | + enableProjectScoping?: boolean; |
| 57 | + /** `'auto'` (both bases) or `'required'` (scoped only). */ |
| 58 | + projectResolution?: string; |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Mount the direct-mount registrars for this boot and record every route they |
| 63 | + * mounted on {@link DirectMountComposition.recorder}. |
| 64 | + */ |
| 65 | +export function mountAndRecordDirectRoutes(composition: DirectMountComposition): void { |
| 66 | + const { server, recorder, ctx, versionedBase, protocol } = composition; |
| 67 | + const enableProjectScoping = composition.enableProjectScoping ?? false; |
| 68 | + const projectResolution = composition.projectResolution ?? 'auto'; |
| 69 | + |
| 70 | + // Package management routes — only when the service backing them exists. |
| 71 | + try { |
| 72 | + const packageService = ctx.getService<PackageService>('package'); |
| 73 | + if (packageService) { |
| 74 | + // `required` scoping serves ONLY the scoped variant; `auto` serves |
| 75 | + // both. Unchanged from the pre-#5822 plugin — expressed as the list |
| 76 | + // of bases so the mount and the record cannot disagree about it. |
| 77 | + const scopedBase = `${versionedBase}/environments/:environmentId`; |
| 78 | + const bases = enableProjectScoping |
| 79 | + ? (projectResolution === 'required' ? [scopedBase] : [versionedBase, scopedBase]) |
| 80 | + : [versionedBase]; |
| 81 | + for (const base of bases) { |
| 82 | + recorder.recordDirectMountedRoutes( |
| 83 | + registerPackageRoutes(server, packageService, base, { protocol }), |
| 84 | + ); |
| 85 | + } |
| 86 | + ctx.logger.info('Package management routes registered'); |
| 87 | + } |
| 88 | + } catch (e) { |
| 89 | + // Package service not available, skip |
| 90 | + ctx.logger.debug('Package service not available, package routes skipped'); |
| 91 | + } |
| 92 | + |
| 93 | + // External Datasource Federation routes (ADR-0015): catalog / draft / |
| 94 | + // import / validate. Registered unconditionally — they degrade gracefully |
| 95 | + // (503) when the `external-datasource` service is absent. |
| 96 | + // NOTE: the datasource *lifecycle* routes (ADR-0015 Addendum: |
| 97 | + // list / test / create / update / remove) moved to the private |
| 98 | + // `@objectstack/datasource-admin` package, which registers its own. |
| 99 | + try { |
| 100 | + recorder.recordDirectMountedRoutes( |
| 101 | + registerExternalDatasourceRoutes(server, ctx, versionedBase), |
| 102 | + ); |
| 103 | + ctx.logger.info('Datasource federation routes registered'); |
| 104 | + } catch (e: any) { |
| 105 | + // Nothing is recorded on this path: a registrar that threw part-way |
| 106 | + // may have mounted some routes, and under-claiming a mounted route is |
| 107 | + // the safe direction — a document that omits a live route is visibly |
| 108 | + // incomplete, one that invents a dead route is not. |
| 109 | + ctx.logger.warn('Datasource federation routes registration failed', { error: e?.message }); |
| 110 | + } |
| 111 | +} |
0 commit comments