Files
alknet/docs/architecture/crates/http/http-adapters.md
glm-5.2 0a78306686 docs(http): add ADR-043 WebTransport bidirectional ALPN substrate; fix spec drift from mid-spec pivot
A consistency review of the alknet-http specs found two classes of
issues: internal contradictions from the mid-spec pivot (the to_openapi
gateway pattern landed in prose but not in cross-references), and a
systematic client→server assumption that only holds for the OpenAPI/MCP
case leaking into the WebTransport architecture.

Class 1 (internal contradictions):
- C1: to_openapi was half-refactored — body described the ADR-042
  gateway pattern but the decisions table and ADR-036 still said
  'paths mirror /{service}/{op}'. ADR-036's to_openapi clause is now
  amended as superseded by ADR-042; the stale decisions row and README
  Principle 2 are fixed.
- C2: the axum Router route list didn't include the 5 gateway endpoints
  (/search, /schema, /call, /batch, /subscribe). Added them; clarified
  /openapi.json as the gateway description doc; added gateway paths to
  the decoy exclusion list.
- C3: ADR-034 §5 still talked about the 'h3/WebTransport deferral
  bucket' that ADR-038 eliminated. Amended §5/Consequences/References
  to drop the deferral framing (the auth-model decision stands; only
  the 'when' wording was stale).

Class 2 (one-way direction assumption):
- C4/C5/C6: the WebTransport specs framed the session as browser→hub
  one-way, when the call protocol is bidirectional and WebTransport is
  a general ALPN transport substrate. New ADR-043 reframes WebTransport
  as a bidirectional ALPN transport substrate (call protocol is the
  first/canonical target; needs no WASM parser), names the call
  protocol's bidirectionality over WebTransport sessions, and states
  the inbound no-PeerId connection-local overlay as the mirror of
  ADR-034 §2. webtransport.md is updated to reflect this framing;
  ADR-040 is repositioned (not superseded) as the substrate's non-call-
  ALPN mechanism.
- C7: the HTTP/1.1+HTTP/2 surface's one-directionality is now named as
  a lossy consequence of HTTP request/response; WebTransport is named
  as the surface that restores the bidirectional call model.
- C8: overview.md acknowledges the from/to direction model is
  OpenAPI/MCP-specific, not a call-protocol property.

A review subagent pass on ADR-043 + webtransport.md found no critical
issues; warnings W1-W3 (residual browser-as-subject framing, ADR-009
rationale in spec, opening abstract tone) and suggestions S2/S4/S5
were addressed.
2026-06-29 10:43:18 +00:00

384 lines
19 KiB
Markdown

