docs(http): add ADR-042 OpenAPI gateway pattern for to_openapi
The to_openapi spec was describing one OpenAPI path per alknet operation
— the inverse of from_openapi. That inverse is genuinely messy: the call
protocol's input is a flat JSON object, and generating a traditional
OpenAPI path entry (POST /fs/{path} with path param, body, query params)
requires reverse-engineering which fields are path/query/body — metadata
the call protocol doesn't carry. The three options (leaky HTTP metadata
on OperationSpec, fragile heuristics, manual annotation) are all messy.
ADR-042 replaces this with the gateway pattern (same as ADR-041 for
to_mcp): to_openapi generates 5 fixed endpoints (search, schema, call,
batch, subscribe) that gate access to the full operation registry. The
input is always a flat JSON body — no path/query/body split to
reverse-engineer. JSON Schema is already in the OperationSpec.
The per-caller API surface is the key advantage: /search is
AccessControl-filtered, so the client sees only what it can call. The
Gitea failure mode (dumping admin ops to every caller in a static
OpenAPI doc) is structurally impossible — the per-caller surface is the
default, not an afterthought. OpenAPI has no per-caller filtering
concept; the gateway pattern provides it through /search.
Gateway endpoint set:
- /search -> services/list (AccessControl-filtered, names + descriptions)
- /schema -> services/schema (full OperationSpec)
- /call -> call.requested (Query/Mutation, flat JSON body)
- /batch -> multiple call.requested (correlated IDs)
- /subscribe -> call.requested (Subscription, SSE) — the one endpoint
the MCP gateway excludes (MCP is request/response; OpenAPI/SSE
supports streaming)
A traditional per-operation-paths projection is additive (a deployment
that wants the nice Swagger UI builds it with HTTP-specific metadata),
not a replacement. The gateway is the default.
http-adapters.md to_openapi section rewritten: the gateway endpoint
set, per-caller filtering, error fidelity on the /call endpoint, and
the additive traditional projection. The 'Why' section adds the
flat->structured and per-caller-surface rationale.
README/overview ADR tables and the top-level README current-state note
updated for ADR-042.
This commit is contained in:
@@ -18,7 +18,7 @@ The storage and auth strategy research (`docs/research/alknet-storage-strategy/f
|
|||||||
|
|
||||||
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
|
The alknet-call crate is **implemented and reviewed** — both the server-side core and the client/adapter surface (207 lib + 2 integration tests passing). The alknet-core and alknet-call crate specs are in draft; the alknet-vault crate specs are stable.
|
||||||
|
|
||||||
**alknet-http specs drafted.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and six new ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — browser → WebTransport stream → any ALPN handler via WASM parser; the "VPN-like without being a VPN" use case), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http` → `alknet-call` dependency edge). The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
|
**alknet-http specs drafted.** The alknet-http crate (HTTP interface — `h2`/`http/1.1`/`h3` server + `from_openapi`/`to_openapi`/`from_mcp`/`to_mcp` adapters) now has architecture specs: [crates/http/](crates/http/) (overview, http-server, http-adapters, http-mcp, webtransport) and seven new ADRs — [ADR-036](decisions/036-http-to-call-operation-mapping.md) (HTTP-to-call mapping), [ADR-037](decisions/037-mcp-stdio-transport-exclusion.md) (MCP stdio exclusion), [ADR-038](decisions/038-http3-and-webtransport-as-first-class.md) (HTTP/3 + WebTransport as first-class, correcting the Phase 0 deferral framing), [ADR-039](decisions/039-http-server-and-client-host-colocated.md) (HTTP server + client host colocated in one crate), [ADR-040](decisions/040-webtransport-alpn-stream-proxy.md) (WebTransport ALPN-stream-proxy — browser → WebTransport stream → any ALPN handler via WASM parser; the "VPN-like without being a VPN" use case), [ADR-041](decisions/041-mcp-tool-gateway-pattern.md) (`to_mcp` tool-gateway pattern — 4 fixed gateway tools instead of one tool per operation, addressing LLM context tool-bloat), [ADR-042](decisions/042-openapi-gateway-pattern.md) (`to_openapi` gateway pattern — 5 fixed gateway endpoints instead of one path per operation; per-caller AccessControl-filtered API surface). ADR-003 Amendment 1 clarifies that `alknet-call` is a protocol-foundation crate (the `alknet-http` → `alknet-call` dependency edge). The specs are in draft; implementation has not started. Three open questions carried: OQ-38 (WebTransport standalone relay service scope — distinct from the in-process ALPN-stream-proxy resolved by ADR-040), OQ-39 (`to_openapi` published-spec versioning), OQ-40 (reqwest client config).
|
||||||
|
|
||||||
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted, ADRs 036–038 proposed), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
|
**Next step**: The storage/repo-pattern ADRs (030–033) are accepted and amend the core and call specs. The next implementation phase is the ADR-029 migration (peer-keyed overlays, `PeerRef` routing, retire `remote_safe`/`trusted_peer`) with the ADR-030 `PeerEntry` change and the ADR-032 `forwarded_for` field folded in — the `OperationContext`, `from_call` handler, and `AuthPolicy` are all under edit, making this the cheapest window. After that: alknet-http implementation (specs drafted, ADRs 036–038 proposed), which consumes the `CredentialStore` trait and the `OperationAdapter` contract. The alknet-ssh crate (the other post-core crate, specced in parallel) proceeds independently — it depends on `alknet-core`, not `alknet-call`.
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c
|
|||||||
| [039](decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | Proposed |
|
| [039](decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | Proposed |
|
||||||
| [040](decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Proposed |
|
| [040](decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Proposed |
|
||||||
| [041](decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | Proposed |
|
| [041](decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | Proposed |
|
||||||
|
| [042](decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | Proposed |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ on standard ALPNs, and hosts the HTTP-backed call-protocol adapters
|
|||||||
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
|
| [039](../../decisions/039-http-server-and-client-host-colocated.md) | HTTP Server and Client Host Colocated in alknet-http | One crate for server + client host (shared HTTP deps, shared mapping) |
|
||||||
| [040](../../decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
|
| [040](../../decisions/040-webtransport-alpn-stream-proxy.md) | WebTransport ALPN-Stream-Proxy | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
|
||||||
| [041](../../decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation; Subscription excluded |
|
| [041](../../decisions/041-mcp-tool-gateway-pattern.md) | MCP Tool-Gateway Pattern for to_mcp | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation; Subscription excluded |
|
||||||
|
| [042](../../decisions/042-openapi-gateway-pattern.md) | OpenAPI Gateway Pattern for to_openapi | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered |
|
||||||
|
|
||||||
## Relevant Open Questions
|
## Relevant Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -193,27 +193,65 @@ source other than `OperationContext.capabilities`. See
|
|||||||
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
|
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
|
||||||
```
|
```
|
||||||
|
|
||||||
`to_openapi` generates an OpenAPI document from the local registry's
|
`to_openapi` generates an OpenAPI document with a **fixed gateway
|
||||||
`External` operations:
|
endpoint set** that gates access to the full operation registry — not
|
||||||
|
one path per operation. This is the OpenAPI gateway pattern (ADR-042):
|
||||||
|
the same principle as the MCP gateway (ADR-041) applied to OpenAPI. The
|
||||||
|
external client (a code generator, a human developer, a `fetch`-based
|
||||||
|
client) calls `/search` to discover operations, `/schema` to learn an
|
||||||
|
operation's input shape, `/call` to invoke. See
|
||||||
|
[ADR-042](../../decisions/042-openapi-gateway-pattern.md) for the
|
||||||
|
rationale (the flat→structured split problem, the per-caller API
|
||||||
|
surface problem).
|
||||||
|
|
||||||
1. For each `External` operation in the registry, generate an OpenAPI
|
#### The gateway endpoint set
|
||||||
path entry:
|
|
||||||
- Path: `/{service}/{op}` (the operation path, ADR-036 — the HTTP
|
|
||||||
path IS the operation path).
|
|
||||||
- Method: the operation's `OperationType` → HTTP method (`Query`→GET,
|
|
||||||
`Mutation`→POST by default, `Subscription`→GET with
|
|
||||||
`text/event-stream` response).
|
|
||||||
- `operationId`: the operation name.
|
|
||||||
- `parameters` / `requestBody` / `responses`: built from the
|
|
||||||
operation's `input_schema` / `output_schema` / `error_schemas`.
|
|
||||||
2. The `components.schemas` section holds the reusable schemas
|
|
||||||
referenced by `$ref` from the paths.
|
|
||||||
3. The `info` section carries the API title, version, and description.
|
|
||||||
|
|
||||||
This is a pure projection — it consumes the registry and produces a
|
`to_openapi` generates 5 fixed endpoints:
|
||||||
spec. It does not modify the registry; it does not register operations;
|
|
||||||
it is not an `OperationAdapter`. The HTTP server serves the generated
|
| OpenAPI path | Call protocol | HTTP method | Purpose |
|
||||||
spec at `GET /openapi.json` (or a configured path).
|
|--------------|--------------|-------------|---------|
|
||||||
|
| `/search` | `services/list` | `GET` | List/search operations (AccessControl-filtered). Names + descriptions. |
|
||||||
|
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec`. |
|
||||||
|
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation. Flat JSON body `{ operation, input }`. |
|
||||||
|
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations. Array of `{ operation, input }`. |
|
||||||
|
| `/subscribe` | `call.requested` (Subscription) | `GET` (SSE) | Invoke a streaming operation. `text/event-stream`. |
|
||||||
|
|
||||||
|
The input is always a flat JSON body — no path/query/body split to
|
||||||
|
reverse-engineer. JSON Schema for the input/output is already in the
|
||||||
|
`OperationSpec`; the gateway wraps it in OpenAPI's schema format without
|
||||||
|
splitting parameters.
|
||||||
|
|
||||||
|
`/subscribe` is the one endpoint the MCP gateway excludes (ADR-041 —
|
||||||
|
MCP tool calls are request/response). OpenAPI/SSE supports streaming;
|
||||||
|
the gateway's `/subscribe` uses the same SSE projection ADR-036
|
||||||
|
describes — `call.responded` → SSE `data:` frames, `call.completed` →
|
||||||
|
stream close.
|
||||||
|
|
||||||
|
#### Per-caller API surface
|
||||||
|
|
||||||
|
The `/search` endpoint's results are `AccessControl::check(identity)`-
|
||||||
|
filtered — the client sees only the operations it is authorized to call.
|
||||||
|
The generated OpenAPI doc describes the 5 gateway endpoints (stable,
|
||||||
|
same for every caller); the per-caller operation surface is discovered
|
||||||
|
through `/search`, not preloaded into the doc. This is the key
|
||||||
|
advantage over a traditional per-operation-paths OpenAPI doc: the
|
||||||
|
per-caller API surface is the default (the Gitea failure mode — dumping
|
||||||
|
admin ops to every caller — is structurally impossible). See ADR-042 §3.
|
||||||
|
|
||||||
|
#### Pure projection
|
||||||
|
|
||||||
|
`to_openapi` is a pure projection — it consumes the registry and
|
||||||
|
produces a spec. It does not modify the registry; it does not register
|
||||||
|
operations; it is not an `OperationAdapter`. The HTTP server serves the
|
||||||
|
generated spec at `GET /openapi.json` (or a configured path).
|
||||||
|
|
||||||
|
#### Traditional per-operation-paths projection (additive)
|
||||||
|
|
||||||
|
A deployment that wants a traditional REST OpenAPI doc (per-operation
|
||||||
|
paths with split parameters) can build it as a separate projection with
|
||||||
|
HTTP-specific metadata (which fields are path params, etc.). The
|
||||||
|
gateway pattern is the default `to_openapi` projection; the traditional
|
||||||
|
projection is additive, not a replacement. See ADR-042 §5.
|
||||||
|
|
||||||
### Error Fidelity (ADR-023)
|
### Error Fidelity (ADR-023)
|
||||||
|
|
||||||
@@ -229,18 +267,30 @@ protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
|
|||||||
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
|
||||||
```
|
```
|
||||||
|
|
||||||
`to_openapi` projects `error_schemas` back to OpenAPI response
|
`to_openapi` projects `error_schemas` to the gateway endpoint's
|
||||||
definitions:
|
response definitions. The `/call` endpoint's responses include the
|
||||||
|
operation-level errors (mapped by `http_status`), plus the protocol-
|
||||||
|
level errors:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# /call endpoint responses
|
||||||
responses:
|
responses:
|
||||||
'200': { schema: <output_schema> }
|
'200': { schema: <output_schema for the called operation> }
|
||||||
'404': { schema: <error_schemas[i].schema> } # where http_status = 404
|
'400': { schema: <INVALID_INPUT error> }
|
||||||
'429': { schema: <error_schemas[j].schema> } # where http_status = 429
|
'401': { schema: <no bearer token> }
|
||||||
|
'403': { schema: <FORBIDDEN — insufficient scopes> }
|
||||||
|
'404': { schema: <NOT_FOUND — operation not registered or Internal> }
|
||||||
|
'422': { schema: <operation-level error with http_status=422> }
|
||||||
|
'429': { schema: <operation-level error with http_status=429> }
|
||||||
|
'500': { schema: <INTERNAL> }
|
||||||
|
'504': { schema: <TIMEOUT> }
|
||||||
```
|
```
|
||||||
|
|
||||||
This makes the adapter contract from ADR-017 faithful on the error axis —
|
The operation-level errors (with `http_status`) are surfaced on the
|
||||||
no silent dropping of error contracts. See ADR-023.
|
`/call` endpoint's response — the gateway propagates the called
|
||||||
|
operation's `error_schemas` as response definitions. This makes the
|
||||||
|
adapter contract from ADR-017 faithful on the error axis — no silent
|
||||||
|
dropping of error contracts. See ADR-023.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -253,10 +303,17 @@ its errors are typed. The agent crate's LLM provider calls go through
|
|||||||
invariant makes aisdk's env-var reads unreachable.
|
invariant makes aisdk's env-var reads unreachable.
|
||||||
|
|
||||||
`to_openapi` is how external systems discover the alknet operation
|
`to_openapi` is how external systems discover the alknet operation
|
||||||
surface. An API gateway, a client generator, or a human developer reads
|
surface. A client generator, a human developer, or a `fetch`-based
|
||||||
the OpenAPI doc to learn what operations exist and how to call them.
|
client reads the OpenAPI doc to learn the gateway's shape (5 fixed
|
||||||
The generated spec is a compatibility contract (ADR-017 Consequences) —
|
endpoints), then calls `/search` to discover what *it* can call
|
||||||
once published, the mapping is one-way.
|
(per-caller, AccessControl-filtered) and `/schema` to learn an
|
||||||
|
operation's input shape. The gateway pattern avoids the flat→structured
|
||||||
|
split that a traditional per-operation-paths projection would require,
|
||||||
|
and makes the per-caller API surface the default (the Gitea failure
|
||||||
|
mode — dumping admin ops to every caller — is structurally impossible).
|
||||||
|
See [ADR-042](../../decisions/042-openapi-gateway-pattern.md). The
|
||||||
|
generated spec is a compatibility contract (ADR-017 Consequences) —
|
||||||
|
once published, the 5-endpoint gateway shape is one-way.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
@@ -296,6 +353,7 @@ once published, the mapping is one-way.
|
|||||||
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
|
| Error fidelity (`HTTP_<status>` codes) | [ADR-023](../../decisions/023-operation-error-schemas.md) | No collision with protocol codes; `to_openapi` projects back |
|
||||||
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
| No-env-vars credential injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Handler reads `context.capabilities`, not env vars |
|
||||||
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
|
| HTTP path = operation path | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `to_openapi` paths mirror `/{service}/{op}` |
|
||||||
|
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ verified against this invariant. See ADR-014 and
|
|||||||
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
|
| HTTP server + client host colocated | [ADR-039](../../decisions/039-http-server-and-client-host-colocated.md) | One crate for server + adapters (shared HTTP deps, shared mapping) |
|
||||||
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
|
| WebTransport ALPN-stream-proxy | [ADR-040](../../decisions/040-webtransport-alpn-stream-proxy.md) | Browser → WebTransport stream → any ALPN handler (SSH, git, SFTP) via WASM parser |
|
||||||
| `to_mcp` tool-gateway pattern | [ADR-041](../../decisions/041-mcp-tool-gateway-pattern.md) | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation |
|
| `to_mcp` tool-gateway pattern | [ADR-041](../../decisions/041-mcp-tool-gateway-pattern.md) | 4 fixed gateway tools (search/schema/call/batch), not one tool per operation |
|
||||||
|
| `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe); per-caller AccessControl-filtered |
|
||||||
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
|
| `alknet-call` is protocol-foundation | [ADR-003](../../decisions/003-crate-decomposition.md) Am. 1 | `alknet-http` depends on `alknet-call` (types, not peer handler) |
|
||||||
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
|
| Bearer auth via `resolve_from_token` | [ADR-004](../../decisions/004-auth-as-shared-core.md) | HTTP handler credential source + resolution (settled) |
|
||||||
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |
|
| Stealth mode = HTTP handler on standard ALPNs | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | Decoy for unknown paths (settled) |
|
||||||
|
|||||||
243
docs/architecture/decisions/042-openapi-gateway-pattern.md
Normal file
243
docs/architecture/decisions/042-openapi-gateway-pattern.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# ADR-042: OpenAPI Gateway Pattern for to_openapi
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current `to_openapi` spec (`crates/http/http-adapters.md`) describes
|
||||||
|
`to_openapi` as generating a traditional OpenAPI document with one path
|
||||||
|
entry per alknet `External` operation — `POST /fs/readFile`,
|
||||||
|
`POST /agent/chat`, etc., each with parameters, request body, and
|
||||||
|
responses built from the operation's `input_schema`/`output_schema`/
|
||||||
|
`error_schemas`. This is the "inverse of `from_openapi`" framing: since
|
||||||
|
`from_openapi` merges OpenAPI path params / query params / request body
|
||||||
|
into a single flat JSON input schema, `to_openapi` should split them
|
||||||
|
back out.
|
||||||
|
|
||||||
|
### The flat→structured problem
|
||||||
|
|
||||||
|
The inverse is genuinely messy. The call protocol's input is a flat JSON
|
||||||
|
object (e.g., `{ path, content, encoding }` for `fs/writeFile`). To
|
||||||
|
generate a traditional OpenAPI path entry (`POST /fs/{path}` with path
|
||||||
|
param `path`, body `content`), `to_openapi` would need to know which
|
||||||
|
fields are path params, which are query params, and which is the body.
|
||||||
|
That information isn't in the flat schema — it's metadata the call
|
||||||
|
protocol doesn't carry because it doesn't care about HTTP parameter
|
||||||
|
structure. `to_openapi` would need either:
|
||||||
|
|
||||||
|
1. HTTP-specific metadata on `OperationSpec` (which fields are path
|
||||||
|
params, etc.) — a leaky abstraction that puts HTTP concerns in the
|
||||||
|
protocol-foundation crate (`alknet-call`), or
|
||||||
|
2. Heuristics (guess that fields named `id` are path params?) — fragile
|
||||||
|
and wrong, or
|
||||||
|
3. Manual annotation per operation — boilerplate that defeats the "pure
|
||||||
|
projection" promise.
|
||||||
|
|
||||||
|
All three are messy. The flat→structured split is the hard direction,
|
||||||
|
and it's the one `to_openapi` has to do.
|
||||||
|
|
||||||
|
### The per-caller API surface problem
|
||||||
|
|
||||||
|
A traditional OpenAPI document is static — it describes the full API
|
||||||
|
surface regardless of who's reading it. Real APIs have per-caller
|
||||||
|
authorization: an admin sees admin operations, a regular user sees a
|
||||||
|
subset. OpenAPI has no standard mechanism for "show me only what I have
|
||||||
|
access to." The Gitea API is a concrete failure case: its OpenAPI spec
|
||||||
|
dumps the full API (including admin operations) to every caller,
|
||||||
|
regardless of privilege. A user reading the spec can't tell which
|
||||||
|
endpoints they can actually call without trial-and-error `403`s.
|
||||||
|
|
||||||
|
The call protocol already has the per-caller filtering primitive:
|
||||||
|
`services/list` is `AccessControl::check(identity)`-filtered — the
|
||||||
|
caller sees only the operations they are authorized to call. A
|
||||||
|
`to_openapi` that generates a static full-surface doc loses this
|
||||||
|
property. A `to_openapi` that uses the gateway pattern preserves it.
|
||||||
|
|
||||||
|
### The pattern that works
|
||||||
|
|
||||||
|
The same tool-gateway pattern ADR-041 applies to `to_mcp` applies here:
|
||||||
|
`to_openapi` exposes a small fixed set of endpoints that gate access to
|
||||||
|
the full operation registry. The external client (a code generator, a
|
||||||
|
human developer, a `fetch`-based client) calls `search` to discover
|
||||||
|
operations, `schema` to learn an operation's input shape, `call` to
|
||||||
|
invoke. The input is always a flat JSON body — no path/query/body split
|
||||||
|
to reverse-engineer. JSON Schema for the input/output is already in the
|
||||||
|
`OperationSpec` — no conversion beyond wrapping it in OpenAPI's schema
|
||||||
|
format.
|
||||||
|
|
||||||
|
The OpenAPI gateway has one endpoint the MCP gateway doesn't:
|
||||||
|
`subscribe` (SSE). OpenAPI/SSE supports streaming; MCP tool calls don't.
|
||||||
|
So the OpenAPI gateway is 5 endpoints; the MCP gateway is 4.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. `to_openapi` exposes a fixed gateway endpoint set, not one path per operation
|
||||||
|
|
||||||
|
`to_openapi` generates an OpenAPI document with a small fixed set of
|
||||||
|
endpoints that gate access to the full operation registry. The external
|
||||||
|
client discovers and invokes operations through the gateway.
|
||||||
|
|
||||||
|
The gateway endpoint set (initial, two-way-door extensible):
|
||||||
|
|
||||||
|
| OpenAPI path | Call protocol operation | HTTP method | Purpose |
|
||||||
|
|--------------|------------------------|-------------|---------|
|
||||||
|
| `/search` | `services/list` | `GET` | List/search available operations (filtered by the caller's `AccessControl`). Returns names + descriptions. |
|
||||||
|
| `/schema` | `services/schema` | `GET` | Get an operation's full `OperationSpec` (input/output JSON Schemas, error schemas). |
|
||||||
|
| `/call` | `call.requested` (Query/Mutation) | `POST` | Invoke an operation by name with a JSON input. Returns the output or a typed error (ADR-023). |
|
||||||
|
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations in one request (correlated request IDs, OQ-14). Returns an array of results. |
|
||||||
|
| `/subscribe` | `call.requested` (Subscription) | `GET` (SSE) | Invoke a streaming operation. Returns `text/event-stream` — each `call.responded` is an SSE frame, `call.completed` closes the stream. |
|
||||||
|
|
||||||
|
Five endpoints. The client calls `/search` to find operations, `/schema`
|
||||||
|
to learn the input shape, `/call` (or `/subscribe` for streaming) to
|
||||||
|
invoke. The input is always a flat JSON body (`{ operation:
|
||||||
|
"/fs/readFile", input: { ... } }`); the output is the operation's result
|
||||||
|
as JSON. No path/query/body split to reverse-engineer.
|
||||||
|
|
||||||
|
### 2. `subscribe` is the OpenAPI gateway's streaming endpoint (SSE)
|
||||||
|
|
||||||
|
The OpenAPI gateway includes `subscribe` (which the MCP gateway excludes
|
||||||
|
— ADR-041, MCP tool calls are request/response). The `subscribe`
|
||||||
|
endpoint maps `Subscription` operations onto SSE: `GET /subscribe` with
|
||||||
|
`Accept: text/event-stream`, each `call.responded` event is an SSE
|
||||||
|
`data:` frame, `call.completed` closes the stream, `call.aborted` closes
|
||||||
|
with an error frame. This is the same SSE projection ADR-036 describes
|
||||||
|
for `h2`/`http/1.1` clients — the gateway's `subscribe` endpoint is the
|
||||||
|
single SSE entry point instead of per-operation SSE streams.
|
||||||
|
|
||||||
|
### 3. The generated OpenAPI doc is per-caller (AccessControl-filtered)
|
||||||
|
|
||||||
|
The `/search` endpoint's results are filtered by the caller's
|
||||||
|
`AccessControl::check(identity)` — the client sees only the operations
|
||||||
|
it is authorized to call. The `/call` and `/subscribe` endpoints run the
|
||||||
|
same `AccessControl` check on dispatch. The generated OpenAPI doc
|
||||||
|
describes the gateway endpoints (5 fixed paths); the per-caller
|
||||||
|
operation surface is discovered through `/search`, not preloaded into
|
||||||
|
the doc.
|
||||||
|
|
||||||
|
This is the key advantage over a traditional per-operation-paths OpenAPI
|
||||||
|
doc: the per-caller API surface is the default, not an afterthought. A
|
||||||
|
client reading the gateway OpenAPI doc learns the gateway's shape (5
|
||||||
|
endpoints, stable); a client calling `/search` learns what *it* can call
|
||||||
|
(per-caller, AccessControl-filtered). The Gitea failure mode (dumping
|
||||||
|
admin ops to every caller) is structurally impossible — `/search` doesn't
|
||||||
|
return operations the caller can't call.
|
||||||
|
|
||||||
|
### 4. The gateway OpenAPI doc is a compatibility contract
|
||||||
|
|
||||||
|
Once published, the gateway endpoint set (5 endpoints) and the
|
||||||
|
request/response shapes are a compatibility contract (ADR-017
|
||||||
|
Consequences). Adding endpoints is additive (non-breaking); removing or
|
||||||
|
renaming is a one-way door. The initial 5-endpoint set is the published
|
||||||
|
contract. The versioning strategy for the generated doc is tracked as
|
||||||
|
OQ-39 (same as the per-operation-paths versioning question — the
|
||||||
|
gateway pattern doesn't eliminate the versioning concern, it simplifies
|
||||||
|
it to 5 stable endpoints instead of a per-operation surface).
|
||||||
|
|
||||||
|
### 5. A traditional per-operation-paths projection is additive, not replacement
|
||||||
|
|
||||||
|
A deployment that wants a traditional REST OpenAPI doc (per-operation
|
||||||
|
paths with split parameters) can build it as a separate projection with
|
||||||
|
the HTTP-specific metadata (which fields are path params, etc.). The
|
||||||
|
gateway pattern is the default `to_openapi` projection; the traditional
|
||||||
|
projection is an additive alternative for deployments that need it. The
|
||||||
|
gateway does not foreclose the traditional projection — it just doesn't
|
||||||
|
require it for the common case.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- No flat→structured split. The gateway's input is always a flat JSON
|
||||||
|
body (`{ operation, input }`); the operation's input/output schemas
|
||||||
|
are already JSON Schemas in the `OperationSpec`. No reverse-
|
||||||
|
engineering of path/query/body semantics. The messy direction of the
|
||||||
|
`from_openapi` inverse is sidestepped.
|
||||||
|
- Per-caller API surface by default. `/search` is
|
||||||
|
`AccessControl`-filtered; the client sees only what it can call. The
|
||||||
|
Gitea failure mode (dumping admin ops to every caller) is structurally
|
||||||
|
impossible. This is a property the traditional per-operation-paths
|
||||||
|
OpenAPI doc cannot provide (OpenAPI has no per-caller filtering
|
||||||
|
concept).
|
||||||
|
- Easy to build clients for. Any language's `fetch` + JSON Schema
|
||||||
|
libraries can call the gateway: `POST /call` with a JSON body, get a
|
||||||
|
JSON result. No code generator needed for the common case; a code
|
||||||
|
generator produces a `CallClient` (call/search/schema/batch/
|
||||||
|
subscribe) instead of typed per-operation methods.
|
||||||
|
- 5 stable endpoints instead of a per-operation surface. The
|
||||||
|
versioning concern (OQ-39) is simpler — 5 endpoints that rarely
|
||||||
|
change vs. a per-operation surface that changes on every operation
|
||||||
|
addition/modification.
|
||||||
|
- `subscribe` maps cleanly onto SSE — the same projection ADR-036
|
||||||
|
describes, just as a single gateway entry point instead of per-
|
||||||
|
operation SSE streams.
|
||||||
|
- A deployment that wants the traditional REST surface can build it
|
||||||
|
additively. The gateway doesn't foreclose it.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- The generated OpenAPI doc is not a "nice UI" by default. A Swagger UI
|
||||||
|
rendering shows 5 generic endpoints instead of a REST tree. This is
|
||||||
|
the tradeoff for avoiding the flat→structured split and gaining per-
|
||||||
|
caller filtering. A deployment that wants the nice UI builds the
|
||||||
|
traditional projection (additive, with metadata).
|
||||||
|
- A code generator reading the gateway OpenAPI doc produces a
|
||||||
|
`CallClient` (generic call/search/schema methods), not typed per-
|
||||||
|
operation methods. Typed methods require the traditional projection
|
||||||
|
(with metadata) or a client that reads `/search` + `/schema` and
|
||||||
|
generates typed wrappers at build time. The gateway is optimized for
|
||||||
|
the `fetch`-and-JSON-Schema use case, not the code-generation use
|
||||||
|
case.
|
||||||
|
- The gateway doc is less "traditional" — a developer expecting a
|
||||||
|
REST OpenAPI doc sees a small RPC-style surface instead. This is
|
||||||
|
honest (the call protocol is a flat JSON RPC, not a REST API), but
|
||||||
|
it's a departure from OpenAPI conventions.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
1. **The gateway endpoint set is stable.** Once external clients build
|
||||||
|
against the 5-endpoint gateway, changing the endpoint set (renaming,
|
||||||
|
removing) breaks them. Adding endpoints is additive (non-breaking).
|
||||||
|
The initial 5-endpoint set is the published contract.
|
||||||
|
|
||||||
|
2. **`AccessControl` filtering is the right per-caller mechanism.** The
|
||||||
|
client sees the operations it's authorized to call. If an operation's
|
||||||
|
existence is itself sensitive, `Visibility::Internal` (ADR-015) is
|
||||||
|
the mechanism — Internal ops are excluded from `services/list` and
|
||||||
|
therefore from `/search` results. The gateway does not add a
|
||||||
|
separate visibility layer.
|
||||||
|
|
||||||
|
3. **The common case is `fetch` + JSON Schema, not code generation.**
|
||||||
|
The gateway is optimized for the developer who calls `POST /call`
|
||||||
|
with a JSON body and parses the result. The code-generation case
|
||||||
|
(typed per-operation methods) is served by the traditional projection
|
||||||
|
(additive) or a client that generates wrappers from `/search` +
|
||||||
|
`/schema` at build time.
|
||||||
|
|
||||||
|
4. **`subscribe` (SSE) is the streaming projection for the gateway.**
|
||||||
|
Over `h2`/`http/1.1`, subscriptions are SSE. Over WebTransport
|
||||||
|
(`h3`), subscriptions project onto WebTransport streams directly
|
||||||
|
(ADR-038) — the gateway's `/subscribe` is the `h2`/`http/1.1` path;
|
||||||
|
the WebTransport path is the native call-protocol session
|
||||||
|
(`webtransport.md`).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-015](015-privilege-model-and-authority-context.md) —
|
||||||
|
External/Internal visibility (Internal ops excluded from
|
||||||
|
`services/list`, therefore from `/search`)
|
||||||
|
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
|
||||||
|
`to_*` adapters are projections; published-spec compatibility contract
|
||||||
|
- [ADR-023](023-operation-error-schemas.md) — typed error `details`
|
||||||
|
mapped to OpenAPI error responses
|
||||||
|
- [ADR-036](036-http-to-call-operation-mapping.md) — the SSE projection
|
||||||
|
for subscriptions over `h2`/`http/1.1` (the gateway's `/subscribe`
|
||||||
|
endpoint uses the same SSE framing)
|
||||||
|
- [ADR-038](038-http3-and-webtransport-as-first-class.md) — the
|
||||||
|
WebTransport streaming path (the gateway's `/subscribe` is the
|
||||||
|
`h2`/`http/1.1` path; WebTransport is native)
|
||||||
|
- [ADR-041](041-mcp-tool-gateway-pattern.md) — the sibling gateway
|
||||||
|
pattern for `to_mcp` (4 tools; `subscribe` excluded because MCP tool
|
||||||
|
calls are request/response)
|
||||||
|
- OQ-39 — `to_openapi` published-spec versioning (simplified by the
|
||||||
|
gateway pattern to 5 stable endpoints)
|
||||||
|
- `crates/http/http-adapters.md` — the spec that implements the gateway
|
||||||
Reference in New Issue
Block a user