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:
+1
-1
@@ -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
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]).
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user