Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/lazy-buttons-invite.md
Original file line number Diff line number Diff line change
@@ -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` 读法不变。
111 changes: 111 additions & 0 deletions packages/rest/src/direct-mount-composition.ts
Original file line number Diff line number Diff line change
@@ -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<PackageService>('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 });
}
}
Loading
Loading