11 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| http/server/gateway-endpoints | Implement 5 gateway endpoints (search/schema/call/batch/subscribe) — axum route handlers | completed |
|
broad | medium | component | implementation |
Description
Implement the 5 fixed gateway endpoints in src/server/gateway_routes.rs.
These are the sole invoke path over HTTP (ADR-042, ADR-047): an HTTP
client invokes an operation via POST /call with
{ "operation": "/fs/readFile", "input": {...} }, discovers what it can
call via the AccessControl-filtered GET /search, and learns an
operation's shape via GET /schema. There is no per-operation
POST /{service}/{op} direct-call surface — the gateway is the invoke
path (ADR-047 supersedes ADR-036's direct-call surface).
The 5 endpoints (http-server.md §"HTTP-to-call dispatch", http-adapters.md §"The gateway endpoint set")
| Endpoint | Call protocol | HTTP method | Purpose |
|---|---|---|---|
/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) |
POST (SSE) |
Invoke a streaming operation. Body { operation, input }; response text/event-stream. |
POST /call dispatch (http-server.md §"HTTP-to-call dispatch")
- The axum route handler reads the JSON body
{ "operation": "/fs/readFile", "input": {...} }. - Resolves the caller's identity from the
Authorization: Bearerheader (via the sharedbearer_auth_middleware— stashed in extensions asResolvedIdentity). - Calls
GatewayDispatch::invoke(identity, operation, input)— the shared dispatch spine (thegateway-dispatch-spinetask). This builds the rootOperationContext(internal: false,forwarded_for: None) and dispatches throughOperationRegistry::invoke(). - The response (
ResponseEnvelope) is serialized as the HTTP response body (JSON). Errors map to HTTP status codes via theerror-mappingtask (call_error_to_http_response).
Internal operations (ADR-015) return 404 (NOT_FOUND) — the gateway
dispatches only External operations, and the caller discovers which
External operations it can call via the AccessControl-filtered
/search endpoint. This is the per-caller API surface property: an HTTP
client cannot stub its toe on a path for an operation it can't call,
because there is no per-operation path — /search tells it what it can
call, /call invokes it, and the AccessControl check runs on /call
regardless.
GET /search (AccessControl-filtered discovery)
Dispatches services/list through GatewayDispatch::invoke() with the
resolved caller identity. The services/list handler (already in
OperationRegistry) filters by AccessControl::check(identity) — the
client sees only the operations it is authorized to call. Returns
operation names + descriptions (not full schemas). Query parameters for
filtering/searching are a two-way-door extension (the v1 shape is "list
all I can call"; search/filter sugar is additive).
GET /schema
Dispatches services/schema through GatewayDispatch::invoke() with
the resolved caller identity. Returns the operation's full
OperationSpec (input/output JSON Schemas, error schemas). The
AccessControl check runs (an unauthorized caller gets FORBIDDEN, not
the schema).
POST /batch
Follows the same dispatch path as /call with an array of
{ operation, input } pairs (OQ-14). batch is a loop over
GatewayDispatch::invoke() in the gateway (research §6 open question
#3 — confirm batch is genuinely just a loop, no shared batch-specific
state, no transactional semantics). Returns an array of results (or
errors), one per entry, in order.
POST /subscribe (SSE streaming projection)
A Subscription operation invoked via the gateway's POST /subscribe
endpoint projects its call.responded stream as Server-Sent Events. The
request body is { operation, input } (the same flat JSON shape as
/call); the response is text/event-stream (negotiated via
Accept: text/event-stream on the POST). The axum route handler:
- Sets
Content-Type: text/event-stream. - For each
call.respondedevent, writes an SSEdata:frame (the event'soutputserialized as JSON). - On
call.completed, closes the SSE stream (normal end). - On
call.aborted, closes the stream with an SSE error event. - On HTTP client disconnect (detected as the response writer closing),
sends
call.abortedfor the in-flight subscription, which cascades to descendants per ADR-016.
This is the HTTP/1.1 + HTTP/2 streaming projection. Over WebSocket
(websocket.md), the subscription projects directly onto the WS
connection — call.responded events as binary WS messages, no SSE
framing. WebTransport (h3, deferred per ADR-044) would project onto
WebTransport bidirectional streams.
One-directional projection (http-server.md §"One-directional projection")
The HTTP/1.1 + HTTP/2 surface is a lossy, one-directional projection
of the call protocol. HTTP is request/response: the client initiates, the
server responds. The call protocol is bidirectional — both sides can
initiate calls. The HTTP projection carries only the client→server call
direction; the server→client call direction has no HTTP expression.
Subscription streaming is the one partial exception — the server
streams call.responded frames back over the SSE response — but even
there, the call is client-initiated; only the results flow
server→client. WebSocket restores the bidirectional call model for
browsers (the websocket/ tasks).
Constraints
- The gateway is the sole invoke path over HTTP (ADR-042, ADR-047).
No per-operation
POST /{service}/{op}direct-call surface. Externaloperations only.Internaloperations return404on the gateway's/call, matching the call protocol'sNOT_FOUND.- Bearer-only auth. Via the shared
bearer_auth_middleware. - No secret material in HTTP responses. Capabilities are used for
outbound calls (
from_openapi), never serialized into HTTP response bodies (ADR-014).
Acceptance Criteria
POST /callroute handler reads{ operation, input }JSON body/callresolves identity viaResolvedIdentityextractor (shared middleware)/calldispatches viaGatewayDispatch::invoke(identity, operation, input)/callresponse isResponseEnvelopeserialized as JSON/callerrors mapped viacall_error_to_http_response(error-mapping task)Internalop on/call→404 NOT_FOUNDExternalop withAccessControlrestrictions + unauthorized →403 FORBIDDENExternalop withAccessControlrestrictions + no identity →401GET /searchdispatchesservices/listviaGatewayDispatch::invoke/searchresults areAccessControl::check(identity)-filtered/searchreturns operation names + descriptions (not full schemas)GET /schemadispatchesservices/schemaviaGatewayDispatch::invoke/schemareturns the operation's fullOperationSpec/schemafor unauthorized op →403 FORBIDDENPOST /batchdispatches an array of{ operation, input }via loop overinvoke/batchreturns an array of results (or errors), one per entry, in orderPOST /subscribesetsContent-Type: text/event-stream/subscribewritescall.respondedevents as SSEdata:frames/subscribecloses stream oncall.completed/subscribewrites SSE error event oncall.aborted/subscribesendscall.abortedon HTTP client disconnect (ADR-016 cascade)- No per-operation
POST /{service}/{op}direct-call surface (ADR-047) - No secret material in HTTP response bodies (ADR-014)
- Integration test:
/callround-trip (External op → 200 + JSON body) - Integration test:
/callInternal op → 404 - Integration test:
/callunauthorized → 403 - Integration test:
/callunauthenticated + restricted op → 401 - Integration test:
/searchreturns only AccessControl-allowed ops - Integration test:
/schemareturns full spec for authorized op - Integration test:
/batchreturns array of results in order - Integration test:
/subscribestreams SSE events until completed - Integration test:
/subscribeclient disconnect → abort cascade cargo test -p alknet-httpsucceedscargo clippy -p alknet-http --all-targetssucceeds with no warnings
References
- docs/architecture/crates/http/http-server.md — HTTP-to-call dispatch, SSE projection, one-directional projection
- docs/architecture/crates/http/http-adapters.md — The gateway endpoint set, per-caller API surface
- docs/architecture/decisions/042-openapi-gateway-pattern.md — ADR-042 (5 fixed gateway endpoints)
- docs/architecture/decisions/047-remove-direct-call-http-surface.md — ADR-047 (gateway is sole invoke path)
- docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal → 404)
- docs/architecture/decisions/016-abort-cascade-for-nested-calls.md — ADR-016 (disconnect → abort cascade)
- docs/architecture/decisions/014-secret-material-flow-and-capability-injection.md — ADR-014 (no secrets in responses)
Notes
The 5 gateway endpoints are the sole HTTP invoke path (ADR-047). The /call handler delegates to GatewayDispatch::invoke (the shared spine); the error mapping is the error-mapping task; the auth is the shared bearer-auth-middleware. /subscribe is the SSE streaming projection — the one to_openapi-specific piece that does not go through the shared spine's request/response invoke (research §6 open question #5 — /subscribe is to_openapi-owned, not in the shared core). /batch is a loop over invoke (research §6 open question #3 — confirm no batch-specific shared state). The one-directional projection is a structural property of HTTP; WebSocket restores bidirectionality for browsers.
Summary
Implemented 5 fixed gateway endpoints in src/server/gateway_routes.rs: POST /call, GET /search, GET /schema, POST /batch, POST /subscribe (SSE). All delegate to GatewayDispatch::invoke; auth via ResolvedIdentity extractor; errors mapped via call_error_to_http_response (identity-aware 401/403 split). Internal ops → 404. /schema adds ACL pre-check. /subscribe projects ResponseEnvelope as SSE. /batch loops over invoke returning array. Wired into adapter.rs replacing placeholder 501s. 188 tests pass. Clippy clean.
Note: /subscribe SSE completes after single event (registry invoke returns single ResponseEnvelope, no streaming subscription handler yet — research §6 OQ#5).