Files
alkhttp/docs/architecture/http-adapters.md
T
glm-5.3-flash ac6b4b6c9a fix(gateway): unify INVALID_INPUT to 422 on hand-rolled paths + sink deadline (GW-16, GW-17)
GW-16: empty body / malformed first line / missing header fields /
per-line cap / batch over-cap rejections now route through
call_error_to_http_response_with_identity, mapping INVALID_INPUT to
422 — same status as mid-stream chunk errors. One error class, one
status.

GW-17: invoke_sink wraps the registry sink invoke in the same 30 s
tokio::time::timeout the Once-op invoke uses; a hung sink handler
surfaces as a TIMEOUT (504, retryable) error envelope instead of
holding the HTTP connection forever. The sink wrapper bounds the
whole dispatch (chunk pacing included), matching http-server.md's
deadline contract.

Docs: http-server.md error table documents the 422 triggers and the
sink deadline; http-adapters.md batch cap status corrected.

to_openapi: gateway spec version 1.3.0 -> 1.4.0 (ADR-045 minor):
/publish framing faults and /batch cap reject documented at 422 (the
400 slots moved with the runtime); /publish 400 slot removed; 504
description covers the sink dispatch.

Verification: scripts/verify.sh OK (397 tests); cargo test
--all-features OK (513 tests); clippy --all-features --all-targets -D
warnings OK; cargo fmt --check OK.
2026-08-31 01:03:29 +00:00

703 lines
38 KiB
Markdown

