docs: missing_docs sweep — 0 warnings + deny gate + publish-prep decisions (HY-02, HY-04, HY-11)

- document every public-API item across 18 files (openapi_spec model,
  HttpAuthScheme/HttpServiceConfig, HttpClientBuildError + SharedHttpClient
  accessors, RetryAfterMiddleware, GatewayDispatch, gateway error
  mapping, CallRequest/SchemaQuery/SubscribeStream, HttpAdapter +
  ALPNs + builders, decoy/healthz/state, WsSessions/WsPumps,
  from_openapi/from_jsonschema/from_mcp/from_wss/to_mcp, lib.rs module
  docs)
- enforcement: #![deny(missing_docs)] at crate root — stronger than CI
  rustdocflags (every build incl. cfg(test), where rustdoc misses the
  test-support module docs)
- HY-10 (opportunistic): all 8 docs.rs/alkhttp placeholder ADR links +
  the one relative ../docs link converted to plain text; the 10
  pre-existing private/redundant intra-doc-link warnings fixed —
  RUSTDOCFLAGS="-D warnings" cargo doc is fully clean
- HY-11 decision: docs/ + tasks/ excluded from the published package
  (contributor-facing design/process material; ADR references degrade
  to plain text uniformly). cargo publish --dry-run: 38 files, ~889 KiB,
  zero docs/ or tasks/ entries
- HY-04 decision: keep + document — frame_channel0_chunk's unwrap is
  on serializing the acyclic EventEnvelope (unreachable failure);
  # Panics on it and the adjacent WsClient senders state the contract

Verified: cargo test (299 + 5 TLS), --all-features (370 + suites),
--no-default-features (299), clippy --all-targets -D warnings
(default + all-features), fmt --check, cargo doc -D warnings clean,
cargo publish --dry-run --allow-dirty clean.

