Skip to content

Commit d7f422e

Browse files
committed
feat(rest): direct-mount 的 9 条路由对 RestServer 可枚举,并进入 /openapi.json (#5822)
The two registrars that bypass RouteManager (`package-routes.ts`, `external-datasource-routes.ts`) now declare their routes once, as data, hand that array to `mountDirectRoutes` and return it; the composition step (`mountAndRecordDirectRoutes`, called by `rest-api-plugin.ts`) records it on the `RestServer`. `getRoutes()` therefore answers for the whole mounted surface — each row carrying `source: 'route-manager' | 'direct-mount'` — and the OpenAPI built-in section #5588 / PR #5821 made a projection of that table now covers the nine, eight of which are `disposition: 'sdk'` capabilities. The description and the mount are the SAME array, so there is no second source of truth. Both honesty directions are pinned: a boot without the `package` service mounts, enumerates and documents no `packages.*` route, while the five federation routes mount unconditionally (503 per request when the service is absent) and are documented accordingly. The route-ledger conformance guard's second enumeration — mock-server registration capture — is deleted: it now drives the same composition function production does and partitions one `getRoutes()` answer by `source`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
1 parent a682670 commit d7f422e

12 files changed

Lines changed: 973 additions & 182 deletions

.changeset/lazy-buttons-invite.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
REST 的 9 条 direct-mount 路由现在对 `RestServer` 可枚举,并随之进入 `GET {apiPath}/openapi.json`
6+
7+
`package-routes.ts`(4 条 `packages.*`)与 `external-datasource-routes.ts`(5 条
8+
`datasources/:name/external/*`)一直绕过 `RouteManager`、直接挂在宿主 `IHttpServer` 上,
9+
`RestServer` 因此不持有「这 9 条本次 boot 是否挂载」的事实。#5588(PR #5821)把
10+
`/openapi.json` 的 built-in 段改成服务器自身路由表的投影之后,这 9 条(其中 8 条在
11+
`rest-route-ledger.ts` 里是 `disposition: 'sdk'` 的真实能力)就不在生成的文档里 ——
12+
`/openapi.json` 生成客户端的 consumer 拿不到它们,任何基于 `getRoutes()` 的自省也看不见。
13+
14+
现在两个 registrar 各自把「实际挂载的那一个数组」原样返回,由组合步骤
15+
(`mountAndRecordDirectRoutes`,`rest-api-plugin.ts` 调用)登记到 `RestServer` 上:
16+
17+
- `RestServer.getRoutes()` 返回本次 boot 的**全部**已挂载路由,每条带 `source`
18+
(`'route-manager' | 'direct-mount'`),类型为新导出的 `MountedRoute`;
19+
- `/openapi.json` 的 built-in 段随之覆盖这 9 条,带各自的 summary / tags / 路径参数;
20+
- 描述与挂载**同源**:返回的数组就是用来挂载的那个数组,不存在第二份手工清单。
21+
22+
诚实性两个方向都保持不变:某次 boot 没有 `package` 服务 ⇒ `packages.*` 既没挂载、
23+
也不出现在 `getRoutes()` 与文档里;federation 那 5 条无条件挂载(服务缺席时按请求答 503),
24+
所以它们始终出现 —— 文档说的仍然只是「什么被挂载了」。
25+
26+
对使用者的影响:`getRoutes()` 的返回值多了 9 条(服务在场时)以及每条上的 `source`
27+
字段;既有的 `method` / `path` / `handler` / `metadata` 读法不变。
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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

Comments
 (0)