First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3 server + from_openapi/to_openapi/from_mcp/to_mcp adapters). Specs (crates/http/): - README.md, overview.md — crate index, two-roles-in-one-crate framing, adapter location map, feature gates (h3, mcp), no-env-vars invariant - http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream, Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy - http-adapters.md — from_openapi (reqwest) and to_openapi (projection), error fidelity (HTTP_<status> per ADR-023), type definitions - http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only - webtransport.md — h3/WebTransport handler, browser streaming path, HTTP/3 request vs WebTransport session distinguished at framing layer ADRs: - ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path mapping; to_openapi is projection, not router (the load-bearing one-way door from Phase 0 DH-3) - ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP only; stdio is not built (RCE-vector security position) - ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in scope, not deferred, per ADR-009 §'What this framework is NOT' - ADR-039 HTTP Server and Client Host Colocated in alknet-http (Proposed) — one crate for server + client host (shared HTTP deps, shared operation-spec->HTTP mapping) - ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation crate (the alknet-http -> alknet-call dependency edge) Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'): - OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not a deferral — the decision is made when the use case becomes concrete) - OQ-39 to_openapi published-spec versioning (one-way after first publication) - OQ-40 reqwest client config and connection pooling (two-way-door) Architecture README and overview updated with doc table, ADR table (036-039), current-state note, and crate graph (alknet-http -> alknet-call edge). Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5 suggestion issues found and fixed (missing ADR-039, WebTransport stream routing conflation, undefined types, stale OQ-37 deferral language, README OQ table completeness, Bearer-only attribution, cross-references, ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
15 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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:
from_openapi— parses an OpenAPI document, constructs aHandlerRegistrationbundle per OpenAPI operation with a forwarding handler that calls the external HTTP endpoint viareqwest, and returns the bundles for registration in theOperationRegistry. The adapter implementsOperationAdapter(the async trait fromalknet-call, ADR-017 §5). Provenance isFromOpenAPI(leaf,composition_authority: None,scoped_env: None,Internalby default — ADR-015/022).to_openapi— generates an OpenAPI document from the local registry'sExternaloperations. A pure projection: it consumes the registry, it does not produce entries for it (ADR-017 §5 — theto_*adapters are outbound projections, notOperationAdapterimplementations). Served atGET /openapi.jsonby the HTTP server.
from_openapi
pub struct FromOpenAPI {
spec: OpenAPISpec,
config: HttpServiceConfig,
}
#[async_trait]
impl OperationAdapter for FromOpenAPI {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
Type definitions
/// 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:
- Parses the OpenAPI document (
OpenAPISpec—paths,components,$refresolution). On parse failure, returnsAdapterError::SchemaParse. The TS prior art (@alkdev/operations/src/from_openapi.ts) shows the parsing patterns:resolveReffor$ref,resolveRefsRecursivefor nested refs,buildInputSchema(parameters + request body → input JSON Schema),buildOutputSchema(200/201 response → output JSON Schema),detectOperationType(SSE response →Subscription, GET →Query, elseMutation). - For each
(path, method, operation)inspec.paths, constructs aHandlerRegistration:spec.name= theoperationId(or a generated${method}_${path_parts}name ifoperationIdis absent — same normalization as the TSnormalizeOperationId).spec.namespace= theconfig.namespace(the importing deployment's name for the service, not the OpenAPI doc'sinfo.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= theErrorDefinitions 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).
- 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:
- Reads the call input (
serde_json::Value). - 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 fromcontext.capabilities(see No-Env-Vars below). - Body: the
bodyfield of the input (forMutation/Subscription).
- URL path: substitutes path parameters (
- Sends the request via the shared
reqwest::Client(see HTTP Client below). - For a
Query/Mutation: parses the response body (JSON, text, or binary — same content-type branching as the TScreateHTTPOperation), wraps it in aResponseEnvelope, returns. - For a
Subscription(text/event-streamresponse): streamscall.respondedevents as the SSE chunks arrive (same SSE parsing as the TSparseSSEFrames), thencall.completedon stream end. - On HTTP error (non-2xx): maps to the declared
ErrorDefinitionby HTTP status code (see Error Fidelity below), returns aCallError.
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>, orAuthorization: ApiKey <key>— theHTTPServiceConfig.authin 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 and
client-and-adapters.md.
to_openapi
pub fn to_openapi(registry: &OperationRegistry) -> OpenAPISpec;
to_openapi generates an OpenAPI document from the local registry's
External operations:
- For each
Externaloperation in the registry, generate an OpenAPI path entry:- Path:
/{service}/{op}(the operation path, ADR-036 — the HTTP path IS the operation path). - Method: the operation's
OperationType→ HTTP method (Query→GET,Mutation→POST by default,Subscription→GET withtext/event-streamresponse). operationId: the operation name.parameters/requestBody/responses: built from the operation'sinput_schema/output_schema/error_schemas.
- Path:
- The
components.schemassection holds the reusable schemas referenced by$reffrom the paths. - The
infosection carries the API title, version, and description.
This 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).
Error Fidelity (ADR-023)
from_openapi maps OpenAPI non-2xx response status codes to
ErrorDefinitions (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:
// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }
to_openapi projects error_schemas back to OpenAPI response
definitions:
responses:
'200': { schema: <output_schema> }
'404': { schema: <error_schemas[i].schema> } # where http_status = 404
'429': { schema: <error_schemas[j].schema> } # where http_status = 429
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. An API gateway, a client generator, or a human developer reads
the OpenAPI doc to learn what operations exist and how to call them.
The generated spec is a compatibility contract (ADR-017 Consequences) —
once published, the mapping is one-way.
Constraints
from_openapi/from_mcphandlers read credentials fromOperationContext.capabilities, notstd::env::var. This is the no-env-vars invariant (ADR-014). The handler implementations are verified against this invariant.from_openapi-registered ops areInternalby default. They are composition material, not directly callable from the wire (ADR-015). The handler that composes them isExternal.from_openapierror codes are prefixedHTTP_<status>. No collision with protocol-level codes (ADR-023, review #002 W20).to_openapiis a pure projection. It consumes the registry, does not produce entries for it. Not anOperationAdapter.- Published
to_openapispecs are compatibility contracts. The generated spec's versioning (tied to the registry'sExternaloperation set version) must be emitted so consumers can detect mapping changes (ADR-017 Consequences, OQ-39). alknet-httpowns itsreqwest::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.
- 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
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
from_openapi is an OperationAdapter |
ADR-017 | Async trait; produces HandlerRegistration bundles |
to_openapi is a projection, not an adapter |
ADR-017 | Consumes the registry, doesn't produce entries |
Adapter-registered ops are Internal |
ADR-015 | from_openapi ops are composition material |
from_openapi provenance is a leaf |
ADR-022 | composition_authority: None, scoped_env: None |
Error fidelity (HTTP_<status> codes) |
ADR-023 | No collision with protocol codes; to_openapi projects back |
| No-env-vars credential injection | ADR-014 | Handler reads context.capabilities, not env vars |
| HTTP path = operation path | ADR-036 | to_openapi paths mirror /{service}/{op} |
Open Questions
See open-questions.md for full details.
- OQ-39 (open):
to_openapipublished-spec versioning — the versioning strategy for generated OpenAPI specs (tied to the registry'sExternaloperation 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
—
OperationAdaptertrait,to_*are projections - ADR-023 — error
fidelity,
HTTP_<status>prefix rule - overview.md — adapter location map, no-env-vars invariant
- ../call/client-and-adapters.md —
OperationAdaptertrait,AdapterErrorvariants (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)