Tasks: review-001-missing-docs-sweep (final pending task; 42/42)
This commit is contained in:
2026-08-30 08:25:18 +00:00
parent 7ce1ca6fbd
commit 91483a74b4
22 changed files with 395 additions and 31 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ description = "HTTP interface for the alk stack: serves HTTP/1.1 + HTTP/2 on sta
repository = "https://git.alk.dev/alkdev/alkhttp"
keywords = ["http", "websocket", "rpc", "openapi", "mcp"]
categories = ["network-programming", "web-programming", "asynchronous"]
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/sdd_process.md", "Cargo.lock"]
exclude = [".opencode/", "AGENTS.md", "docs/", "tasks/", "Cargo.lock"]
[lib]
name = "alkhttp"
+26 -3
View File
@@ -17,8 +17,8 @@
//! sends must match what `/schema` advertises, so peer-supplied input
//! cannot add upstream query parameters, headers, or craft a request body
//! the contract does not declare. Declared keys route by designation: a
//! property marked [`HEADER_PARAM_IN_MARKER`]` = "header"` is sent as a
//! request header, the declared [`GATEWAY_BODY_KEY`] property becomes the
//! property marked with the `HEADER_PARAM_IN_MARKER` marker key set to "header" is sent as a
//! request header, the declared `GATEWAY_BODY_KEY` property becomes the
//! request body, and every other declared key becomes an upstream query
//! parameter.
@@ -79,17 +79,40 @@ pub(crate) const GATEWAY_BODY_KEY: &str = "body";
pub(crate) const HEADER_PARAM_IN_MARKER: &str = "wire";
pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header";
/// The credential scheme forwarded handlers apply to outbound requests
/// (ADR-014). The credential value itself flows through
/// `OperationContext.capabilities` at call time — never through this
/// config.
#[derive(Clone)]
pub enum HttpAuthScheme {
/// `Authorization: Bearer <token>` from the caller's `Bearer`
/// capability.
Bearer,
ApiKey { header_name: String },
/// A named API-key header (e.g. `x-api-key`) carrying the caller's
/// `ApiKey` capability value.
ApiKey {
/// The upstream header the key is sent in.
header_name: String,
},
/// HTTP Basic auth from the caller's `username`/`password`
/// capability pair.
Basic,
}
/// Assembly-time configuration for one imported HTTP service: the
/// registry namespace its operations land under, where traffic goes,
/// and how credentials attach.
pub struct HttpServiceConfig {
/// Registry namespace for the imported operations (the
/// `<namespace>/<operationId>` op names).
pub namespace: String,
/// Outbound base URL every path template is resolved against.
pub base_url: String,
/// Credential scheme for outbound requests; `None` sends an
/// unauthenticated request.
pub auth: Option<HttpAuthScheme>,
/// Static headers attached to every outbound request (e.g. a
/// required `User-Agent`).
pub default_headers: HashMap<String, String>,
}
+5 -1
View File
@@ -35,6 +35,10 @@ use serde_json::Value;
use super::forward::{forward, forward_stream, HttpServiceConfig};
use crate::client::SharedHttpClient;
/// The HTTP-backed single-endpoint adapter (ADR-066): one caller-built
/// [`OperationSpec`] forwarded to one HTTP endpoint. Eagerly validated
/// at construction; the caller's spec visibility is forced to
/// `Internal` (ADR-015).
pub struct FromJsonSchema {
spec: OperationSpec,
config: HttpServiceConfig,
@@ -61,7 +65,7 @@ impl FromJsonSchema {
/// are rejected at call time. Mark a property with
/// `"wire": "header"` to route it as an upstream HTTPS header
/// instead of a query parameter (review 001 OAI-03). Input key
/// [`GATEWAY_BODY_KEY`](super::forward::GATEWAY_BODY_KEY) carries
/// `GATEWAY_BODY_KEY` ("body") carries
/// the request body.
pub fn new(
spec: OperationSpec,
+10
View File
@@ -50,6 +50,9 @@ const MCP_CAPABILITY_KEY: &str = "mcp";
/// timed out. Payload is the array-of-content-blocks shape; retryable.
const MCP_TRANSPORT_ERROR: &str = "MCP_TRANSPORT_ERROR";
/// The `from_mcp` adapter (mcp feature): imports a remote MCP server's
/// tools as call-protocol operations under one namespace, with the
/// auth token injected per-call from `Capabilities` (ADR-014).
pub struct FromMCP {
endpoint: String,
auth_token: Option<Secret<String>>,
@@ -57,6 +60,8 @@ pub struct FromMCP {
}
impl FromMCP {
/// Assemble the adapter for a streamable-HTTP MCP `endpoint`,
/// registering its tools under `namespace`.
pub fn new(endpoint: impl Into<String>, namespace: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
@@ -65,19 +70,24 @@ impl FromMCP {
}
}
/// Set the auth token sent as the MCP endpoint's `Authorization:
/// Bearer` header (wrapped in `Secret` so it never logs).
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(Secret::new(token.into()));
self
}
/// The configured MCP endpoint URL.
pub fn endpoint(&self) -> &str {
&self.endpoint
}
/// The namespace imported operations register under.
pub fn namespace(&self) -> &str {
&self.namespace
}
/// The configured token, for introspection (never logged).
pub fn auth_token(&self) -> Option<&Secret<String>> {
self.auth_token.as_ref()
}
+5
View File
@@ -88,6 +88,9 @@ fn reject_collisions(
Ok(())
}
/// The `from_openapi` adapter (ADR-051 input format): turns a parsed
/// OpenAPI document into forwarding-handler registrations under one
/// namespace — the credential injection point per ADR-014.
pub struct FromOpenAPI {
spec: OpenAPISpec,
config: HttpServiceConfig,
@@ -95,6 +98,8 @@ pub struct FromOpenAPI {
}
impl FromOpenAPI {
/// Assemble the adapter over a parsed spec, service config, and the
/// shared outbound client.
pub fn new(
spec: OpenAPISpec,
config: HttpServiceConfig,
+13
View File
@@ -78,6 +78,9 @@ fn connection_closed_error() -> CallError {
CallError::new("CONNECTION_CLOSED", "from_wss connection dropped", true)
}
/// The `from_wss` consumer adapter (wss feature): dials a remote WS
/// endpoint speaking the channels protocol and imports its operations
/// under an optional namespace (discovered when omitted).
pub struct FromWss {
endpoint: String,
auth_token: Option<Secret<String>>,
@@ -86,6 +89,8 @@ pub struct FromWss {
}
impl FromWss {
/// Assemble the adapter for a `wss://` (or explicit `ws://`)
/// channel endpoint.
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
@@ -95,11 +100,15 @@ impl FromWss {
}
}
/// Set the auth token injected per-call from `Capabilities`
/// (wrapped in `Secret` so it never logs).
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(Secret::new(token.into()));
self
}
/// Pin the namespace imported operations register under; without
/// it the remote's discovery advertises namespaces per operation.
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
@@ -114,14 +123,17 @@ impl FromWss {
self
}
/// The configured channel endpoint URL.
pub fn endpoint(&self) -> &str {
&self.endpoint
}
/// The pinned namespace, when one was set.
pub fn namespace(&self) -> Option<&str> {
self.namespace.as_deref()
}
/// The configured token, for introspection (never logged).
pub fn auth_token(&self) -> Option<&Secret<String>> {
self.auth_token.as_ref()
}
@@ -134,6 +146,7 @@ impl FromWss {
/// in-flight pending calls with retryable `CONNECTION_CLOSED`.
pub struct WssSession {
_client: ChannelClient,
/// The live channel-0 connection the consumer drives calls through.
pub call_connection: Arc<CallConnection>,
_monitor: WssDropMonitor,
}
+59
View File
@@ -16,50 +16,89 @@ use serde_json::Value;
/// exhausting the stack.
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 64;
/// The `paths`-level HTTP methods the adapter models. `trace` is
/// deliberately absent (OAI-06): a path carrying only unsupported
/// methods is skipped with a warning, not imported as a mis-behaving op.
pub(crate) const HTTP_METHODS: &[&str] =
&["get", "post", "put", "patch", "delete", "head", "options"];
/// The `info` block of an OpenAPI document.
#[derive(Clone, Debug)]
pub struct OpenAPIInfo {
/// Human-readable title (OpenAPI `info.title`).
pub title: String,
/// Declared spec/API version (OpenAPI `info.version`).
pub version: String,
}
/// One entry of a path item: a concrete path plus the operations
/// declared on it.
#[derive(Clone, Debug)]
pub struct PathItem {
/// `(method, operation)` pairs, methods lowercase as declared in the
/// document (`get`, `post`, ...), in parsed order.
pub operations: Vec<(String, Operation)>,
}
/// One OpenAPI operation parsed into the shared model.
#[derive(Clone, Debug)]
pub struct Operation {
/// `operationId` when declared; `from_openapi` falls back to
/// `method_path_derived` naming when absent.
pub operation_id: Option<String>,
/// Operation-level (non-`$ref`) parameters; `parameter $ref`s are
/// resolved before this model is built.
pub parameters: Vec<Parameter>,
/// `requestBody` when declared; `requestBody $ref`s are resolved
/// before this model is built.
pub request_body: Option<RequestBody>,
/// Responses keyed by their declared key — concrete statuses
/// (`"200"`, `"404"`), wildcards (`"4XX"`), or `"default"`. Consumers
/// must decide how to treat non-numeric keys (OAI-06).
pub responses: BTreeMap<String, Response>,
}
/// One OpenAPI parameter (`name`/`in`/`required`/`schema`).
#[derive(Clone, Debug)]
pub struct Parameter {
/// Declared parameter name; must be unique per location per
/// operation in a valid document.
pub name: String,
/// Parameter location: `path`, `query`, `header`, or `cookie`
/// (the last unsupported and rejected loudly at import).
pub in_: String,
/// Whether the operation fails without the parameter.
pub required: bool,
/// The parameter's schema as declared (unresolved `$ref`s are
/// resolved by the consumer via `resolve_refs_recursive`).
pub schema: Option<Value>,
}
/// An OpenAPI request body — the media-type → schema map of its
/// `content` field.
#[derive(Clone, Debug)]
pub struct RequestBody {
/// Media type (e.g. `application/json`) → the body schema.
pub content: BTreeMap<String, Value>,
}
/// An OpenAPI response — the media-type → schema map of its `content`
/// field.
#[derive(Clone, Debug)]
pub struct Response {
/// Media type (e.g. `application/json`) → the response schema.
pub content: BTreeMap<String, Value>,
}
/// The `components` block: reusable schemas, parameters, and request
/// bodies keyed by name.
#[derive(Clone, Debug)]
pub struct Components {
/// `components/schemas` entries.
pub schemas: HashMap<String, Value>,
/// `components/parameters` entries.
pub parameters: HashMap<String, Value>,
/// `components/requestBodies` entries.
pub request_bodies: HashMap<String, Value>,
}
@@ -73,15 +112,29 @@ fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> {
map
}
/// A parsed OpenAPI 3.x document: the typed model (`info`, `paths`,
/// `components`) plus the untouched `raw` document for `$ref`
/// resolution.
#[derive(Debug)]
pub struct OpenAPISpec {
/// The parsed `info` block.
pub info: OpenAPIInfo,
/// The path items keyed by their literal path string (with
/// placeholders).
pub paths: BTreeMap<String, PathItem>,
/// The `components` block, when the document declares one.
pub components: Option<Components>,
/// The full document as parsed — the lookup target for `$ref`
/// resolution.
pub raw: Value,
}
impl OpenAPISpec {
/// Parse a JSON OpenAPI document.
///
/// Fails with [`AdapterError::SchemaParse`] for malformed JSON or an
/// invalid document structure (missing `info`/`paths`, unresolvable
/// parameter `$ref`s).
pub fn from_json(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = serde_json::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid JSON: {e}"),
@@ -127,6 +180,12 @@ impl OpenAPISpec {
}
}
/// Parse an already-deserialized OpenAPI document.
///
/// Validation mirrors [`from_json`](Self::from_json): the object
/// shape, `info`/`paths` presence, parameter `$ref` resolvability,
/// and the OAI-06 loud-feature gates (`servers` overrides,
/// non-default `style`/`explode`) all apply.
pub fn from_value(raw: Value) -> Result<Self, AdapterError> {
if !raw.is_object() {
return Err(AdapterError::SchemaParse {
+8
View File
@@ -119,15 +119,20 @@ fn batch_input_schema() -> Value {
})
}
/// The `to_mcp` projection (ADR-041): the local call-protocol
/// operations exposed as 4 fixed MCP gateway tools (`search`, `schema`,
/// `call`, `batch`) over the streamable-HTTP transport.
pub struct ToMcpGateway {
dispatch: Arc<GatewayDispatch>,
}
impl ToMcpGateway {
/// Assemble the gateway over a dispatch spine.
pub fn new(dispatch: Arc<GatewayDispatch>) -> Self {
Self { dispatch }
}
/// The dispatch spine the tools invoke through.
pub fn dispatch(&self) -> &Arc<GatewayDispatch> {
&self.dispatch
}
@@ -480,8 +485,11 @@ impl rmcp::handler::server::ServerHandler for ToMcpGateway {
}
}
/// The ready-to-mount rmcp service wrapping [`ToMcpGateway`] over the
/// streamable-HTTP transport.
pub type ToMcpService = StreamableHttpService<ToMcpGateway, LocalSessionManager>;
/// Build the mountable MCP service for a dispatch spine.
pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService {
let gateway = ToMcpGateway::new(dispatch);
StreamableHttpService::new(
+28
View File
@@ -177,36 +177,59 @@ impl Default for HttpClientConfig {
}
}
/// Why a [`SharedHttpClient`] could not be built: PEM file reads fail
/// with `CaBundleRead`/`ClientCertRead`, PEM parsing fails with
/// `CaBundleParse`/`ClientCertParse` (both carry the offending path),
/// and the underlying reqwest builder fails with `Build`.
#[derive(Debug, Error)]
pub enum HttpClientBuildError {
/// The configured `ca_bundle` path could not be read.
#[error("failed to read CA bundle from {path}: {source}")]
CaBundleRead {
/// The unreadable path, for the caller's diagnostics.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},
/// The CA bundle file exists but is not valid PEM.
#[error("failed to parse CA bundle at {path}: {source}")]
CaBundleParse {
/// The unparseable path, for the caller's diagnostics.
path: PathBuf,
/// The underlying reqwest parse error.
#[source]
source: reqwest::Error,
},
/// A client-certificate PEM path (`cert_pem` or `key_pem`) could
/// not be read.
#[error("failed to read client cert from {path}: {source}")]
ClientCertRead {
/// The unreadable path.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},
/// The client cert/key files exist but do not form a valid
/// reqwest `Identity`.
#[error("failed to parse client cert at {path}: {source}")]
ClientCertParse {
/// The path of the identity whose parse failed.
path: PathBuf,
/// The underlying reqwest parse error.
#[source]
source: reqwest::Error,
},
/// The reqwest client itself failed to build (e.g. TLS backend
/// initialization).
#[error("failed to build reqwest client: {0}")]
Build(reqwest::Error),
}
/// A hot-reloadable, middleware-stacked outbound HTTP client shared by
/// consumer adapters. Clone-cheap (`ArcSwap` inner); callers reach the
/// current stack through [`SharedHttpClient::client`].
pub struct SharedHttpClient {
inner: ArcSwap<SharedHttpInner>,
}
@@ -244,10 +267,15 @@ impl SharedHttpClient {
})
}
/// The current middleware-stacked client. Every call loads the
/// latest stack — after a [`reload`](Self::reload), new requests
/// ride the rebuilt client.
pub fn client(&self) -> Arc<ClientWithMiddleware> {
Arc::clone(&self.inner.load().client)
}
/// The config the current client was built from (swapped together
/// with the client; FWD-12).
pub fn config(&self) -> Arc<HttpClientConfig> {
Arc::clone(&self.inner.load().config)
}
+16
View File
@@ -61,6 +61,10 @@ fn parse_retry_after_with_ceiling(value: &str, ceiling: Duration) -> Option<Syst
Some(clamped)
}
/// Per-URL `Retry-After` memory (FWD-05): records the backlog deadline
/// an upstream declared and holds back subsequent requests to that URL
/// until it elapses. LRU-bounded by capacity; deadlines clamped to the
/// configured ceiling.
pub struct RetryAfterMiddleware {
deadlines: Mutex<HashMap<Url, SystemTime>>,
capacity: usize,
@@ -68,10 +72,22 @@ pub struct RetryAfterMiddleware {
}
impl RetryAfterMiddleware {
/// A middleware with the default 300 s `Retry-After` ceiling.
///
/// # Panics
///
/// Panics when `capacity` is 0 (a rate-limit memory that remembers
/// nothing is a construction error, not a runtime fallback).
pub fn with_capacity(capacity: usize) -> Self {
Self::with_capacity_and_ceiling(capacity, Duration::from_secs(300))
}
/// A middleware with an explicit `capacity` bound and a ceiling
/// clamping hostile upstream deadlines.
///
/// # Panics
///
/// Panics when `capacity` is 0.
pub fn with_capacity_and_ceiling(capacity: usize, ceiling: Duration) -> Self {
Self {
deadlines: Mutex::new(HashMap::with_capacity(capacity.min(128))),
+13
View File
@@ -44,12 +44,17 @@ use serde_json::Value;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
/// The shared dispatch spine: registry + identity provider, wired for
/// the neutral `ResponseEnvelope` result shape both gateway projections
/// map to their wire formats.
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl GatewayDispatch {
/// Assemble a dispatch spine over a registry and an identity
/// provider.
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
@@ -60,18 +65,23 @@ impl GatewayDispatch {
}
}
/// The registry operations resolve against.
pub fn registry(&self) -> &Arc<OperationRegistry> {
&self.registry
}
/// The identity provider bearer tokens resolve against.
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
&self.identity_provider
}
/// Resolve a bearer token to an identity (the auth-middleware hook).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
self.identity_provider.resolve_from_token(token)
}
/// Invoke a Query/Mutation op under the 30 s gateway deadline; a
/// hung handler surfaces as a `TIMEOUT` error envelope (504).
pub async fn invoke(
&self,
identity: Option<Identity>,
@@ -97,6 +107,9 @@ impl GatewayDispatch {
}
}
/// Dispatch a Sub op: the returned stream of envelopes is unbounded
/// by the deadline (subscriptions are long-lived per alkcall
/// ADR-021); pre-handler failures surface as one error envelope.
pub fn invoke_streaming(
&self,
identity: Option<Identity>,
+9
View File
@@ -40,10 +40,16 @@ const STATUS_INTERNAL: u16 = 500;
const RETRY_AFTER_STATUSES: &[u16] = &[429, 503];
/// Map a `CallError` to its HTTP status code (identity-blind variant:
/// ambiguous codes resolve as if no identity were present).
pub fn call_error_to_http_status(error: &CallError) -> u16 {
call_error_to_http_status_with_identity(error, None)
}
/// Identity-aware status mapping: ambiguous protocol codes
/// (`FORBIDDEN`, `INVALID_OPERATION_TYPE`) map 401 without an identity
/// (you are not authenticated) and 403/422 with one (you are, but
/// lack the authority).
pub fn call_error_to_http_status_with_identity(
error: &CallError,
identity: Option<&Identity>,
@@ -74,6 +80,9 @@ pub fn call_error_to_http_status_with_identity(
}
}
/// Map a `CallError` to a full HTTP response — status from
/// [`call_error_to_http_status`], the serialized `CallError` as the
/// JSON body.
pub fn call_error_to_http_response(error: &CallError) -> Response {
call_error_to_http_response_with_identity(error, None)
}
+11
View File
@@ -98,15 +98,22 @@ pub(crate) fn gateway_router() -> Router<RouterState> {
.route("/publish", post(publish_handler))
}
/// The `/call` and `/subscribe` request body: the target operation
/// (with or without a leading `/`) and its input object.
#[derive(Debug, Deserialize)]
pub struct CallRequest {
/// The operation name, qualified (`namespace/op`); a leading slash
/// is tolerated.
pub operation: String,
/// The operation input; defaults to null when the line omits it.
#[serde(default = "Value::default")]
pub input: Value,
}
/// The `GET /schema?name=<operation>` query parameters.
#[derive(Debug, Deserialize)]
pub struct SchemaQuery {
/// The operation name to project, qualified (`namespace/op`).
pub name: String,
}
@@ -212,6 +219,10 @@ pub(crate) async fn subscribe_handler(
.into_response()
}
/// The SSE projection stream of `POST /subscribe`: each item is one
/// wire-ready frame (a `data:` event or a keep-alive comment); the
/// stream is infallible — errors arrive as in-band `event: error`
/// frames (GW-04).
pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
/// `POST /publish` (ADR-068): the body is NDJSON — one published chunk
+19
View File
@@ -1,11 +1,30 @@
//! alkhttp: HTTP interface for the alk stack — serves HTTP/1.1 + HTTP/2 on
//! standard ALPNs (with WebSocket upgrade carrying the channels protocol)
//! and hosts the HTTP-backed call-protocol adapters.
//!
//! # Documentation gate (HY-02)
//!
//! Public-API items must be documented: this crate is pre-crates.io and
//! docs.rs renders straight from the rustdoc. The deny below keeps the
//! gate from regressing.
#![deny(missing_docs)]
/// The HTTP-backed call-protocol adapters: `from_openapi` /
/// `from_jsonschema` / `from_mcp` (credential-injecting consumers) and
/// `to_openapi` / `to_mcp` (gateway projections of local operations).
pub mod adapters;
/// The shared outbound client host: hot-reloadable reqwest stack with
/// same-host-only redirects, idempotent-only retries, and TLS config.
pub mod client;
/// The 6 fixed gateway endpoints and their shared dispatch spine —
/// the sole HTTP invoke path.
pub mod gateway;
/// The HTTP server host: the `HttpAdapter` router, auth, stealth decoy,
/// `/healthz`, `/openapi.json`.
pub mod server;
/// The WebSocket upgrade path carrying the native call-protocol
/// session (browsers; ADR-044, ADR-048).
pub mod websocket;
pub use server::{decoy_fallback, decoy_method_not_allowed, healthz, DecoyConfig};
+19 -2
View File
@@ -23,9 +23,9 @@
//!
//! ## Connection knobs and boundaries
//!
//! [`HttpAdapter::serve_io`] configures the hyper auto builder with a
//! The accept-loop side (`serve_io`, crate-private) configures the hyper auto builder with a
//! tokio timer and timeouts (values in the method doc). The **concurrency
//! cap is not a knob of this crate**: [`ProtocolHandler::handle`] serves
//! cap is not a knob of this crate**: the `ProtocolHandler::handle` trait method serves
//! exactly one accepted bidirectional stream per call, and the accept
//! loop — how many streams are handled concurrently, on which tasks —
//! belongs to the consumer that owns the `Connection` (or the
@@ -53,7 +53,9 @@ use super::decoy::{decoy_fallback, decoy_method_not_allowed};
use super::healthz::healthz;
use super::state::{DecoyConfig, RouterState};
/// The HTTP/1.1 ALPN (`http/1.1`) `HttpAdapter` registers on (ADR-001).
pub const ALPN_HTTP1: &[u8] = b"http/1.1";
/// The HTTP/2 ALPN (`h2`) `HttpAdapter` registers on (ADR-001).
pub const ALPN_H2: &[u8] = b"h2";
/// The WS upgrade path (ADR-067). Reserved in the default surface; the
@@ -79,6 +81,11 @@ pub const RESERVED_PATHS: &[&str] = &[
WS_UPGRADE_PATH,
];
/// The HTTP server host (ADR-001, ADR-002, ADR-039): an axum
/// `Router` serving the gateway, `/healthz`, `/openapi.json`, the MCP
/// route, the WS upgrade path, and assembly-registered custom routes,
/// behind the bearer-auth middleware — served over one ALPN depending
/// on the constructor.
pub struct HttpAdapter {
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>,
@@ -94,6 +101,7 @@ pub struct HttpAdapter {
}
impl HttpAdapter {
/// An HTTP/1.1 adapter (registers on `http/1.1` ALPN).
pub fn new(
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>,
@@ -101,6 +109,7 @@ impl HttpAdapter {
Self::for_alpn(identity_provider, registry, ALPN_HTTP1)
}
/// An HTTP/2 adapter (registers on `h2` ALPN).
pub fn h2(
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>,
@@ -145,6 +154,8 @@ impl HttpAdapter {
}
}
/// Set the decoy surface for unregistered paths and rebuild the
/// router (custom routes are preserved — SRV-05).
pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self {
self.decoy = decoy.clone();
let state = RouterState {
@@ -165,6 +176,9 @@ impl HttpAdapter {
self
}
/// Mount assembly-provided custom routes under the bearer-auth
/// middleware (ADR-046) and rebuild the router. Reserved paths
/// (`RESERVED_PATHS`) are rejected before the merge.
pub fn with_extra_routes(mut self, routes: Router) -> Self {
let state = RouterState {
registry: Arc::clone(&self.registry),
@@ -241,14 +255,17 @@ impl HttpAdapter {
Arc::new(tokio::sync::Semaphore::new(max_sessions))
}
/// The configured decoy surface (assembly introspection).
pub fn decoy(&self) -> &DecoyConfig {
&self.decoy
}
/// The ALPN this adapter registers on (`http/1.1` or `h2`).
pub fn alpn(&self) -> &'static [u8] {
self.alpn
}
/// The assembled router (for the accept loop the consumer owns).
pub fn router(&self) -> &Router {
&self.router
}
+11
View File
@@ -24,6 +24,9 @@ use axum::response::Response;
use super::DecoyConfig;
/// The fallback handler for unregistered paths (stealth mode, ADR-010):
/// resolves the configured [`DecoyConfig`] variant — a fake nginx 404,
/// a static site, or a redirect.
pub async fn decoy_fallback(State(decoy): State<DecoyConfig>, request: Request) -> Response {
match decoy {
DecoyConfig::NotFound => fake_nginx_404(),
@@ -32,6 +35,8 @@ pub async fn decoy_fallback(State(decoy): State<DecoyConfig>, request: Request)
}
}
/// A fake nginx-format 404 body with a 404 status — the stealth
/// "nothing is here" response that does not disclose the gateway.
pub fn fake_nginx_404() -> Response {
let body = nginx_error_body("404 Not Found");
let mut resp = Response::new(Body::from(body));
@@ -62,10 +67,13 @@ fn nginx_405_response() -> Response {
/// `Router::method_not_allowed_fallback` — applies to every
/// previously registered `MethodRouter` (default surface + extra
/// routes).
/// A plain nginx-format 405 body — the decoy method-not-allowed
/// response for reserved paths hit with unsupported methods.
pub async fn decoy_method_not_allowed() -> Response {
nginx_405_response()
}
/// A 302 redirect to `to` (the `DecoyConfig::Redirect` surface).
pub fn redirect(to: &str) -> Response {
let mut resp = Response::new(Body::empty());
*resp.status_mut() = StatusCode::FOUND;
@@ -75,6 +83,9 @@ pub fn redirect(to: &str) -> Response {
resp
}
/// Serve a static site from `root` for unregistered paths (the
/// `DecoyConfig::StaticSite` surface); paths escaping the root get the
/// fake 404.
pub async fn serve_static(root: &Path, request: Request) -> Response {
let path = request.uri().path();
let resolved = match resolve_static_path(root, path).await {
+3
View File
@@ -10,6 +10,9 @@ use axum::response::IntoResponse;
const HEALTHZ_BODY: &str = "ok";
/// `GET /healthz` — the operational liveness probe. Always answers
/// `200 ok` (text/plain), independent of registry contents; the
/// response is deliberately uninformative (stealth):
pub async fn healthz() -> impl IntoResponse {
(
StatusCode::OK,
+10 -4
View File
@@ -17,10 +17,16 @@ pub enum DecoyConfig {
/// Serve a fake `404 Not Found` (the default — a fake nginx 404).
#[default]
NotFound,
/// Serve a static site from a configured directory.
StaticSite { root: PathBuf },
/// Redirect to a configured URL.
Redirect { to: String },
/// Serve a static site from the given directory root.
StaticSite {
/// The directory tree served for unregistered paths.
root: PathBuf,
},
/// Redirect unregistered paths to the given URL.
Redirect {
/// The redirect target URL.
to: String,
},
}
/// State embedded in the axum `Router`: the registry and identity
+5 -4
View File
@@ -38,8 +38,8 @@
//! bytes drain (the mux's EOF sentinels ride the same queue).
//!
//! Shared with the `from_wss` consumer path (ADR-070): one
//! implementation, both directions ([`WsFraming`] + the generic
//! [`run_read_pump`] / [`run_write_pump`] instantiated once per socket
//! implementation, both directions (`WsFraming` + the generic
//! `run_read_pump` / `run_write_pump` instantiated once per socket
//! flavor — WS-11/COV-03).
use std::{
@@ -402,6 +402,8 @@ pub struct WsPumps {
}
impl WsPumps {
/// Abort both pump tasks (forced session teardown; the remote sees
/// an abrupt close, not a graceful one).
pub fn abort(&self) {
self.read_task.abort();
self.write_task.abort();
@@ -454,8 +456,7 @@ impl WsFraming for AxumFraming {
}
/// Split a `WebSocket` into the byte stream + the pump tasks with the
/// default idle-read timeout
/// ([`DEFAULT_WS_IDLE_TIMEOUT`](crate::websocket::DEFAULT_WS_IDLE_TIMEOUT)).
/// default idle-read timeout (`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`).
/// See [`split_ws_to_bytes_idle`] for the configurable form.
pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
split_ws_to_bytes_idle(socket, Some(DEFAULT_WS_IDLE_TIMEOUT))
+3 -3
View File
@@ -1,11 +1,11 @@
//! WebSocket subsystem: the browser bidirectional path
//! ([ADR-067](../docs/architecture/decisions/067-websocket-carries-channels.md)).
//! (ADR-067, `docs/architecture/decisions`).
//!
//! A WS session carries the **channels protocol**: the 8-byte chunk
//! header multiplexes N logical channels over the WS binary message
//! stream; channel 0 is pre-negotiated as `alk/call` and dispatched by
//! the shared `Dispatcher`. The WS↔byte-stream adapter
//! ([`byte_adapter`]) is the single seam between axum's WS and
//! the shared `Dispatcher`. The WS↔byte-stream adapter (`byte_adapter`)
//! is the single seam between axum's WS and
//! alkcall's byte-oriented channels machinery — shared with the
//! `from_wss` consumer path ([ADR-070]).
+36
View File
@@ -157,6 +157,7 @@ impl axum::extract::FromRef<Arc<OperationRegistry>> for SessionState {
}
impl WsSessions {
/// A fresh, empty session registry.
pub fn new() -> Self {
Self::default()
}
@@ -173,6 +174,7 @@ impl WsSessions {
self.sessions.lock().len()
}
/// Whether no sessions are tracked.
pub fn is_empty(&self) -> bool {
self.sessions.lock().is_empty()
}
@@ -400,6 +402,17 @@ pub mod test_support {
/// Frame one `EventEnvelope` as a channel-0 chunk (8-byte chunk
/// header + 4-byte length prefix + JSON body) — the client-side
/// framing channel 0 uses over any transport.
///
/// # Panics
///
/// Panics only if `serde_json` cannot serialize the envelope —
/// unreachable for the acyclic wire type (no non-string map keys,
/// no untagged ambiguities), which is why this returns `Vec<u8>`
/// rather than `Result`: a test helper returning `Result` for an
/// impossible case is worse ergonomics than a documented panic
/// (review 001 HY-04, kept-as-is decision — the item ships behind
/// the opt-in `test-support` feature, the crate's documented
/// exception to no-panics-in-library-code).
pub fn frame_channel0_chunk(envelope: &EventEnvelope) -> Vec<u8> {
let body = serde_json::to_vec(envelope).unwrap();
let mut out = Vec::with_capacity(8 + 4 + body.len());
@@ -418,14 +431,19 @@ pub mod test_support {
}
impl ChunkAssembler {
/// A fresh, empty assembler.
pub fn new() -> Self {
Self::default()
}
/// Append raw bytes to the assembly buffer.
pub fn push(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}
/// Extract the next complete `(channel_id, payload)` chunk, if
/// a full one is buffered (8-byte header + declared payload
/// length).
pub fn next_chunk(&mut self) -> Option<(u32, Vec<u8>)> {
if self.buf.len() < 8 {
return None;
@@ -454,14 +472,19 @@ pub mod test_support {
}
impl FrameAssembler {
/// A fresh, empty assembler.
pub fn new() -> Self {
Self::default()
}
/// Append raw bytes to the assembly buffer.
pub fn push(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}
/// Extract the next complete `EventEnvelope` frame, if a full
/// length-prefixed frame is buffered and parses. Unparseable
/// frames are dropped (test-only surface).
pub fn next_frame(&mut self) -> Option<EventEnvelope> {
if self.buf.len() < 4 {
return None;
@@ -493,6 +516,8 @@ pub mod test_support {
}
impl WsClient {
/// Connect with a `Bearer` token header; the full WS stream is
/// returned (upgrade succeeded).
pub async fn connect_authorized(url: &str, token: &str) -> Result<Self, String> {
let mut request = url
.into_client_request()
@@ -554,10 +579,18 @@ pub mod test_support {
Self { sink, stream }
}
/// Send one binary WS message.
///
/// # Panics
///
/// Panics on a socket write failure — a test client that cannot
/// send has a broken test, not a recoverable runtime state.
pub async fn send_binary(&mut self, bytes: Vec<u8>) {
self.send_binary_piece(&bytes).await;
}
/// Send one binary WS message, in pieces (for split-frame
/// tests). Same panic contract as [`Self::send_binary`].
pub async fn send_binary_piece(&mut self, bytes: &[u8]) {
use futures::SinkExt;
self.sink
@@ -568,6 +601,8 @@ pub mod test_support {
.unwrap();
}
/// Send one text WS message. Same panic contract as
/// [`Self::send_binary`].
pub async fn send_text(&mut self, text: &str) {
use futures::SinkExt;
self.sink
@@ -616,6 +651,7 @@ pub mod test_support {
}
}
/// Close the WS with a normal-close frame.
pub async fn close(&mut self) {
use futures::SinkExt;
let _ = self.sink.close().await;
+85 -13
View File
@@ -1,7 +1,7 @@
---
id: review-001-missing-docs-sweep
name: missing_docs sweep + publish-prep decisions (HY-02, HY-04, HY-11)
status: pending
status: completed
depends_on: [review-001-client-config-and-cert-coverage]
scope: moderate
risk: low
@@ -40,11 +40,11 @@ warnings and that task reshapes the config surface it would document.
## Acceptance Criteria
- [ ] `cargo doc` with `-W missing_docs` (as deny) exits clean — 0 warnings
- [ ] Enforcement landed (CI rustdocflags or lint config) so it stays clean
- [ ] HY-11 decision recorded; `cargo publish --dry-run --allow-dirty` package contents match the decision
- [ ] HY-04 resolved (documented or restructured)
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass
- [x] `cargo doc` with `-W missing_docs` (as deny) exits clean — 0 warnings
- [x] Enforcement landed (CI rustdocflags or lint config) so it stays clean
- [x] HY-11 decision recorded; `cargo publish --dry-run --allow-dirty` package contents match the decision
- [x] HY-04 resolved (documented or restructured)
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass
## References
@@ -52,13 +52,85 @@ warnings and that task reshapes the config surface it would document.
## Notes
> Agent fills during implementation. The `cargo tree -d` and HY-10
> link items: HY-10's ADR-051 placeholder link in
> `src/adapters/from_openapi.rs:12` still resolves to a
> `https://docs.rs/alkhttp` placeholder — fix remaining placeholder/
> relative links opportunistically in this sweep and mark HY-10 fully
> resolved.
Re-measure at start (after the three preceding follow-up tasks):
**101 warnings** across 16 files — the client task had already
documented its new surface, and OAI-06 grew `openapi_spec.rs`.
Sweep order followed the re-measure, not the review's ranking.
**HY-02 mechanics**: documented every public/`pub(crate)`-doc-visible
item across `openapi_spec.rs` (the document model: per-field meaning
+ OAI-06 key semantics), `forward.rs` (`HttpAuthScheme`,
`HttpServiceConfig`), `http_client.rs` (`HttpClientBuildError`
per-variant incl. `#[source]` fields, `SharedHttpClient`,
`client()`/`config()` accessors), `retry_after.rs` (the middleware +
`# Panics` on zero capacity), `dispatch.rs`, `error.rs`, `routes.rs`
(`CallRequest`/`SchemaQuery`/`SubscribeStream`), `server/adapter.rs`
(ALPNs, `HttpAdapter` + builders + accessors), `decoy.rs`,
`healthz.rs`, `state.rs` (incl. struct-variant fields),
`websocket/upgrade.rs` + `byte_adapter.rs` (`WsSessions`, `WsPumps`),
`from_openapi.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`,
`from_wss.rs`, `to_mcp.rs`, `lib.rs` (module docs + `#![deny]`).
The `deny(missing_docs)` at the crate root is the enforcement —
`cargo test` builds the `#[cfg(test)]` code too, so the gate covers
the `test-support` module's docs as well (rustdoc alone misses
those). Opportunistic HY-10 closure: all 8
`https://docs.rs/alkhttp (docs/architecture/decisions)` placeholder
links and the one relative `../docs/architecture/...` link converted
to plain-text ADR mentions — zero `cargo doc` warnings (including
`-D warnings`) now, versus 10 pre-existing private-link warnings
before.
**HY-11 decision recorded**: `docs/architecture/` does **not** ship.
- Rationale: the ADRs are contributor-facing design records (internal
strategy, open questions, rejected designs), not user docs; shipping
them publishes future-direction material to crates.io. The rustdoc
ADR references degrade to plain text uniformly (see HY-10) — no
half-resolved links, nothing dangling on docs.rs.
- Landed as `exclude = [".opencode/", "AGENTS.md", "docs/", "tasks/",
"Cargo.lock"]`. Also excluded `tasks/` (42 task files, internal
process material, same rationale as `docs/reviews/`' existing
exclusion).
- `cargo publish --dry-run --allow-dirty` verified: **38 files,
~889 KiB** — `src/` (31) + 5 integration tests + manifest/lock/git-
metadata. `cargo package --list` contains zero `docs/` or `tasks/`
entries. (Tests ship intentionally — standard for crates; 112 KiB.)
- Note: `Cargo.lock` appears in the package despite the exclude entry —
cargo ≥1.54 always packages it for libraries; harmless (crates.io
ignores it) and the exclude entry documents intent.
**HY-04 decision recorded**: **keep + document**, not restructure.
`frame_channel0_chunk`'s `unwrap` is on `serde_json::to_vec` of the
acyclic `EventEnvelope` wire type (no non-string map keys, no untagged
ambiguities) — the failure mode is unreachable, and a `Result` return
would poison every test call site (this crate's + downstream
consumers') for an impossible case, which is worse ergonomics than a
documented panic. `# Panics` on the item states the contract and the
decision (HY-04, kept-as-is). The adjacent `WsClient::send_binary`/
`send_binary_piece`/`send_text` unwraps got the same treatment (test
client that can't send = broken test, not a runtime branch). The
`test-support` module remains the documented exception to
no-panics-in-library-code.
**Sequencing note**: the sweep landed after the client task as
planned; doing it last meant documenting the *final* shapes
(`PublishSchemaCache` in the gateway, `RetryConfig` on the client)
once — no churn.
## Summary
> Filled on completion.
- ~101 → **0** missing-docs warnings; `#![deny(missing_docs)]` in
`src/lib.rs` is the enforcement (stronger than CI rustdocflags:
every build, no CI wiring to maintain). `RUSTDOCFLAGS="-D warnings"
cargo doc --no-deps` is also fully clean (private-link and
redundant-link warnings fixed opportunistically — HY-10 fully
resolved).
- HY-11: `docs/` + `tasks/` excluded; publish dry-run = 38 files,
~889 KiB; decision + rationale + degradation story above.
- HY-04: kept + documented (`# Panics` with the unreachable-failure
rationale and the test-support exception note).
- Verified: `cargo test` (299 + 5 TLS), `--all-features` (370 + all
suites), `--no-default-features` (299; same 4 pre-existing
no-default-feature warnings as the base commit), `clippy --all-
targets -- -D warnings` (default + all-features), `fmt --check`,
`cargo doc --no-deps` clean under `-D warnings`,
`cargo publish --dry-run --allow-dirty` clean.