---
status: draft
last_updated: 2026-06-29
---
# HTTP Adapters — from_openapi and to_openapi
The OpenAPI-direction adapters: `from_openapi` imports external HTTP APIs
as call-protocol operations (reqwest-backed forwarding handlers), and
`to_openapi` generates an OpenAPI spec from the local registry's
`External` operations. This document covers both, the error fidelity
(ADR-023), and the no-env-vars credential injection point.
## What
Two adapters, both in `alknet-http`:
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
`alknet-call`, ADR-017 §5). Provenance is `FromOpenAPI` (leaf,
`composition_authority: None`, `scoped_env: None`, `Internal` by
default — ADR-015/022).
2. **`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 (ADR-017 §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 concrete type is a two-way-door
/// implementation detail (openapiv3::OpenApi, a local alknet-http
/// type, or a serde_json::Value-based parse); 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 type.
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
// ... OpenAPI 3.x fields as needed
}
/// 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). 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 → `Subscription`, GET → `Query`,
else `Mutation`).
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` / `Subscription` (detected
from the method + response content type, same as TS).
- `spec.visibility` = `Internal` (adapter-registered ops are
composition material, not directly callable from the wire —
ADR-015).
- `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 (ADR-023 §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 — ADR-022).
- `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 the `Arc<dyn Handler>` stored in the
`HandlerRegistration`. 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`/`Subscription`).
3. Sends the request via the shared `reqwest::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.
5. For a `Subscription` (`text/event-stream` response): streams
`call.responded` events as the SSE chunks arrive (same SSE parsing as
the TS `parseSSEFrames`), then `call.completed` on stream end.
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 the `CallAdapter` — it's an `Arc<dyn Handler>`
the registry dispatches. `alknet-call` never sees `reqwest`.
### HTTP client (reqwest)
`alknet-http` maintains a shared `reqwest::Client` (constructed once,
reused across all `from_openapi`/`from_mcp` forwarding handlers). The
client handles connection pooling, keep-alive, and TLS. The aisdk
`core/client.rs` reference shows the pattern worth referencing: a shared
client with `OnceLock<reqwest::Client>`, retry logic (exponential
backoff, `Retry-After` header), and separate streaming vs non-streaming
clients. `alknet-http` owns its HTTP client; it does not inherit aisdk's.
The retry/pooling config comes from `StaticConfig` or `DynamicConfig`
(hot-reloadable). The credential injection happens per-request (from
`OperationContext.capabilities`), not at client construction — the
client is shared across all operations, the credentials are per-call.
The exact pooling/retry config is a two-way-door implementation detail
(OQ-40); the one-way constraint is that `alknet-http` owns its `reqwest`
client (no env-var-based client config, no shared global client).
### 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
(ADR-022 §6), which was populated by the assembly layer from the vault
(ADR-014). 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
[client-and-adapters.md](../call/client-and-adapters.md).
### 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` 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 5 fixed endpoints:
| OpenAPI path | 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) | `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)
`from_openapi` maps OpenAPI non-2xx response status codes to
`ErrorDefinition`s (ADR-023 §5). The normative rule (review #002 W20):
`from_openapi` must not produce error codes that collide with the five
protocol-level codes (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`,
`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 include the
operation-level errors (mapped by `http_status`), plus the protocol-
level errors:
```yaml
# /call endpoint responses
responses:
'200': { schema: <output_schema for the called operation> }
'400': { schema: <INVALID_INPUT error> }
'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> }
```
The operation-level errors (with `http_status`) are surfaced on the
`/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
`from_openapi` is how alknet 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.
`to_openapi` is how external systems discover the alknet operation
surface. A client generator, a human developer, or a `fetch`-based
client reads the OpenAPI doc to learn the gateway's shape (5 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 (ADR-017 Consequences) —
once published, the 5-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). The handler implementations are
verified against this invariant.
- **`from_openapi`-registered ops are `Internal` by default.** They are
composition material, not directly callable from the wire (ADR-015).
The handler that composes them is `External`.
- **`from_openapi` error codes are prefixed `HTTP_<status>`.** No
collision with protocol-level codes (ADR-023, review #002 W20).
- **`to_openapi` is a pure projection.** It consumes the registry, does
not produce entries for it. Not an `OperationAdapter`.
- **Published `to_openapi` specs are compatibility contracts.** The
generated spec's versioning (tied to the registry's `External`
operation set version) must be emitted so consumers can detect mapping
changes (ADR-017 Consequences, OQ-39).
- **`alknet-http` owns its `reqwest::Client`.** Shared across all
forwarding handlers, constructed once. No env-var-based client config.
Pooling/retry config is a two-way door (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` | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Async trait; produces `HandlerRegistration` bundles |
| `to_openapi` is a projection, not an adapter | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Consumes the registry, doesn't produce entries |
| Adapter-registered ops are `Internal` | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `from_openapi` ops are composition material |
| `from_openapi` provenance is a leaf | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | `composition_authority: None`, `scoped_env: None` |
| 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 |
| HTTP path = operation path (direct-call surface) | [ADR-036](../../decisions/036-http-to-call-operation-mapping.md) | `POST /{service}/{op}` → `call.requested` (the direct-call surface; not what `to_openapi` describes) |
| `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. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-39** (open): `to_openapi` published-spec versioning — the
versioning strategy for generated OpenAPI specs (tied to the
registry's `External` operation set version). One-way after first
publication.
- **OQ-40** (open): reqwest client config and connection pooling —
two-way-door: the exact pooling/retry config shape, hot-reloadable
via `DynamicConfig`.
## References
- [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md)
— `OperationAdapter` trait, `to_*` are projections
- [ADR-023](../../decisions/023-operation-error-schemas.md) — error
fidelity, `HTTP_<status>` prefix rule
- [overview.md](overview.md) — adapter location map, no-env-vars
invariant
- [../call/client-and-adapters.md](../call/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`)
- `/workspace/aisdk/src/core/client.rs` — HTTP client reference (pooling,
retry, streaming vs non-streaming)