---
status: draft
last_updated: 2026-08-27
---
# HTTP Adapters — from_openapi, from_jsonschema, and to_openapi
The OpenAPI-direction adapters plus the single-endpoint adapter:
`from_openapi` imports external HTTP APIs described by a full OpenAPI
document, `from_jsonschema` imports a single non-standard / non-OpenAPI
HTTP endpoint described by a caller-supplied `OperationSpec`, and
`to_openapi` generates an OpenAPI spec from the local registry's
`External` operations. This document covers all three, the error
fidelity (alkcall ADR-016 — Operation Error Schemas), and the
no-env-vars credential injection point.
## What
Three adapters, all in `alkhttp`:
1. **`from_openapi`** — parses an OpenAPI document, constructs a
`HandlerRegistration` bundle per OpenAPI operation with a forwarding
handler that calls the external HTTP endpoint via `reqwest`, and
returns the bundles for registration in the `OperationRegistry`. The
adapter implements `OperationAdapter` (the async trait from
`alkcall::client` — alkcall ADR-022 §5, Call Protocol Client and
Adapter Contract). Provenance is `FromOpenAPI` (leaf,
`composition_authority: None`, `scoped_env: None`, `Internal` by
default — alkcall ADR-017/018).
2. **`from_jsonschema`** — registers a single HTTP endpoint as a
call-protocol operation, one at a time, for non-standard /
non-OpenAPI / basic REST endpoints that don't have a full OpenAPI
document. The caller supplies an `OperationSpec` + `HttpServiceConfig`
+ path template + HTTP method; the adapter builds one
`HandlerRegistration` with a reqwest forwarding handler (the same
handler shape as `from_openapi`) and `FromJsonSchema` provenance.
Implements `OperationAdapter`. See
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md).
3. **`to_openapi`** — generates an OpenAPI document from the local
registry's `External` operations. A pure projection: it consumes the
registry, it does not produce entries for it (alkcall ADR-022 §5 —
the `to_*` adapters are outbound projections, not `OperationAdapter`
implementations). Served at `GET /openapi.json` by the HTTP server.
### from_openapi
```rust
pub struct FromOpenAPI {
spec: OpenAPISpec,
config: HttpServiceConfig,
}
#[async_trait]
impl OperationAdapter for FromOpenAPI {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
#### Type definitions
```rust
/// A parsed OpenAPI document. The internal representation is
/// `serde_json::Value`-based (ADR-051 §Assumptions #1) — both JSON and
/// YAML parse paths produce the same `serde_json::Value` tree, then feed
/// the existing `from_value` constructor. A future swap to
/// `openapiv3::OpenApi` is a two-way door: both JSON and YAML constructors
/// adapt in lockstep, since the constructor is the adapter between wire
/// format and internal type. The one-way constraint is that
/// `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and
/// `to_openapi` produces one. Both directions share the same Rust type,
/// but not the same document shape: `from_openapi` consumes traditional
/// per-operation-paths docs (one path per operation), while `to_openapi`
/// produces the 6-endpoint gateway doc (ADR-042, extended with `/publish`
/// by ADR-068). The type is shared; the shape is not.
///
/// Input formats (ADR-051): `from_openapi` accepts both JSON and YAML.
/// JSON is parsed via `serde_json`; YAML via `yaml_serde` (the maintained
/// fork of the deprecated `serde_yaml`). Both paths produce the same
/// `serde_json::Value`-based internal type — there is one
/// `OpenAPISpec`, not a JSON and a YAML variant. `from_str` detects
/// format by trying JSON first and falling back to YAML (defensive
/// default — ADR-051 §2: JSON's stricter grammar is immune to any
/// YAML-specific type interpretation, present or future; with
/// `yaml_serde` 0.10.x's YAML 1.2 core schema the coercion the original
/// rationale cited is not present, but JSON-first locks the contract
/// against a future YAML-parser swap).
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
// ... OpenAPI 3.x fields as needed
}
impl OpenAPISpec {
pub fn from_json(doc: &str) -> Result<Self, AdapterError>; // JSON input
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError>; // YAML input
pub fn from_str(doc: &str) -> Result<Self, AdapterError>; // format-detecting (JSON-first, YAML-fallback — ADR-051 §2)
pub fn from_value(raw: Value) -> Result<Self, AdapterError>; // pre-parsed serde_json::Value
}
/// Configuration for an HTTP-backed adapter (`from_openapi`). Carries
/// the base URL, auth credentials (from `Capabilities` at registration,
/// not env vars — the no-env-vars invariant), and optional headers. The
/// `auth` field is the auth scheme the external API expects (bearer,
/// apiKey, basic); the credential itself is read from
/// `OperationContext.capabilities` at call time, not stored here.
pub struct HttpServiceConfig {
pub namespace: String,
pub base_url: String,
pub auth: Option<HttpAuthScheme>,
pub default_headers: HashMap<String, String>,
}
pub enum HttpAuthScheme {
Bearer, // Authorization: Bearer <token>
ApiKey { header_name: String }, // e.g., X-API-Key: <key>
Basic, // Authorization: Basic <credentials>
}
```
The adapter:
1. Parses the OpenAPI document (`OpenAPISpec``paths`, `components`,
`$ref` resolution). Accepts JSON or YAML (ADR-051 — JSON via
`serde_json`, YAML via `yaml_serde`; `from_str` detects format
JSON-first/YAML-fallback, a defensive default — ADR-051 §2). On parse
failure, returns `AdapterError::SchemaParse`. The TS prior art
(`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns:
`resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs,
`buildInputSchema` (parameters + request body → input JSON Schema),
`buildOutputSchema` (200/201 response → output JSON Schema),
`detectOperationType` (SSE response → `Sub`, GET → `Query`,
else `Mutation`). Pub ops are not produced by `from_openapi`
OpenAPI has no representation for producer→consumer streaming in v1.
2. For each `(path, method, operation)` in `spec.paths`, constructs a
`HandlerRegistration`:
- `spec.name` = the `operationId` (or a generated
`${method}_${path_parts}` name if `operationId` is absent — same
normalization as the TS `normalizeOperationId`).
- `spec.namespace` = the `config.namespace` (the importing
deployment's name for the service, not the OpenAPI doc's `info.title`).
- `spec.op_type` = `Query` / `Mutation` / `Sub` (detected as `Sub`
from the method + response content type, same as TS).
- `spec.visibility` = `Internal` (adapter-registered ops are
composition material, not directly callable from the wire —
alkcall ADR-017).
- `spec.input_schema` / `output_schema` = the JSON Schemas built
from the OpenAPI parameters/responses.
- `spec.error_schemas` = the `ErrorDefinition`s built from the
non-2xx OpenAPI responses (alkcall ADR-016 §5 — see Error
Fidelity below).
- `spec.access_control` = `AccessControl::default()` (the adapter
doesn't declare scopes; the composing handler that reaches the
imported op gates access).
- `handler` = a forwarding handler (see Forwarding Handler below).
- `provenance` = `FromOpenAPI`, `composition_authority: None`,
`scoped_env: None` (leaf — alkcall ADR-018).
- `capabilities` = the credentials the forwarding handler needs (the
bearer token / API key for the external HTTP endpoint, injected by
the assembly layer at registration — see No-Env-Vars below).
3. Returns the bundles. The caller (the assembly layer) registers them
in the `OperationRegistry`.
### Forwarding handler
The forwarding handler is stored in the `HandlerRegistration` as a
`HandlerKind` (alkcall ADR-021). At call time, it:
1. Reads the call input (`serde_json::Value`).
2. Builds the outbound HTTP request:
- URL path: substitutes path parameters (`{id}` → input value),
appends query parameters from input fields not in the path.
- Method: the OpenAPI operation's method.
- Headers: `Content-Type: application/json` + the auth header built
from `context.capabilities` (see No-Env-Vars below).
- Body: the `body` field of the input (for `Mutation`/`Sub`).
3. Sends the request via the shared HTTP client (see HTTP Client
below).
4. For a `Query`/`Mutation`: parses the response body (JSON, text, or
binary — same content-type branching as the TS `createHTTPOperation`),
wraps it in a `ResponseEnvelope`, returns. Registered as
`HandlerKind::Once` — a `Handler` returning a single
`ResponseEnvelope`.
5. For a `Sub` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as
the TS `parseSSEFrames`), then the stream ends on SSE close (which
becomes `call.completed` on the wire). Registered as
`HandlerKind::Stream` — a `StreamingHandler` returning a
`BoxStream<ResponseEnvelope>` (alkcall ADR-021). Each SSE `data:`
frame becomes a `ResponseEnvelope::ok()`; an HTTP error (non-2xx)
becomes a single `ResponseEnvelope::error()` and ends the stream.
The streaming send rides the shared HTTP client's
**stream client** (see HTTP Client below): the same config minus
the total request timeout (FWD-15) — a subscription is unbounded in
*time* by contract (alkcall ADR-021 sets `deadline: None`), so the
outbound half must not die at the 30 s request deadline the
request/response half carries. Time-unbounded does not mean
memory-unbounded: the SSE parse loop enforces a **total
streamed-bytes cap** per subscription
(`HttpClientConfig.stream_total_byte_cap`, default 1 GiB),
accumulated across every chunk fed to the parser (FWD-14);
exceeding it terminates the stream with a single terminal error
envelope (`HTTP_413`), the same stream-ends semantics as the other
terminal arms. The bounded-time companion is the client's read
timeout, which stays armed on the stream client: it bounds
upstream *staleness* (no byte for 30 s → terminal), not stream
lifetime.
6. On HTTP error (non-2xx): maps to the declared `ErrorDefinition` by
HTTP status code (see Error Fidelity below), returns a `CallError`.
The handler is opaque to `alkcall`'s `CallAdapter` — it's a
`HandlerKind` the registry dispatches (via `invoke()` for `Once`,
`invoke_streaming()` for `Stream`). `alkcall` never sees `reqwest`.
### HTTP client (reqwest)
`alkhttp` maintains a shared HTTP client, constructed once and reused
across all `from_openapi`/`from_mcp` forwarding handlers. The client owns
connection pooling, keep-alive, TLS, and a retry stack. The shared type is
`reqwest_middleware::ClientWithMiddleware`, not a bare `reqwest::Client`
both retry and Retry-After are middleware on the stack, and middleware
requires the `ClientWithMiddleware` wrapper.
The shared `SharedHttpClient` exposes **two derived clients** from one
config, rebuilt-and-swapped together (the same atomicity rule as the
config itself):
- **Request client** (`SharedHttpClient::client`) — carries the total
request timeout (default 30 s, anchored to the gateway's Once-op
deadline). Every request/response forward (`from_openapi`/
`from_jsonschema` Queries and Mutations) sends through it.
- **Stream client** (`SharedHttpClient::stream_client`) — built from
the same config with the total request timeout removed and the
connect + read timeouts retained (FWD-15). Streaming (SSE)
subscription forwards send through it. reqwest 0.13's per-request
timeout override can lengthen a client-level total timeout but never
clear it (the request-scoped `None` falls back to the client
default), so a timeout-free subscription send requires the derived
client rather than a request extension. Unbounded *time* per
subscription is the contract (alkcall ADR-021); the complementary
bounds are the read timeout (staleness) and the total streamed-bytes
cap (see the forwarding-handler step 5 above).
Both clients carry the same middleware stack, redirect policy, and TLS
trust — the total request timeout is the only delta between them.
The middleware stack has two layers:
1. **`RetryTransientMiddleware`** (from `reqwest-retry`) — exponential
backoff on transient failures (connection errors, 5xx). The "retry N
times with increasing intervals" part. Configured via an
`ExponentialBackoff` policy at client construction.
2. **Inlined `RetryAfterMiddleware`** — parses the `Retry-After` header
on 429/503 and sleeps before the next request to that URL. The
"respect what the server told you" part. Inlined (MIT, ~50 lines of
real logic) from `melotic/reqwest-retry-after`, not pulled as a
dependency: the crate is complementary to `reqwest-retry` (whose
default strategy does not honor `Retry-After`), and inlining lets
the upstream's unbounded `HashMap<Url, SystemTime>` storage be
bounded for a long-running process.
Pooling, keep-alive, and TLS come from `reqwest::ClientBuilder` defaults;
outbound TLS uses the system trust store (standard HTTPS to external APIs
like OpenAI, Anthropic). Custom CA bundle + client certs are an optional
config for self-hosted API gateways (two-way-door implementation detail;
the credential comes from `Capabilities`, the TLS trust comes from the
system).
Credential injection happens per-request (from
`OperationContext.capabilities`), not at client construction — the client
is shared across all operations, the credentials are per-call.
Hot-reload of the pooling/retry config is **rebuild-and-swap**: a config
change rebuilds the `ClientWithMiddleware` and swaps it via `ArcSwap`
(the same pattern `ConfigIdentityProvider` uses for its
`ArcSwap<DynamicConfig>` reload — see the alkcall crate's
`docs/architecture/decisions/006-authcontext-structure.md` and
`docs/architecture/decisions/025-peerentry-and-identity-id-decoupling.md`).
A rebuild drops the connection pool / keep-alive state, which is
acceptable — a config change wanting a fresh pool is the case that
triggers it. The retry policy is baked into the middleware at
`ClientBuilder::build()` time; live policy mutation is not supported by
`reqwest-retry`, so cheap per-policy updates are not part of the model.
The exact pooling/retry config (pool size, retry count, timeout
defaults, hot-reloadability via `DynamicConfig`) is a two-way-door
implementation detail (OQ-40, now resolved); the one-way constraint is
that `alkhttp` owns its HTTP client (no env-var-based client config,
no shared global client).
**Downstream layering boundary.** The agent crate's provider SSE
normalization (replicating the solid part of aisdk's pattern — the
Vercel-UI-message normalization that maps different providers' SSE to a
common shape) sits on top of this `ClientWithMiddleware`: it consumes the
`reqwest::Response` stream the forwarding handler produces and emits
`call.responded` events. It does not replace the client or own
transport/pooling/retry. `alkhttp` owns transport; the agent crate
owns provider-specific SSE → Vercel-UI-message mapping. The aisdk
`core/client.rs` reference for HTTP client construction is *not* carried
forward — its env-var config and hand-rolled retry are the anti-patterns
discarded in favor of the middleware stack above. The
`@alkdev/operations/src/from_openapi.ts` SSE *normalization* pattern is
separate and stays referenced in the Forwarding Handler section above
(the `parseSSEFrames`, `createHTTPOperation`, content-type branching
patterns).
### No-Env-Vars credential injection
The forwarding handler is the **credential injection point** for the
no-env-vars architecture. The handler reads
`context.capabilities.get("<service>")` (e.g., `"openai"`, `"vastai"`,
`"github"`), extracts the credential, and injects it into the outbound
HTTP request:
- Bearer token → `Authorization: Bearer <token>`.
- API key → the header the OpenAPI spec declares (e.g., `X-API-Key:
<key>`, or `Authorization: ApiKey <key>` — the `HTTPServiceConfig.auth`
in the TS prior art shows the three auth types: `bearer`, `apiKey`,
`basic`).
- Basic auth → `Authorization: Basic <credentials>`.
The credential comes from `Capabilities`, which was populated by the
dispatch path from the `HandlerRegistration.capabilities` bundle
(alkcall ADR-018 §6), which was populated by the assembly layer from the
vault ([ADR-014](decisions/014-secret-material-flow-and-capability-injection.md)).
The handler never reads `std::env::var`. This is the
spec-level invariant: no handler reads outbound credentials from any
source other than `OperationContext.capabilities`. See
[overview.md](overview.md) and the alkcall crate's
`docs/architecture/client-and-adapters.md`.
### from_jsonschema
`from_jsonschema` registers a single HTTP endpoint as a call-protocol
operation, one at a time. It is functionally similar to `from_openapi`
but for one endpoint instead of a full OpenAPI document — for
non-standard, non-OpenAPI, or basic REST endpoints that don't have a
`paths` object, an `operationId`, or `components`. The caller supplies
the schema directly; the adapter builds a reqwest forwarding handler
identical in shape to `from_openapi`'s. See
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md).
```rust
pub struct FromJsonSchema {
spec: OperationSpec,
config: HttpServiceConfig,
path_template: String,
method: String,
http_client: Arc<SharedHttpClient>,
}
#[async_trait]
impl OperationAdapter for FromJsonSchema {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
```
The adapter:
1. Takes an `OperationSpec` (name, op type, input/output JSON Schema,
`error_schemas`, `access_control`, `visibility`), an
`HttpServiceConfig` (base URL, auth scheme, default headers — the
same config type `from_openapi` uses), a path template
(e.g. `/users/{id}/posts`), and an HTTP method (e.g. `GET`).
2. Builds one `HandlerRegistration`:
- `spec` = the caller-supplied `OperationSpec` (the caller already
has the JSON Schemas; no parsing needed).
- `handler` = a reqwest forwarding handler, identical in shape to
`from_openapi`'s: builds the HTTP request (path-template
substitution, query params, body), injects credentials from
`context.capabilities`, sends via the shared HTTP client, parses
the response (JSON / text / binary — same content-type branching).
For `Sub` op type, registers a `StreamingHandler`
(alkcall ADR-021) expecting `text/event-stream`.
- `provenance` = `FromJsonSchema` (leaf, `composition_authority: None`,
`scoped_env: None` — alkcall ADR-018).
- `capabilities` = the credentials the forwarding handler needs
(same no-env-vars path as `from_openapi`).
3. Returns the single bundle. The caller registers it in the
`OperationRegistry`.
#### Relationship to from_openapi
`from_jsonschema` is functionally similar to `from_openapi` but for one
endpoint instead of a full OpenAPI document. The two adapters share the
forwarding-handler implementation, the credential injection path, the
error-fidelity rule (`HTTP_<status>` prefix, alkcall ADR-016), the
streaming shape (alkcall ADR-021), and the no-env-vars invariant
([ADR-014](decisions/014-secret-material-flow-and-capability-injection.md)).
The difference is purely the input shape: a full document vs. a single
endpoint. See
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md)
§"Relationship to `from_openapi`" for the comparison table.
#### Origin (ADR-066)
`from_jsonschema` was originally placed in the call crate
(alkcall ADR-022 §5) as a schema-only adapter with a
`NOT_FOUND`-returning placeholder handler — broken, because an op in the
registry needs a real handler.
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md) moved
it to `alkhttp` and gave it a real reqwest forwarding handler. The
`FromJsonSchema` provenance variant stays in `alkcall`
(`OperationProvenance`, in `alkcall::registry` — alkcall ADR-027 records
the move from the call-crate side); only the adapter implementation
moved. See
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md) for the
full rationale (why the placeholder was broken, why the "schema-only"
concept conflated two things, why it was mispaced in the call crate).
### to_openapi
```rust
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
```
`to_openapi` generates an OpenAPI document with a **fixed gateway
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` (or `/batch`, `/subscribe`, or
`/publish`) 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).
#### The gateway endpoint set
`to_openapi` generates 6 fixed endpoints — the original five from
[ADR-042](decisions/042-openapi-gateway-pattern.md) plus `/publish`
([ADR-068](decisions/068-gateway-publish-endpoint.md)):
| OpenAPI path | Call protocol | HTTP method | Purpose |
|--------------|--------------|-------------|---------|
| `/search` | `services/list` | `GET` | List the caller's callable operations (AccessControl-filtered). Items: `name`, `namespace`, `op_type`. |
| `/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` (Sub) | `POST` (SSE) | Invoke a streaming operation. Body `{ operation, input }` (same shape as `/call`); response is `text/event-stream`. |
| `/publish` | `call.requested` (Pub) | `POST` (NDJSON) | Publish to a Pub operation. Request body is newline-delimited JSON — each line is one published chunk, streamed to the operation's `HandlerKind::Sink` handler (alkcall ADR-046; [ADR-068](decisions/068-gateway-publish-endpoint.md)). |
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` and `/publish` are the two endpoints the MCP gateway
excludes ([ADR-041](decisions/041-mcp-tool-gateway-pattern.md) —
MCP tool calls are request/response; the tool-gateway surface has
neither a streaming nor a client-publish shape). OpenAPI/SSE supports
streaming; the gateway's `/subscribe` uses the same SSE projection
[ADR-036](decisions/036-http-to-call-operation-mapping.md)
describes — `call.responded` → SSE `data:` frames, `call.completed` →
stream close. `/publish` inverts the direction: the HTTP caller's
newline-delimited JSON lines become `call.published` chunks on the call
protocol ([ADR-068](decisions/068-gateway-publish-endpoint.md)).
#### 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 6 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](decisions/042-openapi-gateway-pattern.md) §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](decisions/042-openapi-gateway-pattern.md) §5.
#### Shared dispatch spine with `to_mcp`
`to_openapi`'s `/call` endpoint and `to_mcp`'s `call` tool share the
same dispatch spine (resolve identity → build `OperationContext` →
`OperationRegistry::invoke()` → map `ResponseEnvelope`). The
wire-framing, discovery, streaming, and server-integration layers are
per-gateway. See [http-mcp.md](http-mcp.md) §"Shared dispatch spine
with `to_openapi`" and
`/workspace/@alkdev/alknet/docs/research/alknet-http-gateway-factoring/findings.md`
for the factoring recommendation (thin shared struct, not a trait).
### Error Fidelity (alkcall ADR-016)
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (alkcall ADR-016 §5). The normative rule (review
#002 W20): `from_openapi` must not produce error codes that collide
with the six protocol-level codes (`NOT_FOUND`, `FORBIDDEN`,
`INVALID_INPUT`, `INVALID_OPERATION_TYPE`, `INTERNAL`, `TIMEOUT`). The
adapter prefixes imported error codes with `HTTP_` and the status
number:
```rust
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
```
`to_openapi` projects `error_schemas` to the gateway endpoint's
response definitions. The `/call` endpoint's responses are the shared
components-referenced status map: the protocol-level errors, plus any
operation-level errors (keyed by the registry's declared `http_status`.
Entries whose codes lack the `HTTP_<status>` prefix carry
`x-runtime-behavior: 500` — the runtime mapper is purely code-driven,
see review-001 PRJ-04. Declarations at a protocol status from
`HTTP_<status>`-prefixed codes are **merged oneOf** into the shared
protocol response instead of overwriting it — the runtime emits both,
see review-002 PRJ-17):
```yaml
# /call endpoint responses
responses:
'200': { $ref: '#/components/schemas/CallOk' } # envelope { request_id, result, output }
'400': { description: plain-text extractor rejection (framework body) }
'415': { description: plain-text extractor rejection (missing Content-Type, PRJ-19) }
'401': { oneOf: [CallErrorForbidden, CallErrorInvalidOperationType] } # identity split, PRJ-20
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' } # merged oneOf when ops declare HTTP_404 (PRJ-17)
'422': { oneOf: [CallErrorInvalidInput, CallErrorInvalidOperationType] } # + plain-text extractor shape rejection (PRJ-19)
'429': { ... } # only when ops declare errors at 429; x-runtime-behavior: 500 for non-HTTP_* codes
'500': { oneOf: [CallErrorInternal, CallFailure] } # INTERNAL + non-HTTP_* operation codes
'503': { ... } # only when ops declare errors at 503
'504': { $ref: '#/components/responses/Timeout' }
```
The operation-declared errors are surfaced on the `/call` and
`/publish` endpoints' responses — the gateway projects the registered
operations' `error_schemas` as response definitions. This makes the
adapter contract from alkcall ADR-022 faithful on the error axis — no
silent dropping of error contracts. The `/batch` endpoint documents no
HTTP 500 (review-002 PRJ-21): every per-entry dispatch failure is an
in-band `results[]` entry; the only HTTP error status is the
request-level cap failure (422; GW-16 unified it with the
`INVALID_INPUT → 422` mapping — it was hand-rolled as 400 before
review-002). `BatchResultEntry.error` references
the `BatchError` component (review-002 PRJ-16b): the serialized
`CallError` as a oneOf over the six protocol-code envelopes plus a
generic arm carrying the operation-declared codes. See alkcall ADR-016.
## Why
`from_openapi` is how the alk stack composes external HTTP APIs (OpenAI,
Anthropic, vast.ai, GitHub) into the call protocol. An operation
imported via `from_openapi` is a first-class operation: it has a spec,
it's discoverable via `services/list`, it can be composed by handlers,
its errors are typed. The agent crate's LLM provider calls go through
`from_openapi`-imported operations — that's how the no-env-vars
invariant makes aisdk's env-var reads unreachable.
`from_jsonschema` fills the gap that `from_openapi` can't: endpoints
that have no OpenAPI document. A non-standard REST endpoint, a basic
internal API, or a third-party service with only a JSON Schema
description can be registered as a call-protocol operation one at a
time, with the same reqwest forwarding handler and the same
no-env-vars credential path. The caller supplies the schema; the
adapter supplies the handler. See
[ADR-066](decisions/066-from-jsonschema-as-http-adapter.md).
`to_openapi` is how external systems discover the alk stack's operation
surface. A client generator, a human developer, or a `fetch`-based
client reads the OpenAPI doc to learn the gateway's shape (6 fixed
endpoints), then calls `/search` to discover what *it* can call
(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 (alkcall ADR-022
Consequences) — once published, the 6-endpoint gateway shape is
one-way.
## Constraints
- **`from_openapi`/`from_mcp` handlers read credentials from
`OperationContext.capabilities`, not `std::env::var`.** This is the
no-env-vars invariant
([ADR-014](decisions/014-secret-material-flow-and-capability-injection.md)).
The handler implementations are verified against this invariant.
`from_jsonschema` shares this invariant — same handler shape, same
credential path ([ADR-066](decisions/066-from-jsonschema-as-http-adapter.md)).
- **`from_openapi`-registered ops are `Internal` by default.** They are
composition material, not directly callable from the wire (alkcall
ADR-017). The handler that composes them is `External`.
`from_jsonschema` ops are `Internal` by default for the same reason
([ADR-066](decisions/066-from-jsonschema-as-http-adapter.md)).
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
collision with protocol-level codes (alkcall ADR-016, review #002
W20). `from_jsonschema` shares this rule
([ADR-066](decisions/066-from-jsonschema-as-http-adapter.md)).
- **`from_openapi` accepts JSON and YAML; `from_str` detects format
JSON-first.** JSON-first is a defensive default (ADR-051 §2 as
amended): JSON's stricter grammar is immune to any YAML-specific type
interpretation. With `yaml_serde` 0.10.x (YAML 1.2 core schema) the
coercion the original rationale cited is not present, but JSON-first
locks the contract against a future YAML-parser swap. `from_str` tries
JSON first, falls back to YAML only if JSON parse fails.
`from_json`/`from_yaml` are the explicit constructors for callers that
know the format.
- **`to_openapi` is a pure projection.** It consumes the registry, does
not produce entries for it. Not an `OperationAdapter`.
- **`to_openapi` output is JSON.** The published gateway doc is served at
`GET /openapi.json`. YAML output is out of scope (ADR-051 §4); the gap
this fills is on the consume side (importing external YAML schemas),
not the publish side.
- **Published `to_openapi` specs are compatibility contracts.** The
generated gateway doc carries `info.version` (semver) tracking the
**gateway endpoint contract**, not the operation set — per-caller
operation changes (add/remove/modify, schema changes) do not bump
the version (the operation set is discovered via `/search`, not
preloaded into the doc). Consumers detect breaking changes via the
major version (alkcall ADR-022 Consequences,
[ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md),
resolves OQ-39).
- **`alkhttp` owns its HTTP client.** Shared across all forwarding
handlers, constructed once. The shared type is
`reqwest_middleware::ClientWithMiddleware` (middleware stack:
`RetryTransientMiddleware` + inlined `RetryAfterMiddleware`). No
env-var-based client config. Pooling/retry config is a two-way door,
resolved in OQ-40.
- **TLS for outbound calls uses the system trust store by default.**
Standard HTTPS to external APIs (OpenAI, Anthropic). Custom CA bundle
+ client certs are an optional config for self-hosted API gateways.
This is a two-way-door implementation detail; the credential (API
key/token) comes from `Capabilities`, the TLS trust comes from the
system.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| `from_openapi` is an `OperationAdapter` | alkcall ADR-022 (Call Protocol Client and Adapter Contract) | Async trait (`alkcall::client`); produces `HandlerRegistration` bundles. ~~`from_jsonschema` clause superseded by ADR-066~~ |
| `from_jsonschema` as HTTP-backed single-endpoint adapter in `alkhttp` | [ADR-066](decisions/066-from-jsonschema-as-http-adapter.md) | Moved `from_jsonschema` from the call crate (broken schema-only placeholder — the call-crate side of the move is alkcall ADR-027) to `alkhttp` as a real reqwest-backed single-endpoint adapter; `FromJsonSchema` provenance stays in `alkcall` as a leaf |
| `to_openapi` is a projection, not an adapter | alkcall ADR-022 (Call Protocol Client and Adapter Contract) | Consumes the registry, doesn't produce entries |
| Adapter-registered ops are `Internal` | alkcall ADR-017 (Privilege Model and Authority Context) | `from_openapi` ops are composition material |
| `from_openapi` provenance is a leaf | alkcall ADR-018 (Handler Registration, Provenance, and Composition Authority) | `composition_authority: None`, `scoped_env: None` |
| Error fidelity (`HTTP_<status>` codes) | alkcall ADR-016 (Operation Error Schemas) | 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 |
| HTTP path = operation path (~~direct-call surface~~) | [ADR-036](decisions/036-http-to-call-operation-mapping.md) → superseded by [ADR-047](decisions/047-remove-direct-call-http-surface.md) | ~~`POST /{service}/{op}` → `call.requested`~~ — removed; the gateway `/call` with `{ operation, input }` is the sole invoke path; `to_openapi` describes the gateway, not a per-operation surface |
| `to_openapi` gateway pattern | [ADR-042](decisions/042-openapi-gateway-pattern.md) | 6 fixed gateway endpoints (search/schema/call/batch/subscribe/publish — `/publish` per [ADR-068](decisions/068-gateway-publish-endpoint.md)), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
| `to_openapi` published-spec versioning | [ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md) | `info.version` semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version |
| Streaming handler for subscriptions | alkcall ADR-021 (Streaming Handler for Subscription Operations) | `from_openapi` / `from_jsonschema` `Sub` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE response → `BoxStream<ResponseEnvelope>`; `Query`/`Mutation` stay `HandlerKind::Once` |
| YAML input + JSON-first format detection | [ADR-051](decisions/051-yaml-input-for-from-openapi.md) | `from_openapi` accepts JSON and YAML (`from_json`/`from_yaml`/`from_str`); `from_str` is JSON-first/YAML-fallback (defensive default, §2 amended — `yaml_serde` 0.10.x is YAML 1.2, not 1.1; JSON-first locks the contract against a future parser swap); YAML dep is `yaml_serde`; `to_openapi` output stays JSON (out of scope, §4) |
## Open Questions
See [open-questions.md](open-questions.md) for full details.
- **OQ-39** (resolved): `to_openapi` published-spec versioning —
resolved by
[ADR-045](decisions/045-to-openapi-gateway-spec-versioning.md):
`info.version` semver tracks the gateway endpoint contract (major =
breaking gateway change, minor = additive, patch = wording); the
per-caller operation set is discovered via `/search` and does not bump
the version. The additive traditional per-operation-paths projection
([ADR-042](decisions/042-openapi-gateway-pattern.md) §5) versions
independently, out of scope.
- **OQ-40** (resolved): reqwest client config and connection pooling —
`ClientWithMiddleware` + `RetryTransientMiddleware` + inlined
`RetryAfterMiddleware`; rebuild-and-swap hot-reload; per-request
credential injection. Two-way-door config shape, now resolved.
## References
- alkcall ADR-022 (Call Protocol Client and Adapter Contract) — the
`OperationAdapter` trait (in `alkcall::client`), `to_*` are
projections
- [ADR-066](decisions/066-from-jsonschema-as-http-adapter.md) —
`from_jsonschema` as HTTP-backed single-endpoint adapter in
`alkhttp` (supersedes alkcall ADR-022 §5's `from_jsonschema` clause;
the call-crate-side record of the move is alkcall ADR-027)
- alkcall ADR-016 (Operation Error Schemas) — error fidelity,
`HTTP_<status>` prefix rule
- [overview.md](overview.md) — adapter location map, no-env-vars
invariant
- the alkcall crate's `docs/architecture/client-and-adapters.md` —
`OperationAdapter` trait, `AdapterError` variants (OQ-26), no-env-vars
invariant
- `/workspace/@alkdev/operations/src/from_openapi.ts` — TypeScript prior
art (parsing, SSE, auth headers, `createHTTPOperation`,
`parseSSEFrames` — the SSE normalization patterns, not the client
construction)
- `reqwest-retry` crate (https://docs.rs/reqwest-retry/) —
`RetryTransientMiddleware` / `ExponentialBackoff` retry policy
- `melotic/reqwest-retry-after`
(https://github.com/melotic/reqwest-retry-after) — `RetryAfterMiddleware`
source (MIT, inlined, not a dependency)