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
33 changes: 33 additions & 0 deletions .changeset/endpoint-path-describe-carveout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': patch
---

**`ApiEndpointSchema.path` 的 `.describe()` 换成 carve-out 形状的示例(#5310)**

这段文案过去是 `URL Path (e.g. /api/v1/customers)`。ADR-0121 D1 把声明路径收紧为
`运行前缀 + /apps/ + 命名空间 + 子路径`(`appEndpointMountPrefix()` =
`/api/v1/apps/<namespace>/`),publish 门 `namespaceGate` 对 carve-out 之外的路径直接拒绝
—— 也就是说,**词表自己举的例子,publish 会当场拒**。

**为什么现在要紧。** #5271 之前 `api` 没有注册 schema,`/meta/types` 不为它出 JSON Schema,
Studio 只能给一个 raw-JSON 文本框,这段 `.describe()` 没有渲染面。#5271 之后它成为
metadata-admin 端点表单里 `path` 字段的说明文字,并进入生成的 JSON Schema —— 于是一段会被
拒绝的示例,变成了作者(按 ADR-0033,常常是 AI 作者)照抄的第一手提示。

**新文案**给出 carve-out 形状 `/api/v1/apps/<manifest.namespace>/<subpath>`、一个具体示例
`/api/v1/apps/crm/leads`,并说明命名空间段派生自 `manifest.namespace`(ADR-0121 D2)而不是
作者的自由字段。措辞直接复用 `endpoint-publish-gate.ts` 里 `namespaceGate` 的拒绝文案,
没有新发明判据 —— 作者在表单里读到的,和被拒时读到的,是同一条规则。

**只改了文案。** schema 结构、`/^\//` 正则、门逻辑一律未动:`path` 仍是一个以斜杠开头的
字符串,carve-out 仍然只由 publish 门(和运行期匹配器)判定。因此这不是破坏性变更,今天能
发布的声明明天照样能发布。

`ApiMappingSchema` 的三条 `.describe()` 逐条复核后**保持原样**:`source` / `target`
(*Source field/path* / *Target field/path*)不举例,也没有说错;`transform`
(*Transformation function name*)描述的键确实会被门整键拒绝,但那属于「词表冻结后,被拒键
是否应在表单里自陈」的另一类问题,已另行归档,不在本次文案修正内。

配套:`packages/spec/src/api/apis-publish-gates.test.ts` 新增一条断言,把 `.describe()` 的
文本**读回来**再喂给门 —— 文案里出现的每个具体示例路径都必须在它自己命名的命名空间下通过
`validateApiEndpointDeclarations`。把一个会被拒的路径写回 `.describe()`,这条测试就红。
2 changes: 1 addition & 1 deletion content/docs/references/api/endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const result = ApiEndpointSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Unique endpoint ID |
| **path** | `string` | ✅ | URL Path (e.g. /api/v1/customers) |
| **path** | `string` | ✅ | URL Path — must be inside this stack's endpoint carve-out: `/api/v1/apps/<manifest.namespace>/<subpath>` with a non-empty subpath (ADR-0121 D1), e.g. `/api/v1/apps/crm/leads` for a stack whose `manifest.namespace` is `crm`. Only the subpath is yours to name; the namespace segment is derived from `manifest.namespace` (ADR-0121 D2), never authored here. A path outside the carve-out is rejected at publish and would match NOTHING at runtime. |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP Method |
| **summary** | `string` | optional | |
| **description** | `string` | optional | |
Expand Down
59 changes: 59 additions & 0 deletions packages/spec/src/api/apis-publish-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,65 @@ describe('[#5111] gate (c) — namespace carve-out (ADR-0121 D1/D2)', () => {
});
});

describe('[#5310] the `path` vocabulary text is itself publishable', () => {
/**
* `ApiEndpointSchema.path`'s `.describe()` is not a code comment. Since `api`
* became a registered metadata kind (#5271) it is the field help the
* metadata-admin endpoint form renders, and it lands in the generated JSON
* Schema — so it is the first-hand prompt an author copies, and the author is
* very often an AI maintainer (ADR-0033) that copies it verbatim. It used to
* read `URL Path (e.g. /api/v1/customers)`, an example `namespaceGate` rejects
* on sight (ADR-0121 D1): the vocabulary's own example was the one shape
* publish refuses.
*
* The assertion reads the text back OUT of the schema instead of restating
* it, so it cannot drift from the string an author actually sees — put a
* rejected path back into the `.describe()` and this file goes red.
*/
const description = ApiEndpointSchema.shape.path.description ?? '';

/** Concrete paths in that text. `<manifest.namespace>` is a placeholder, not an example. */
const concreteExamples = (description.match(/\/[A-Za-z0-9_<>./-]+/g) ?? []).filter(
(candidate) => !candidate.includes('<'),
);

it('shows the author at least one CONCRETE example path', () => {
expect(description, '`path` must carry a description — it is the form\'s field help').not.toBe('');
expect(
concreteExamples,
`no concrete example path found in: ${description}`,
).not.toHaveLength(0);
});

it('every concrete example it shows PASSES the gate that judges authored paths', () => {
for (const path of concreteExamples) {
const carveOut = /^\/api\/v1\/apps\/([a-z][a-z0-9_]{1,19})\/(.+)$/.exec(path);
expect(
carveOut,
`example '${path}' is not \`/api/v1/apps/[namespace]/[subpath]\` — the shape ADR-0121 D1 requires`,
).not.toBeNull();

// Judged under the namespace the example itself names: the carve-out is
// derived from `manifest.namespace` (D2), so an example is only honest
// paired with the manifest that could declare it.
const namespace = carveOut![1]!;
const issues = validateApiEndpointDeclarations(
[ApiEndpointSchema.parse({ ...validObjectEndpoint, path })],
{ namespace },
);
expect(
issues.map((issue) => issue.message).join('\n'),
`example '${path}' must publish under \`manifest.namespace: '${namespace}'\``,
).toBe('');
}
});

it('the example it USED to show is still rejected — the defect itself, pinned', () => {
const message = reject({ manifest, apis: [{ ...validObjectEndpoint, path: '/api/v1/customers' }] });
expect(message).toMatch(/not inside this stack's endpoint carve-out/);
});
});

describe('[#5111] gate (a) — the supported subset (mirrors `planEndpointTarget`)', () => {
it("rejects `type: 'script'` with the flow prescription", () => {
const message = reject({
Expand Down
9 changes: 8 additions & 1 deletion packages/spec/src/api/endpoint.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ export const ApiMappingSchema = lazySchema(() => z.object({
export const ApiEndpointSchema = z.object({
/** Identity */
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique endpoint ID'),
path: z.string().regex(/^\//).describe('URL Path (e.g. /api/v1/customers)'),
path: z.string().regex(/^\//).describe(
'URL Path — must be inside this stack\'s endpoint carve-out: '
+ '`/api/v1/apps/<manifest.namespace>/<subpath>` with a non-empty subpath (ADR-0121 D1), '
+ 'e.g. `/api/v1/apps/crm/leads` for a stack whose `manifest.namespace` is `crm`. '
+ 'Only the subpath is yours to name; the namespace segment is derived from '
+ '`manifest.namespace` (ADR-0121 D2), never authored here. A path outside the carve-out '
+ 'is rejected at publish and would match NOTHING at runtime.',
),
method: HttpMethod.describe('HTTP Method'),

/** Documentation */
Expand Down
Loading