From 7df2f327f3000e498bb5999b654c05cedf70ea0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:53:17 +0000 Subject: [PATCH] =?UTF-8?q?docs(protocol):=20http-protocol=20=E7=9A=84=20A?= =?UTF-8?q?PI=20Discovery=20=E6=8B=86=E6=88=90=E4=B8=A4=E6=AE=B5=E5=BC=8F?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20REST=20=E5=BD=A2=E7=8A=B6=E4=B8=8E=20di?= =?UTF-8?q?spatcher=20=E5=BD=A2=E7=8A=B6=E5=88=86=E5=BC=80=20(#4817)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该页此前声称 `/.well-known/objectstack` 与 `/api/v1/discovery` 「都直接返回同一份 discovery 文档」,并给出一份混合示例(`name` / `version: "2.1.0"` / `environment` / `locale`)。这份示例其实是 dispatcher `getDiscoveryInfo()` 的形状,而挂了 `@objectstack/rest` 的常规组合下 `/api/v1/discovery` 返回的是 `{ version, apiName, routes, services, capabilities }` + rest-server 追加的 `scoping`,没有 `name` / `environment` / `locale`,`version` 还被 `api.version` 覆盖成 `"v1"`。 改为与 `content/docs/api/index.mdx`(#4816 已核实)一致的两段式: - `GET /api/v1`(与 `/api/v1/discovery`)一节给 REST 形状; - `GET /.well-known/objectstack` 一节给 dispatcher 形状(`{ "data": ... }` 包裹, 含 `name` / `environment` / `features` / `locale`); - 「两条路径同文档」限定为 REST-less 组合,并点名 ADR-0076 D11 单一 owner 规则。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../http-protocol-discovery-two-shapes.md | 4 + .../docs/protocol/kernel/http-protocol.mdx | 145 +++++++++++++++--- 2 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 .changeset/http-protocol-discovery-two-shapes.md diff --git a/.changeset/http-protocol-discovery-two-shapes.md b/.changeset/http-protocol-discovery-two-shapes.md new file mode 100644 index 0000000000..ae8c626bb4 --- /dev/null +++ b/.changeset/http-protocol-discovery-two-shapes.md @@ -0,0 +1,4 @@ +--- +--- + +docs(protocol): `protocol/kernel/http-protocol` 的 API Discovery 一节拆成两段式 —— `@objectstack/rest` 服务的 `/api/v1`(与 `/api/v1/discovery`)与 dispatcher 服务的 `/.well-known/objectstack` 各给一份真实响应形状,不再共用一份混合示例。Docs-only;releases nothing. diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index fbcaf5dcf8..42b8a4ffe9 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -19,51 +19,148 @@ The **HTTP API** defines how ObjectStack maps data operations to RESTful HTTP en ## API Discovery -### Discovery Endpoint +Before making any API calls, clients should request a discovery endpoint to learn about +available services. **Two endpoints answer that question, and in a stack that mounts +`@objectstack/rest` they do not return the same shape** — they are built by different +packages. Read the one that matches your composition; do not mix their fields. -Before making any API calls, clients should request the discovery endpoint to learn about available services: +### `GET /api/v1` (and `GET /api/v1/discovery`) -**Request:** -```http -GET /.well-known/objectstack HTTP/1.1 -Host: api.acme.com -``` +Returns the full discovery manifest. `@objectstack/rest` registers **one handler at both +paths** — the API base path and `/discovery` — so the two are the same document, +not a redirect and not two shapes. In a REST-less composition the runtime dispatcher +registers `/discovery` as the fallback owner instead, and then serves its own +`/.well-known/objectstack` payload there (see below); when `@objectstack/rest` is mounted +the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11). -`/.well-known/objectstack` and the versioned `/api/v1/discovery` route both return the -discovery document directly — there is no HTTP redirect between them: +**Request:** ```http GET /api/v1/discovery HTTP/1.1 +Host: api.acme.com ``` **Response:** ```json { - "name": "Acme CRM Production", - "version": "2.1.0", - "environment": "production", + "version": "v1", + "apiName": "ObjectStack API", "routes": { "data": "/api/v1/data", - "metadata": "/api/v1/meta", - "packages": "/api/v1/packages", - "auth": "/api/v1/auth", - "ui": "/api/v1/ui", - "storage": "/api/v1/storage" + "metadata": "/api/v1/meta" }, "services": { - "data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" }, - "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, - "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" }, + "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" }, + "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" }, "search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" }, "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" } }, - "locale": { - "default": "en-US", - "supported": ["en-US", "zh-CN", "es-ES", "fr-FR"], - "timezone": "America/Los_Angeles" + "capabilities": { + "cron": { "enabled": false }, + "automation": { "enabled": false }, + "search": { "enabled": false }, + "transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" } + }, + "scoping": { + "enabled": false, + "resolution": "auto", + "scoped": false + } +} +``` + +Three things about this body are worth stating explicitly, because they are what the +`/.well-known/objectstack` document below does *not* share: + +- **`version` is the configured API version, not a product version.** The handler + overwrites the protocol's value with `api.version` — the same string that forms the + path segment (`"v1"`). It is never a semantic version like `2.1.0`. +- **There is no `name`, `environment` or `locale` here.** Those are dispatcher fields + (see below). A client that initialises i18n from `locale` must read + `/.well-known/objectstack`, not this response. +- **`scoping` is added by the REST server**, so clients can detect dual-mode routing; + `environmentId` is present only on the environment-scoped mount + (`/api/v1/environments/:environmentId/...`). + +Disabled/uninstalled route keys are omitted from `routes` entirely rather than set to +`null`; check `services` to tell "not installed" apart from "installed but not yet mounted +here." `capabilities` is a flat map of platform feature flags (`comments`, `automation`, +`cron`, `search`, `export`, `chunkedUpload`, `transactionalBatch`), each derived from what +is actually registered — never hardcoded. See +[API → Discovery](/docs/api#discovery) for the field-by-field reference. + +### `GET /.well-known/objectstack` + +Served by the runtime dispatcher (`@objectstack/runtime`), not `@objectstack/rest` — its +body is wrapped as `{ "data": { ... } }` and includes fields (`name`, `environment`, +`features`, `locale`) that the `@objectstack/rest`-served `/api/v1` response above does +not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it +answers with this shape whether or not REST is mounted. The client SDK's `connect()` tries +`/api/v1/discovery` first and falls back to this endpoint, unwrapping either `body.data` or +the bare `body`. + +**Request:** +```http +GET /.well-known/objectstack HTTP/1.1 +Host: api.acme.com +``` + +**Response:** +```json +{ + "data": { + "name": "ObjectOS", + "version": "1.0.0", + "environment": "production", + "routes": { + "data": "/api/v1/data", + "metadata": "/api/v1/meta", + "packages": "/api/v1/packages", + "auth": "/api/v1/auth", + "ui": "/api/v1/ui", + "i18n": "/api/v1/i18n" + }, + "features": { + "search": false, + "websockets": false, + "files": false, + "analytics": false, + "ai": false, + "notifications": false, + "i18n": true + }, + "services": { + "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" }, + "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "kernel" }, + "auth": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/auth" }, + "search": { "enabled": false, "status": "unavailable", "handlerReady": false, "message": "No implementation ships for the 'search' slot — register a service under it to enable" } + }, + "locale": { + "default": "en-US", + "supported": ["en-US", "zh-CN"], + "timezone": "UTC" + } } } ``` +`name` and `version` are the dispatcher's own build identity, not your app's name — they +are fixed strings, so do not display them as the deployment's title. `environment` is the +process `NODE_ENV`. `locale` is derived from the registered i18n service (`getDefaultLocale()` +/ `getLocales()`); with no i18n service it degrades to `{ "default": "en", "supported": +["en"], "timezone": "UTC" }`. The body also repeats `routes` under an `endpoints` key as a +backward-compatibility alias, and carries **no** `capabilities` map — that one exists only +on the REST-served response above. + + +**"Both paths return the same document" holds only in a REST-less composition.** There, the +dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and +`/.well-known/objectstack` both answer with the dispatcher payload above (the bare +`/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as +`@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule +(ADR-0076 D11) and the two paths answer different shapes. Never write a client that reads +`locale` or `environment` off `/api/v1/discovery`. + + **Why discovery matters:** - **Environment agnostic:** Works across dev, staging, production without hardcoding URLs - **Version tolerance:** API routes can change without breaking clients