Split the feature graph so consumers pulling only the import adapters (from_openapi / from_jsonschema / from_mcp) no longer compile the axum / hyper server stack, and server-only deployments no longer compile reqwest. One crate, one import path — sides cut by features, not by a crate split. Feature graph: - server (default): axum host, gateway, WS upgrade, to_openapi, to_mcp - client (default): client host, forward, from_jsonschema, from_openapi - openapi: shared OpenAPISpec model (implied by both sides) - mcp: from_mcp needs client, to_mcp needs server - wss: tungstenite transport (from_wss); tungstenite half of the shared WS↔byte-stream adapter - h2/http1: hyper protocol features; imply server Wire-contract neutral: gateway endpoints, ALPNs, and all public API shapes unchanged; defaults keep both sides on. Supporting changes: - forward.rs drops its axum::body::Bytes type leak (bytes crate types) - bounded_join + error-echo caps move to input_validation (usable by both sides; openapi_spec no longer imports from forward) - byte_adapter: axum flavor compiles under server, tungstenite under wss; the generic pumps stay shared (WS-11) - input_validation / openapi_spec import-only internals gated to the side that consumes them - http-body-util moves to dev-dependencies (was test-only) - integration-test required-features updated for the new sides - from_wss unit tests (axum producer harness) gated to server Verified: cargo test (defaults, 453) and --all-features (575) pass; lean side builds (client / server / client,mcp / client,wss / server,wss / openapi-only) build clean with zero warnings; clippy -D warnings clean across all feature combinations; fmt clean.
183 lines
9.1 KiB
Markdown
183 lines
9.1 KiB
Markdown
# ADR-039: HTTP Server and Client Host Colocated in alkhttp
|
|
|
|
*Ported from alknet ADR-039 (HTTP Server and Client Host Colocated in alknet-http); re-targeted to alkhttp.*
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
alkhttp has two roles: an HTTP server (the `HttpAdapter`
|
|
`ProtocolHandler` for `h2`/`http/1.1`, built on `axum`/`hyper`)
|
|
and an HTTP client host (the `from_openapi`/`from_mcp` forwarding
|
|
handlers, built on `reqwest`). The question is whether these two
|
|
directions live in one crate (alkhttp) or are split into two
|
|
crates (an HTTP-server crate + an HTTP-client crate).
|
|
|
|
ADR-003 lists the HTTP crate as a single crate with dependency
|
|
`alkcall, axum` and justifies the per-handler-crate decomposition
|
|
with "each handler is self-contained — it receives a byte stream and
|
|
manages its own protocol." That rationale covers the server side (the
|
|
`HttpAdapter` is self-contained), but it does not address the
|
|
within-crate dual-role question: should the inbound HTTP server and
|
|
the outbound HTTP client (the adapter forwarding handlers) be
|
|
colocated, or split?
|
|
|
|
This is a load-bearing choice. Once published, downstream consumers
|
|
build import paths against the crate boundary; the shared `reqwest::Client`
|
|
and the no-env-vars invariant boundary (ADR-014) are scoped by it; the
|
|
`to_openapi`/`to_mcp` projections are pure-registry-consumers that
|
|
*describe* the server surface but live where the adapter types do.
|
|
Splitting later would be a rewrite of every consumer's import paths,
|
|
not a cheap revert. It needs an ADR.
|
|
|
|
## Decision
|
|
|
|
**One crate — alkhttp houses both the HTTP server and the HTTP
|
|
client host (the adapter forwarding handlers and the `to_*` projections).**
|
|
|
|
The two directions share the HTTP dependencies and HTTP-specific
|
|
concerns that make splitting them counterproductive:
|
|
|
|
- **Shared HTTP dependencies.** Both `axum` (server) and `reqwest`
|
|
(client) pull in `hyper`, `http`, `http-body`, `rustls`/TLS stack
|
|
types, and the HTTP header/status code types. A split into two crates
|
|
would either duplicate these dependencies across both crates or
|
|
force a third shared-types crate, neither of which is an improvement.
|
|
- **Shared HTTP-specific concerns.** Both directions care about HTTP
|
|
headers, status codes, content types, SSE framing, streaming vs
|
|
non-streaming bodies, and TLS trust stores. The `from_openapi`
|
|
forwarding handler's error mapping (HTTP status → `HTTP_<status>`
|
|
error codes, ADR-023) and the `to_openapi` projection's error mapping
|
|
(`ErrorDefinition.http_status` → HTTP response status) are *the same
|
|
mapping* read in two directions — splitting them would put the two
|
|
halves in different crates.
|
|
- **The `to_*` projections describe the server surface.** `to_openapi`
|
|
generates an OpenAPI doc whose paths mirror the gateway HTTP routes
|
|
the `HttpAdapter` serves (ADR-036's mapping, superseded by ADR-047 —
|
|
see [ADR-047](047-remove-direct-call-http-surface.md)).
|
|
`to_mcp` exposes the
|
|
same operations as MCP tools. These projections consume the
|
|
`OperationRegistry` and produce specs; they live with the adapter
|
|
types (in alkhttp, per the adapter location map — see
|
|
the alkcall crate docs, client-and-adapters)
|
|
because they share the operation-spec→HTTP mapping logic with the
|
|
server's request dispatch.
|
|
- **The no-env-vars invariant boundary is crate-scoped.** The
|
|
`from_openapi`/`from_mcp` forwarding handlers are the credential
|
|
injection point (ADR-014). The invariant — "no handler reads outbound
|
|
credentials from any source other than `OperationContext.capabilities`"
|
|
— is verified against the handler implementations in this crate. A
|
|
split would put the invariant verification boundary across two crates.
|
|
|
|
### What this does NOT change
|
|
|
|
- ADR-003's rule "no handler crate depends on another handler crate"
|
|
applies to peer handler crates (alkhttp does not depend on
|
|
`alknet-ssh`). The alkhttp → `alkcall` edge is the
|
|
protocol-foundation exception (ADR-003 Amendment 1). This ADR is
|
|
about the *internal* structure of alkhttp, not its dependency
|
|
edges.
|
|
- The adapter location map (the `OperationAdapter` trait in
|
|
`alkcall`; the HTTP-backed adapter implementations in
|
|
alkhttp) is unchanged. This ADR records *why* the HTTP-backed
|
|
adapters live in the same crate as the HTTP server, not whether they
|
|
live in alkhttp vs `alkcall`.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
- One crate, one set of HTTP dependencies, one HTTP-specific concern
|
|
surface. No duplicated `hyper`/`http` types across two crates, no
|
|
shared-types crate needed.
|
|
- The `to_*` projections live with the server whose surface they
|
|
describe, and with the adapter types they consume. The operation-spec
|
|
→ HTTP mapping logic is in one place.
|
|
- The no-env-vars invariant verification boundary is one crate. The
|
|
`from_openapi`/`from_mcp` handlers and the credential injection
|
|
logic they share are co-located.
|
|
- A downstream consumer wires one crate (alkhttp) into the
|
|
`HandlerRegistry` and gets the full HTTP surface — server + adapters +
|
|
projections. No two-crate wiring.
|
|
|
|
**Negative:**
|
|
- A deployment that only needs the HTTP server (no `from_openapi`/`from_
|
|
mcp` forwarding) still compiles the `reqwest` dependency. Mitigated:
|
|
the `mcp` feature is already gated (ADR-037); the `from_openapi`
|
|
forwarding is always available but the `reqwest` client is only
|
|
constructed if a `from_openapi`/`from_mcp` adapter is registered at
|
|
assembly time. The dependency is compiled, the client is lazy.
|
|
- A deployment that only needs the HTTP client (e.g., an agent crate
|
|
that only uses `from_openapi` forwarding, no inbound HTTP) still
|
|
compiles `axum`/`hyper`. This is the rarer case — the agent crate
|
|
(alknet-agent) consumes `alkcall` directly for tool dispatch
|
|
and uses `from_openapi` via alkhttp's adapter, but doesn't
|
|
serve inbound HTTP itself. In practice, the agent deployment wires
|
|
alkhttp for the adapters and the CLI wires it for the server;
|
|
the compile cost is paid once per workspace, not once per deployment.
|
|
- The crate is larger than a single-direction crate would be. This is
|
|
the cost of colocating shared concerns; the alternative (two crates
|
|
+ a shared types crate) is more crates, not less code.
|
|
|
|
## Assumptions
|
|
|
|
1. **The shared-HTTP-dependencies argument holds.** `axum` and
|
|
`reqwest` both pull in `hyper` and the `http` crate's types; the
|
|
shared types (headers, status codes, method, URI) are the same. If
|
|
a future version of `axum` or `reqwest` diverges its HTTP types
|
|
(e.g., `axum` moves to a different HTTP implementation), this
|
|
argument weakens. As of `axum` 0.7+ and `reqwest` 0.12+, both are
|
|
built on `hyper` 1.x and share `http` types.
|
|
|
|
2. **The `to_*` projections share enough mapping logic with the server
|
|
to justify colocation.** The operation-spec → HTTP path/method/
|
|
error-status mapping is the same in both directions. If the
|
|
projections turn out to be pure registry-consumers with no
|
|
HTTP-mapping logic (just spec serialization), the colocation
|
|
argument is weaker — but the current design (ADR-036, ADR-023)
|
|
has them sharing the mapping.
|
|
|
|
## Amendment 1: feature-sided builds (`server` / `client`)
|
|
|
|
The colocation decision stands — one crate, one import path — but the
|
|
"the compile cost is paid once per workspace" mitigation proved
|
|
insufficient: a consumer that only imports (consumer side) was forced
|
|
to compile the axum/hyper server stack, and vice versa. The feature
|
|
graph now sides the crate without splitting it:
|
|
|
|
- `server` (default) — the axum `Router` host, the gateway routes,
|
|
the WS upgrade path, `to_openapi`, `to_mcp` (with `mcp`).
|
|
- `client` (default) — the outbound client host, the `from_*` import
|
|
adapters (`from_wss` additionally needs `wss`).
|
|
- `openapi` — the shared `OpenAPISpec` document model (implied by
|
|
both sides; `from_openapi` needs it, `to_openapi` needs it).
|
|
- `mcp` — `from_mcp` requires `client`, `to_mcp` requires `server`.
|
|
- `wss` — the tungstenite WS transport (`from_wss`; the tungstenite
|
|
flavor of the shared WS↔byte-stream adapter).
|
|
- `h2` / `http1` — hyper protocol features; imply `server`.
|
|
|
|
Default features keep both sides on (zero behavior change); lean
|
|
builds use `default-features = false` + the side they need. The
|
|
wire contract (gateway endpoints, ALPNs) is untouched — this is a
|
|
dependency-graph change, not a surface change.
|
|
|
|
## References
|
|
|
|
- [ADR-003](003-crate-decomposition.md) — crate decomposition (this
|
|
ADR addresses the within-alkhttp dual-role question, not the
|
|
dependency edge; Amendment 1 covers the `alkcall` edge)
|
|
- [ADR-014](014-secret-material-flow-and-capability-injection.md) —
|
|
the no-env-vars invariant whose verification boundary is crate-scoped
|
|
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
|
|
adapter contract; `to_*` are projections
|
|
- [ADR-023](023-operation-error-schemas.md) — the error mapping shared
|
|
between `from_openapi` (status → code) and `to_openapi` (code →
|
|
status)
|
|
- [ADR-036](036-http-to-call-operation-mapping.md) — the HTTP path =
|
|
operation path mapping shared between server dispatch and `to_openapi`
|
|
- [ADR-037](037-mcp-stdio-transport-exclusion.md) — the `mcp` feature
|
|
gate
|
|
- `overview.md` — the crate overview (the inline rationale
|
|
for this decision is replaced by a pointer to this ADR)
|