docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation: Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint gateway, channels-over-WS, Sub/Pub operation types): - overview.md, http-server.md, http-adapters.md, http-mcp.md - README.md index (rewritten for alkhttp) New ADRs: - 067: WebSocket carries the channels protocol (8-byte chunk demux, channel 0 = alk/call, upgrade path /alk/channels) - 068: gateway /publish endpoint for Pub operations (NDJSON body) - 069: WebTransport out of scope in alkhttp (alknet concern) - 070: from_wss consumer adapter (wss feature, tokio-tungstenite) Ported ADRs (25, same numbers, port notes + amendments where the extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027, 034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066. websocket.md rewritten for the channels session; open-questions.md seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing, OQ-03 from_wss reconnect, OQ-04 browser client ownership). Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# ADR-042: OpenAPI Gateway Pattern for to_openapi
|
||||
|
||||
*Ported from alknet ADR-042 (OpenAPI Gateway Pattern for to_openapi); re-targeted to alkhttp.*
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
The current `to_openapi` spec (`http-adapters.md` in this crate's
|
||||
`docs/architecture/`; alknet original: `crates/http/http-adapters.md`)
|
||||
describes `to_openapi` as generating a traditional OpenAPI document with
|
||||
one path
|
||||
entry per `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 (alkcall), 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. (In
|
||||
alkhttp, the OpenAPI gateway is extended with `/publish` — ADR-068 —
|
||||
making it 6 endpoints; see Decision §1.)
|
||||
|
||||
## 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](023-operation-error-schemas.md)). |
|
||||
| `/batch` | multiple `call.requested` | `POST` | Invoke multiple operations in one request (correlated request IDs, alknet OQ-14). Returns an array of results. |
|
||||
| `/subscribe` | `call.requested` (Sub) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }` (same shape as `/call`); response is `text/event-stream` — each `call.responded` is an SSE frame, `call.completed` closes the stream. |
|
||||
|
||||
Five endpoints (the 5-endpoint set, extended with `/publish` in alkhttp
|
||||
— ADR-068; `POST /publish` invokes Pub operations — producer→consumer
|
||||
streaming; the request body is streamed as newline-delimited JSON, each
|
||||
line one published chunk, and the final `ResponseEnvelope` is returned
|
||||
as the HTTP response). The client calls `/search` to find operations,
|
||||
`/schema` to learn the input shape, `/call` (or `/subscribe` for
|
||||
streaming, `/publish` for Pub 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 `Sub` operations onto SSE: `POST /subscribe` with
|
||||
a `{ operation, input }` JSON body (same shape as `/call`) and
|
||||
`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.
|
||||
|
||||
`POST` (not `GET`) is used because `/subscribe` is an invoke endpoint
|
||||
that carries `{ operation, input }` in the request body, the same flat
|
||||
JSON body shape the rest of the gateway uses. A `GET` request has no
|
||||
body, so it cannot carry the operation name and input. The SSE response
|
||||
is negotiated via `Accept: text/event-stream` on the `POST`, not via the
|
||||
method. (Browsers using `EventSource` cannot `POST`, but browsers use
|
||||
WebSocket for the bidirectional path — ADR-044; the HTTP gateway's
|
||||
`/subscribe` is for non-browser HTTP clients, and `fetch` +
|
||||
`ReadableStream` handles POST-SSE cleanly.)
|
||||
|
||||
### 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 — extended with
|
||||
`/publish` in alkhttp, ADR-068); 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 — 6 in alkhttp); 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 — extended with
|
||||
`/publish` in alkhttp, ADR-068) and the
|
||||
request/response shapes are a compatibility contract ([ADR-017](017-call-protocol-client-and-adapter-contract.md)
|
||||
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 was tracked as
|
||||
alknet OQ-39 (now **resolved by
|
||||
[ADR-045](045-to-openapi-gateway-spec-versioning.md)**:
|
||||
`info.version` semver tracks the gateway endpoint contract, not the
|
||||
operation set) — the gateway pattern simplifies versioning 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 (6 in alkhttp
|
||||
with `/publish`, ADR-068). The
|
||||
versioning concern (alknet OQ-39) is simpler — a small set of
|
||||
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 (alkhttp
|
||||
extends it with `/publish` — ADR-068 — an additive addition, the
|
||||
pattern this assumption already permits).
|
||||
|
||||
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](015-privilege-model-and-authority-context.md)) 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 WebSocket (the v1
|
||||
browser bidirectional path, ADR-044), subscriptions project onto the
|
||||
WS connection directly as binary messages — the gateway's `/subscribe`
|
||||
is the `h2`/`http/1.1` SSE path; the WebSocket path is the native
|
||||
call-protocol session (the alkcall crate's `docs/architecture/`
|
||||
stream model; the gateway shape does not
|
||||
appear on WS per [ADR-048](048-websocket-native-session-not-gateway.md)).
|
||||
WebTransport (`h3`, deferred per ADR-044) would project onto
|
||||
WebTransport streams; no deferred WebTransport spec is carried in
|
||||
alkhttp (per ADR-044).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-015](015-privilege-model-and-authority-context.md) —
|
||||
External/Internal visibility (Internal ops excluded from
|
||||
`services/list`, therefore from `/search`; the decision record is
|
||||
alkcall ADR-017)
|
||||
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) —
|
||||
`to_*` adapters are projections; published-spec compatibility contract
|
||||
(the decision record is alkcall ADR-022)
|
||||
- [ADR-023](023-operation-error-schemas.md) — typed error `details`
|
||||
mapped to OpenAPI error responses (the decision record is alkcall
|
||||
ADR-016)
|
||||
- [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-044](044-defer-webtransport-browsers-use-websocket.md) —
|
||||
WebSocket is the v1 browser bidirectional path; `h3`/WebTransport
|
||||
deferred (the gateway's `/subscribe` is the `h2`/`http/1.1` SSE path;
|
||||
the WS path is the native call-protocol session). ADR-038 is
|
||||
superseded by ADR-044.
|
||||
- [ADR-068](068-gateway-publish-endpoint.md) — the alkhttp extension of
|
||||
this ADR's gateway: `POST /publish` (Pub operations,
|
||||
newline-delimited JSON chunks) makes the OpenAPI gateway 6 endpoints
|
||||
- ADR-041 (MCP Tool-Gateway Pattern for `to_mcp`) — the sibling gateway
|
||||
pattern for `to_mcp` (4 tools; `subscribe` excluded because MCP tool
|
||||
calls are request/response; ported to alkhttp — see
|
||||
`decisions/041-mcp-tool-gateway-pattern.md` if present, otherwise the
|
||||
alknet record)
|
||||
- alknet OQ-39 — `to_openapi` published-spec versioning (simplified by
|
||||
the gateway pattern to 5 stable endpoints; **resolved by
|
||||
[ADR-045](045-to-openapi-gateway-spec-versioning.md)**)
|
||||
- `http-adapters.md` (in this crate's `docs/architecture/`) — the spec
|
||||
that implements the gateway (alknet original:
|
||||
`crates/http/http-adapters.md`)
|
||||
|
||||
## Port notes
|
||||
|
||||
- Renames: "alknet" → alkhttp where the system/node being described is
|
||||
meant; "alknet-call" (protocol-foundation crate) → alkcall.
|
||||
- `OperationType::Subscription` → `Sub` in the gateway endpoint table,
|
||||
§2, and throughout (alkcall rename). alkcall ADR-046 added
|
||||
`OperationType::Pub` (producer→consumer streaming, `HandlerKind::Sink`);
|
||||
the original 5-endpoint table predates `Pub` and is preserved verbatim
|
||||
as decision history — the `/publish` extension is marked as an alkhttp
|
||||
addition (ADR-068), never silently rewritten.
|
||||
- **Gateway endpoint count**: the alknet record's "5 endpoints" is
|
||||
preserved everywhere as the original decision; alkhttp's `/publish`
|
||||
extension (ADR-068) is noted inline at each site where the count
|
||||
matters (§1, §3, §4, Assumption 1, Context). "Never rewrite history"
|
||||
framing: the published initial contract remains the 5-endpoint set;
|
||||
`/publish` is an additive extension under the contract's own
|
||||
additivity rule (§4, Assumption 1).
|
||||
- Producer/consumer terminology: "an admin sees admin operations, a
|
||||
regular user sees a subset" and similar retain their names — no
|
||||
call-protocol server/client role framing existed in the original
|
||||
body. The external client roles (code generator, human developer,
|
||||
`fetch`-based client, Gitea-style API consumers) are inherent-directionality
|
||||
roles of the HTTP/OpenAPI surface and keep their names.
|
||||
- Cross-reference remappings: ADR-015/017/023 are ported to this crate
|
||||
under the same numbers and linked; their alkcall record numbers are
|
||||
noted alongside (015→alkcall ADR-017, 017→alkcall ADR-022,
|
||||
023→alkcall ADR-016). ADR-041 is cited textually with its alknet
|
||||
number (the MCP gateway pattern; not part of this port's 3 files —
|
||||
verify the alkhttp port's existence when linking).
|
||||
- OQ references (OQ-14, OQ-39) are alknet OQ-tracker items, cited
|
||||
textually; OQ-39 is resolved by [ADR-045](045-to-openapi-gateway-spec-versioning.md)
|
||||
(ported to this crate under the same number).
|
||||
- Spec-document links converted per alkhttp conventions:
|
||||
`crates/http/http-adapters.md` → `http-adapters.md` in this crate's
|
||||
`docs/architecture/`; `websocket.md`/`webtransport.md` were alknet
|
||||
mono-repo spec-tree documents — the WS path here is the native
|
||||
call-protocol session (ADR-048) and no deferred WebTransport spec is
|
||||
carried (ADR-044).
|
||||
- No decision content changed — the flat→structured problem, per-caller
|
||||
surface problem, gateway pattern, the 5-endpoint table, the `subscribe`
|
||||
SSE semantics, `POST`-not-`GET` rationale, compatibility-contract
|
||||
clause, additive traditional projection, consequences, and assumptions
|
||||
are verbatim from the alknet ADR modulo the adaptations above.
|
||||
Reference in New Issue
Block a user