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:
glm-5.3-flash committed 2026-08-30 08:25:18 +00:00
1 parent 7ce1ca6fbd
commit 91483a74b4
22 files changed
+395 -31

No files matched your search

+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" repository = "https://git.alk.dev/alkdev/alkhttp"
keywords = ["http", "websocket", "rpc", "openapi", "mcp"] keywords = ["http", "websocket", "rpc", "openapi", "mcp"]
categories = ["network-programming", "web-programming", "asynchronous"] 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] [lib]
name = "alkhttp" name = "alkhttp"
+26 -3
View File
@@ -17,8 +17,8 @@
//! sends must match what `/schema` advertises, so peer-supplied input //! sends must match what `/schema` advertises, so peer-supplied input
//! cannot add upstream query parameters, headers, or craft a request body //! cannot add upstream query parameters, headers, or craft a request body
//! the contract does not declare. Declared keys route by designation: a //! the contract does not declare. Declared keys route by designation: a
//! property marked [`HEADER_PARAM_IN_MARKER`]` = "header"` is sent as a //! 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 header, the declared `GATEWAY_BODY_KEY` property becomes the
//! request body, and every other declared key becomes an upstream query //! request body, and every other declared key becomes an upstream query
//! parameter. //! 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_IN_MARKER: &str = "wire";
pub(crate) const HEADER_PARAM_MARKER_VALUE: &str = "header"; 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)] #[derive(Clone)]
pub enum HttpAuthScheme { pub enum HttpAuthScheme {
/// `Authorization: Bearer <token>` from the caller's `Bearer`
/// capability.
Bearer, 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, 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 { pub struct HttpServiceConfig {
/// Registry namespace for the imported operations (the
/// `<namespace>/<operationId>` op names).
pub namespace: String, pub namespace: String,
/// Outbound base URL every path template is resolved against.
pub base_url: String, pub base_url: String,
/// Credential scheme for outbound requests; `None` sends an
/// unauthenticated request.
pub auth: Option<HttpAuthScheme>, pub auth: Option<HttpAuthScheme>,
/// Static headers attached to every outbound request (e.g. a
/// required `User-Agent`).
pub default_headers: HashMap<String, String>, 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 super::forward::{forward, forward_stream, HttpServiceConfig};
use crate::client::SharedHttpClient; 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 { pub struct FromJsonSchema {
spec: OperationSpec, spec: OperationSpec,
config: HttpServiceConfig, config: HttpServiceConfig,
@@ -61,7 +65,7 @@ impl FromJsonSchema {
/// are rejected at call time. Mark a property with /// are rejected at call time. Mark a property with
/// `"wire": "header"` to route it as an upstream HTTPS header /// `"wire": "header"` to route it as an upstream HTTPS header
/// instead of a query parameter (review 001 OAI-03). Input key /// 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. /// the request body.
pub fn new( pub fn new(
spec: OperationSpec, 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. /// timed out. Payload is the array-of-content-blocks shape; retryable.
const MCP_TRANSPORT_ERROR: &str = "MCP_TRANSPORT_ERROR"; 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 { pub struct FromMCP {
endpoint: String, endpoint: String,
auth_token: Option<Secret<String>>, auth_token: Option<Secret<String>>,
@@ -57,6 +60,8 @@ pub struct FromMCP {
} }
impl 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 { pub fn new(endpoint: impl Into<String>, namespace: impl Into<String>) -> Self {
Self { Self {
endpoint: endpoint.into(), 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 { pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(Secret::new(token.into())); self.auth_token = Some(Secret::new(token.into()));
self self
} }
/// The configured MCP endpoint URL.
pub fn endpoint(&self) -> &str { pub fn endpoint(&self) -> &str {
&self.endpoint &self.endpoint
} }
/// The namespace imported operations register under.
pub fn namespace(&self) -> &str { pub fn namespace(&self) -> &str {
&self.namespace &self.namespace
} }
/// The configured token, for introspection (never logged).
pub fn auth_token(&self) -> Option<&Secret<String>> { pub fn auth_token(&self) -> Option<&Secret<String>> {
self.auth_token.as_ref() self.auth_token.as_ref()
} }
+5
View File
@@ -88,6 +88,9 @@ fn reject_collisions(
Ok(()) 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 { pub struct FromOpenAPI {
spec: OpenAPISpec, spec: OpenAPISpec,
config: HttpServiceConfig, config: HttpServiceConfig,
@@ -95,6 +98,8 @@ pub struct FromOpenAPI {
} }
impl FromOpenAPI { impl FromOpenAPI {
/// Assemble the adapter over a parsed spec, service config, and the
/// shared outbound client.
pub fn new( pub fn new(
spec: OpenAPISpec, spec: OpenAPISpec,
config: HttpServiceConfig, config: HttpServiceConfig,
+13
View File
@@ -78,6 +78,9 @@ fn connection_closed_error() -> CallError {
CallError::new("CONNECTION_CLOSED", "from_wss connection dropped", true) 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 { pub struct FromWss {
endpoint: String, endpoint: String,
auth_token: Option<Secret<String>>, auth_token: Option<Secret<String>>,
@@ -86,6 +89,8 @@ pub struct FromWss {
} }
impl FromWss { impl FromWss {
/// Assemble the adapter for a `wss://` (or explicit `ws://`)
/// channel endpoint.
pub fn new(endpoint: impl Into<String>) -> Self { pub fn new(endpoint: impl Into<String>) -> Self {
Self { Self {
endpoint: endpoint.into(), 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 { pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(Secret::new(token.into())); self.auth_token = Some(Secret::new(token.into()));
self 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 { pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into()); self.namespace = Some(namespace.into());
self self
@@ -114,14 +123,17 @@ impl FromWss {
self self
} }
/// The configured channel endpoint URL.
pub fn endpoint(&self) -> &str { pub fn endpoint(&self) -> &str {
&self.endpoint &self.endpoint
} }
/// The pinned namespace, when one was set.
pub fn namespace(&self) -> Option<&str> { pub fn namespace(&self) -> Option<&str> {
self.namespace.as_deref() self.namespace.as_deref()
} }
/// The configured token, for introspection (never logged).
pub fn auth_token(&self) -> Option<&Secret<String>> { pub fn auth_token(&self) -> Option<&Secret<String>> {
self.auth_token.as_ref() self.auth_token.as_ref()
} }
@@ -134,6 +146,7 @@ impl FromWss {
/// in-flight pending calls with retryable `CONNECTION_CLOSED`. /// in-flight pending calls with retryable `CONNECTION_CLOSED`.
pub struct WssSession { pub struct WssSession {
_client: ChannelClient, _client: ChannelClient,
/// The live channel-0 connection the consumer drives calls through.
pub call_connection: Arc<CallConnection>, pub call_connection: Arc<CallConnection>,
_monitor: WssDropMonitor, _monitor: WssDropMonitor,
} }
+59
View File
@@ -16,50 +16,89 @@ use serde_json::Value;
/// exhausting the stack. /// exhausting the stack.
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 64; 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] = pub(crate) const HTTP_METHODS: &[&str] =
&["get", "post", "put", "patch", "delete", "head", "options"]; &["get", "post", "put", "patch", "delete", "head", "options"];
/// The `info` block of an OpenAPI document.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct OpenAPIInfo { pub struct OpenAPIInfo {
/// Human-readable title (OpenAPI `info.title`).
pub title: String, pub title: String,
/// Declared spec/API version (OpenAPI `info.version`).
pub version: String, pub version: String,
} }
/// One entry of a path item: a concrete path plus the operations
/// declared on it.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PathItem { pub struct PathItem {
/// `(method, operation)` pairs, methods lowercase as declared in the
/// document (`get`, `post`, ...), in parsed order.
pub operations: Vec<(String, Operation)>, pub operations: Vec<(String, Operation)>,
} }
/// One OpenAPI operation parsed into the shared model.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Operation { pub struct Operation {
/// `operationId` when declared; `from_openapi` falls back to
/// `method_path_derived` naming when absent.
pub operation_id: Option<String>, pub operation_id: Option<String>,
/// Operation-level (non-`$ref`) parameters; `parameter $ref`s are
/// resolved before this model is built.
pub parameters: Vec<Parameter>, pub parameters: Vec<Parameter>,
/// `requestBody` when declared; `requestBody $ref`s are resolved
/// before this model is built.
pub request_body: Option<RequestBody>, 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>, pub responses: BTreeMap<String, Response>,
} }
/// One OpenAPI parameter (`name`/`in`/`required`/`schema`).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Parameter { pub struct Parameter {
/// Declared parameter name; must be unique per location per
/// operation in a valid document.
pub name: String, pub name: String,
/// Parameter location: `path`, `query`, `header`, or `cookie`
/// (the last unsupported and rejected loudly at import).
pub in_: String, pub in_: String,
/// Whether the operation fails without the parameter.
pub required: bool, 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>, pub schema: Option<Value>,
} }
/// An OpenAPI request body — the media-type → schema map of its
/// `content` field.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RequestBody { pub struct RequestBody {
/// Media type (e.g. `application/json`) → the body schema.
pub content: BTreeMap<String, Value>, pub content: BTreeMap<String, Value>,
} }
/// An OpenAPI response — the media-type → schema map of its `content`
/// field.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Response { pub struct Response {
/// Media type (e.g. `application/json`) → the response schema.
pub content: BTreeMap<String, Value>, pub content: BTreeMap<String, Value>,
} }
/// The `components` block: reusable schemas, parameters, and request
/// bodies keyed by name.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Components { pub struct Components {
/// `components/schemas` entries.
pub schemas: HashMap<String, Value>, pub schemas: HashMap<String, Value>,
/// `components/parameters` entries.
pub parameters: HashMap<String, Value>, pub parameters: HashMap<String, Value>,
/// `components/requestBodies` entries.
pub request_bodies: HashMap<String, Value>, pub request_bodies: HashMap<String, Value>,
} }
@@ -73,15 +112,29 @@ fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> {
map map
} }
/// A parsed OpenAPI 3.x document: the typed model (`info`, `paths`,
/// `components`) plus the untouched `raw` document for `$ref`
/// resolution.
#[derive(Debug)] #[derive(Debug)]
pub struct OpenAPISpec { pub struct OpenAPISpec {
/// The parsed `info` block.
pub info: OpenAPIInfo, pub info: OpenAPIInfo,
/// The path items keyed by their literal path string (with
/// placeholders).
pub paths: BTreeMap<String, PathItem>, pub paths: BTreeMap<String, PathItem>,
/// The `components` block, when the document declares one.
pub components: Option<Components>, pub components: Option<Components>,
/// The full document as parsed — the lookup target for `$ref`
/// resolution.
pub raw: Value, pub raw: Value,
} }
impl OpenAPISpec { 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> { pub fn from_json(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = serde_json::from_str(doc).map_err(|e| AdapterError::SchemaParse { let raw: Value = serde_json::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid JSON: {e}"), 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> { pub fn from_value(raw: Value) -> Result<Self, AdapterError> {
if !raw.is_object() { if !raw.is_object() {
return Err(AdapterError::SchemaParse { 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 { pub struct ToMcpGateway {
dispatch: Arc<GatewayDispatch>, dispatch: Arc<GatewayDispatch>,
} }
impl ToMcpGateway { impl ToMcpGateway {
/// Assemble the gateway over a dispatch spine.
pub fn new(dispatch: Arc<GatewayDispatch>) -> Self { pub fn new(dispatch: Arc<GatewayDispatch>) -> Self {
Self { dispatch } Self { dispatch }
} }
/// The dispatch spine the tools invoke through.
pub fn dispatch(&self) -> &Arc<GatewayDispatch> { pub fn dispatch(&self) -> &Arc<GatewayDispatch> {
&self.dispatch &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>; pub type ToMcpService = StreamableHttpService<ToMcpGateway, LocalSessionManager>;
/// Build the mountable MCP service for a dispatch spine.
pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService { pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService {
let gateway = ToMcpGateway::new(dispatch); let gateway = ToMcpGateway::new(dispatch);
StreamableHttpService::new( 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)] #[derive(Debug, Error)]
pub enum HttpClientBuildError { pub enum HttpClientBuildError {
/// The configured `ca_bundle` path could not be read.
#[error("failed to read CA bundle from {path}: {source}")] #[error("failed to read CA bundle from {path}: {source}")]
CaBundleRead { CaBundleRead {
/// The unreadable path, for the caller's diagnostics.
path: PathBuf, path: PathBuf,
/// The underlying I/O error.
#[source] #[source]
source: std::io::Error, source: std::io::Error,
}, },
/// The CA bundle file exists but is not valid PEM.
#[error("failed to parse CA bundle at {path}: {source}")] #[error("failed to parse CA bundle at {path}: {source}")]
CaBundleParse { CaBundleParse {
/// The unparseable path, for the caller's diagnostics.
path: PathBuf, path: PathBuf,
/// The underlying reqwest parse error.
#[source] #[source]
source: reqwest::Error, 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}")] #[error("failed to read client cert from {path}: {source}")]
ClientCertRead { ClientCertRead {
/// The unreadable path.
path: PathBuf, path: PathBuf,
/// The underlying I/O error.
#[source] #[source]
source: std::io::Error, 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}")] #[error("failed to parse client cert at {path}: {source}")]
ClientCertParse { ClientCertParse {
/// The path of the identity whose parse failed.
path: PathBuf, path: PathBuf,
/// The underlying reqwest parse error.
#[source] #[source]
source: reqwest::Error, source: reqwest::Error,
}, },
/// The reqwest client itself failed to build (e.g. TLS backend
/// initialization).
#[error("failed to build reqwest client: {0}")] #[error("failed to build reqwest client: {0}")]
Build(reqwest::Error), 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 { pub struct SharedHttpClient {
inner: ArcSwap<SharedHttpInner>, 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> { pub fn client(&self) -> Arc<ClientWithMiddleware> {
Arc::clone(&self.inner.load().client) 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> { pub fn config(&self) -> Arc<HttpClientConfig> {
Arc::clone(&self.inner.load().config) 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) 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 { pub struct RetryAfterMiddleware {
deadlines: Mutex<HashMap<Url, SystemTime>>, deadlines: Mutex<HashMap<Url, SystemTime>>,
capacity: usize, capacity: usize,
@@ -68,10 +72,22 @@ pub struct RetryAfterMiddleware {
} }
impl 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 { pub fn with_capacity(capacity: usize) -> Self {
Self::with_capacity_and_ceiling(capacity, Duration::from_secs(300)) 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 { pub fn with_capacity_and_ceiling(capacity: usize, ceiling: Duration) -> Self {
Self { Self {
deadlines: Mutex::new(HashMap::with_capacity(capacity.min(128))), 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); 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 { pub struct GatewayDispatch {
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>, identity_provider: Arc<dyn IdentityProvider>,
} }
impl GatewayDispatch { impl GatewayDispatch {
/// Assemble a dispatch spine over a registry and an identity
/// provider.
pub fn new( pub fn new(
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>, identity_provider: Arc<dyn IdentityProvider>,
@@ -60,18 +65,23 @@ impl GatewayDispatch {
} }
} }
/// The registry operations resolve against.
pub fn registry(&self) -> &Arc<OperationRegistry> { pub fn registry(&self) -> &Arc<OperationRegistry> {
&self.registry &self.registry
} }
/// The identity provider bearer tokens resolve against.
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> { pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
&self.identity_provider &self.identity_provider
} }
/// Resolve a bearer token to an identity (the auth-middleware hook).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> { pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
self.identity_provider.resolve_from_token(token) 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( pub async fn invoke(
&self, &self,
identity: Option<Identity>, 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( pub fn invoke_streaming(
&self, &self,
identity: Option<Identity>, identity: Option<Identity>,
+9
View File
@@ -40,10 +40,16 @@ const STATUS_INTERNAL: u16 = 500;
const RETRY_AFTER_STATUSES: &[u16] = &[429, 503]; 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 { pub fn call_error_to_http_status(error: &CallError) -> u16 {
call_error_to_http_status_with_identity(error, None) 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( pub fn call_error_to_http_status_with_identity(
error: &CallError, error: &CallError,
identity: Option<&Identity>, 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 { pub fn call_error_to_http_response(error: &CallError) -> Response {
call_error_to_http_response_with_identity(error, None) 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)) .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)] #[derive(Debug, Deserialize)]
pub struct CallRequest { pub struct CallRequest {
/// The operation name, qualified (`namespace/op`); a leading slash
/// is tolerated.
pub operation: String, pub operation: String,
/// The operation input; defaults to null when the line omits it.
#[serde(default = "Value::default")] #[serde(default = "Value::default")]
pub input: Value, pub input: Value,
} }
/// The `GET /schema?name=<operation>` query parameters.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SchemaQuery { pub struct SchemaQuery {
/// The operation name to project, qualified (`namespace/op`).
pub name: String, pub name: String,
} }
@@ -212,6 +219,10 @@ pub(crate) async fn subscribe_handler(
.into_response() .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>>; pub type SubscribeStream = BoxStream<'static, Result<Event, Infallible>>;
/// `POST /publish` (ADR-068): the body is NDJSON — one published chunk /// `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 //! alkhttp: HTTP interface for the alk stack — serves HTTP/1.1 + HTTP/2 on
//! standard ALPNs (with WebSocket upgrade carrying the channels protocol) //! standard ALPNs (with WebSocket upgrade carrying the channels protocol)
//! and hosts the HTTP-backed call-protocol adapters. //! 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; 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; pub mod client;
/// The 6 fixed gateway endpoints and their shared dispatch spine —
/// the sole HTTP invoke path.
pub mod gateway; pub mod gateway;
/// The HTTP server host: the `HttpAdapter` router, auth, stealth decoy,
/// `/healthz`, `/openapi.json`.
pub mod server; pub mod server;
/// The WebSocket upgrade path carrying the native call-protocol
/// session (browsers; ADR-044, ADR-048).
pub mod websocket; pub mod websocket;
pub use server::{decoy_fallback, decoy_method_not_allowed, healthz, DecoyConfig}; pub use server::{decoy_fallback, decoy_method_not_allowed, healthz, DecoyConfig};
+19 -2
View File
@@ -23,9 +23,9 @@
//! //!
//! ## Connection knobs and boundaries //! ## 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 //! 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 //! exactly one accepted bidirectional stream per call, and the accept
//! loop — how many streams are handled concurrently, on which tasks — //! loop — how many streams are handled concurrently, on which tasks —
//! belongs to the consumer that owns the `Connection` (or the //! 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::healthz::healthz;
use super::state::{DecoyConfig, RouterState}; 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"; 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"; pub const ALPN_H2: &[u8] = b"h2";
/// The WS upgrade path (ADR-067). Reserved in the default surface; the /// The WS upgrade path (ADR-067). Reserved in the default surface; the
@@ -79,6 +81,11 @@ pub const RESERVED_PATHS: &[&str] = &[
WS_UPGRADE_PATH, 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 { pub struct HttpAdapter {
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>, identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
@@ -94,6 +101,7 @@ pub struct HttpAdapter {
} }
impl HttpAdapter { impl HttpAdapter {
/// An HTTP/1.1 adapter (registers on `http/1.1` ALPN).
pub fn new( pub fn new(
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>, identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>, registry: Arc<OperationRegistry>,
@@ -101,6 +109,7 @@ impl HttpAdapter {
Self::for_alpn(identity_provider, registry, ALPN_HTTP1) Self::for_alpn(identity_provider, registry, ALPN_HTTP1)
} }
/// An HTTP/2 adapter (registers on `h2` ALPN).
pub fn h2( pub fn h2(
identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>, identity_provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
registry: Arc<OperationRegistry>, 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 { pub fn with_decoy(mut self, decoy: DecoyConfig) -> Self {
self.decoy = decoy.clone(); self.decoy = decoy.clone();
let state = RouterState { let state = RouterState {
@@ -165,6 +176,9 @@ impl HttpAdapter {
self 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 { pub fn with_extra_routes(mut self, routes: Router) -> Self {
let state = RouterState { let state = RouterState {
registry: Arc::clone(&self.registry), registry: Arc::clone(&self.registry),
@@ -241,14 +255,17 @@ impl HttpAdapter {
Arc::new(tokio::sync::Semaphore::new(max_sessions)) Arc::new(tokio::sync::Semaphore::new(max_sessions))
} }
/// The configured decoy surface (assembly introspection).
pub fn decoy(&self) -> &DecoyConfig { pub fn decoy(&self) -> &DecoyConfig {
&self.decoy &self.decoy
} }
/// The ALPN this adapter registers on (`http/1.1` or `h2`).
pub fn alpn(&self) -> &'static [u8] { pub fn alpn(&self) -> &'static [u8] {
self.alpn self.alpn
} }
/// The assembled router (for the accept loop the consumer owns).
pub fn router(&self) -> &Router { pub fn router(&self) -> &Router {
&self.router &self.router
} }
+11
View File
@@ -24,6 +24,9 @@ use axum::response::Response;
use super::DecoyConfig; 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 { pub async fn decoy_fallback(State(decoy): State<DecoyConfig>, request: Request) -> Response {
match decoy { match decoy {
DecoyConfig::NotFound => fake_nginx_404(), 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 { pub fn fake_nginx_404() -> Response {
let body = nginx_error_body("404 Not Found"); let body = nginx_error_body("404 Not Found");
let mut resp = Response::new(Body::from(body)); 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 /// `Router::method_not_allowed_fallback` — applies to every
/// previously registered `MethodRouter` (default surface + extra /// previously registered `MethodRouter` (default surface + extra
/// routes). /// 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 { pub async fn decoy_method_not_allowed() -> Response {
nginx_405_response() nginx_405_response()
} }
/// A 302 redirect to `to` (the `DecoyConfig::Redirect` surface).
pub fn redirect(to: &str) -> Response { pub fn redirect(to: &str) -> Response {
let mut resp = Response::new(Body::empty()); let mut resp = Response::new(Body::empty());
*resp.status_mut() = StatusCode::FOUND; *resp.status_mut() = StatusCode::FOUND;
@@ -75,6 +83,9 @@ pub fn redirect(to: &str) -> Response {
resp 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 { pub async fn serve_static(root: &Path, request: Request) -> Response {
let path = request.uri().path(); let path = request.uri().path();
let resolved = match resolve_static_path(root, path).await { 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"; 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 { pub async fn healthz() -> impl IntoResponse {
( (
StatusCode::OK, 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). /// Serve a fake `404 Not Found` (the default — a fake nginx 404).
#[default] #[default]
NotFound, NotFound,
/// Serve a static site from a configured directory. /// Serve a static site from the given directory root.
StaticSite { root: PathBuf }, StaticSite {
/// Redirect to a configured URL. /// The directory tree served for unregistered paths.
Redirect { to: String }, 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 /// 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). //! bytes drain (the mux's EOF sentinels ride the same queue).
//! //!
//! Shared with the `from_wss` consumer path (ADR-070): one //! Shared with the `from_wss` consumer path (ADR-070): one
//! implementation, both directions ([`WsFraming`] + the generic //! implementation, both directions (`WsFraming` + the generic
//! [`run_read_pump`] / [`run_write_pump`] instantiated once per socket //! `run_read_pump` / `run_write_pump` instantiated once per socket
//! flavor — WS-11/COV-03). //! flavor — WS-11/COV-03).
use std::{ use std::{
@@ -402,6 +402,8 @@ pub struct WsPumps {
} }
impl WsPumps { impl WsPumps {
/// Abort both pump tasks (forced session teardown; the remote sees
/// an abrupt close, not a graceful one).
pub fn abort(&self) { pub fn abort(&self) {
self.read_task.abort(); self.read_task.abort();
self.write_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 /// Split a `WebSocket` into the byte stream + the pump tasks with the
/// default idle-read timeout /// default idle-read timeout (`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`).
/// ([`DEFAULT_WS_IDLE_TIMEOUT`](crate::websocket::DEFAULT_WS_IDLE_TIMEOUT)).
/// See [`split_ws_to_bytes_idle`] for the configurable form. /// See [`split_ws_to_bytes_idle`] for the configurable form.
pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) { pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
split_ws_to_bytes_idle(socket, Some(DEFAULT_WS_IDLE_TIMEOUT)) 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 //! 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 //! A WS session carries the **channels protocol**: the 8-byte chunk
//! header multiplexes N logical channels over the WS binary message //! header multiplexes N logical channels over the WS binary message
//! stream; channel 0 is pre-negotiated as `alk/call` and dispatched by //! stream; channel 0 is pre-negotiated as `alk/call` and dispatched by
//! the shared `Dispatcher`. The WS↔byte-stream adapter //! the shared `Dispatcher`. The WS↔byte-stream adapter (`byte_adapter`)
//! ([`byte_adapter`]) is the single seam between axum's WS and //! is the single seam between axum's WS and
//! alkcall's byte-oriented channels machinery — shared with the //! alkcall's byte-oriented channels machinery — shared with the
//! `from_wss` consumer path ([ADR-070]). //! `from_wss` consumer path ([ADR-070]).
+36
View File
@@ -157,6 +157,7 @@ impl axum::extract::FromRef<Arc<OperationRegistry>> for SessionState {
} }
impl WsSessions { impl WsSessions {
/// A fresh, empty session registry.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@@ -173,6 +174,7 @@ impl WsSessions {
self.sessions.lock().len() self.sessions.lock().len()
} }
/// Whether no sessions are tracked.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.sessions.lock().is_empty() self.sessions.lock().is_empty()
} }
@@ -400,6 +402,17 @@ pub mod test_support {
/// Frame one `EventEnvelope` as a channel-0 chunk (8-byte chunk /// Frame one `EventEnvelope` as a channel-0 chunk (8-byte chunk
/// header + 4-byte length prefix + JSON body) — the client-side /// header + 4-byte length prefix + JSON body) — the client-side
/// framing channel 0 uses over any transport. /// 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> { pub fn frame_channel0_chunk(envelope: &EventEnvelope) -> Vec<u8> {
let body = serde_json::to_vec(envelope).unwrap(); let body = serde_json::to_vec(envelope).unwrap();
let mut out = Vec::with_capacity(8 + 4 + body.len()); let mut out = Vec::with_capacity(8 + 4 + body.len());
@@ -418,14 +431,19 @@ pub mod test_support {
} }
impl ChunkAssembler { impl ChunkAssembler {
/// A fresh, empty assembler.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Append raw bytes to the assembly buffer.
pub fn push(&mut self, bytes: &[u8]) { pub fn push(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes); 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>)> { pub fn next_chunk(&mut self) -> Option<(u32, Vec<u8>)> {
if self.buf.len() < 8 { if self.buf.len() < 8 {
return None; return None;
@@ -454,14 +472,19 @@ pub mod test_support {
} }
impl FrameAssembler { impl FrameAssembler {
/// A fresh, empty assembler.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
/// Append raw bytes to the assembly buffer.
pub fn push(&mut self, bytes: &[u8]) { pub fn push(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes); 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> { pub fn next_frame(&mut self) -> Option<EventEnvelope> {
if self.buf.len() < 4 { if self.buf.len() < 4 {
return None; return None;
@@ -493,6 +516,8 @@ pub mod test_support {
} }
impl WsClient { 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> { pub async fn connect_authorized(url: &str, token: &str) -> Result<Self, String> {
let mut request = url let mut request = url
.into_client_request() .into_client_request()
@@ -554,10 +579,18 @@ pub mod test_support {
Self { sink, stream } 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>) { pub async fn send_binary(&mut self, bytes: Vec<u8>) {
self.send_binary_piece(&bytes).await; 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]) { pub async fn send_binary_piece(&mut self, bytes: &[u8]) {
use futures::SinkExt; use futures::SinkExt;
self.sink self.sink
@@ -568,6 +601,8 @@ pub mod test_support {
.unwrap(); .unwrap();
} }
/// Send one text WS message. Same panic contract as
/// [`Self::send_binary`].
pub async fn send_text(&mut self, text: &str) { pub async fn send_text(&mut self, text: &str) {
use futures::SinkExt; use futures::SinkExt;
self.sink self.sink
@@ -616,6 +651,7 @@ pub mod test_support {
} }
} }
/// Close the WS with a normal-close frame.
pub async fn close(&mut self) { pub async fn close(&mut self) {
use futures::SinkExt; use futures::SinkExt;
let _ = self.sink.close().await; let _ = self.sink.close().await;
+85 -13
View File
@@ -1,7 +1,7 @@
--- ---
id: review-001-missing-docs-sweep id: review-001-missing-docs-sweep
name: missing_docs sweep + publish-prep decisions (HY-02, HY-04, HY-11) 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] depends_on: [review-001-client-config-and-cert-coverage]
scope: moderate scope: moderate
risk: low risk: low
@@ -40,11 +40,11 @@ warnings and that task reshapes the config surface it would document.
## Acceptance Criteria ## Acceptance Criteria
- [ ] `cargo doc` with `-W missing_docs` (as deny) exits clean — 0 warnings - [x] `cargo doc` with `-W missing_docs` (as deny) exits clean — 0 warnings
- [ ] Enforcement landed (CI rustdocflags or lint config) so it stays clean - [x] 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 - [x] HY-11 decision recorded; `cargo publish --dry-run --allow-dirty` package contents match the decision
- [ ] HY-04 resolved (documented or restructured) - [x] HY-04 resolved (documented or restructured)
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass
## References ## References
@@ -52,13 +52,85 @@ warnings and that task reshapes the config surface it would document.
## Notes ## Notes
> Agent fills during implementation. The `cargo tree -d` and HY-10 Re-measure at start (after the three preceding follow-up tasks):
> link items: HY-10's ADR-051 placeholder link in **101 warnings** across 16 files — the client task had already
> `src/adapters/from_openapi.rs:12` still resolves to a documented its new surface, and OAI-06 grew `openapi_spec.rs`.
> `https://docs.rs/alkhttp` placeholder — fix remaining placeholder/ Sweep order followed the re-measure, not the review's ranking.
> relative links opportunistically in this sweep and mark HY-10 fully
> resolved. **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 ## 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.