feat(build): feature-sided builds — server/client sides independently selectable (ADR-039 Amendment 1)

Split the feature graph so consumers pulling only the import adapters
(from_openapi / from_jsonschema / from_mcp) no longer compile the axum
/ hyper server stack, and server-only deployments no longer compile
reqwest. One crate, one import path — sides cut by features, not by a
crate split.

Feature graph:
- server (default): axum host, gateway, WS upgrade, to_openapi, to_mcp
- client (default): client host, forward, from_jsonschema, from_openapi
- openapi: shared OpenAPISpec model (implied by both sides)
- mcp: from_mcp needs client, to_mcp needs server
- wss: tungstenite transport (from_wss); tungstenite half of the
  shared WS↔byte-stream adapter
- h2/http1: hyper protocol features; imply server

Wire-contract neutral: gateway endpoints, ALPNs, and all public API
shapes unchanged; defaults keep both sides on.

Supporting changes:
- forward.rs drops its axum::body::Bytes type leak (bytes crate types)
- bounded_join + error-echo caps move to input_validation (usable by
  both sides; openapi_spec no longer imports from forward)
- byte_adapter: axum flavor compiles under server, tungstenite under
  wss; the generic pumps stay shared (WS-11)
- input_validation / openapi_spec import-only internals gated to the
  side that consumes them
- http-body-util moves to dev-dependencies (was test-only)
- integration-test required-features updated for the new sides
- from_wss unit tests (axum producer harness) gated to server

Verified: cargo test (defaults, 453) and --all-features (575) pass;
lean side builds (client / server / client,mcp / client,wss /
server,wss / openapi-only) build clean with zero warnings;
clippy -D warnings clean across all feature combinations; fmt clean.
This commit is contained in:
2026-08-31 17:19:05 +00:00
parent 8e8e1f2b14
commit a80f9948b8
16 changed files with 259 additions and 99 deletions

View File

@@ -133,12 +133,15 @@ implementation agents.
coupling, no endpoint/accept-loop); the dial and the TLS config are
concerns of the consumer, not of this crate.
10. **Feature flags** — the HTTP transports are feature-gated: `h2` and
`http1` are default features (hyper), `mcp` gates the
`from_mcp`/`to_mcp` adapters (rmcp). The base crate should compile
lean (no `rmcp` unless the `mcp` feature is on). Verify both
`cargo test` (default) and `cargo test --all-features` pass if
features are added.
10. **Feature flags** — the crate is feature-sided (ADR-039
Amendment 1): `server` (axum host, gateway, WS upgrade, `to_*`)
and `client` (outbound client host, `from_*` adapters) are both
default features; `openapi` is the shared spec model (implied by
both); `mcp` gates the MCP adapters, `wss` the tungstenite
transport, `h2`/`http1` the hyper protocol features (imply
`server`). A lean build takes `default-features = false` plus one
side. Verify both `cargo test` (default) and
`cargo test --all-features` pass if features are added.
11. **Naming** — Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for

1
Cargo.lock generated
View File

@@ -54,6 +54,7 @@ dependencies = [
"arc-swap",
"async-trait",
"axum",
"bytes",
"futures",
"http",
"http-body-util",

View File

@@ -14,39 +14,58 @@ exclude = [".opencode/", "AGENTS.md", "docs/", "tasks/", "Cargo.lock"]
name = "alkhttp"
[features]
default = ["h2", "http1"]
default = ["server", "client", "h2", "http1"]
# Producer side: the axum `Router` host (gateway routes, /healthz,
# /openapi.json, /mcp, the WS upgrade path) and the hyper connection
# driver. `openapi` rides along because /openapi.json is part of the
# default surface (ADR-042/045); `http1` rides along so the hyper
# auto-builder always has at least one protocol compiled.
server = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"]
# Consumer side: the shared outbound client host and the HTTP-backed
# import adapters (`from_jsonschema`, `from_openapi` with `openapi`,
# `from_mcp` with `mcp`).
client = ["dep:arc-swap", "dep:httpdate", "dep:percent-encoding", "dep:reqwest", "dep:reqwest-middleware", "dep:reqwest-retry", "dep:url"]
# Shared OpenAPI document model: `from_openapi` parsing (client side)
# and the `to_openapi` gateway projection (server side).
openapi = ["dep:yaml_serde"]
# MCP adapters: `from_mcp` needs `client`, `to_mcp` needs `server`.
mcp = ["dep:rmcp"]
# WebSocket transport (from_wss consumer; tungstenite flavor of the
# WS ↔ byte-stream adapter).
wss = ["dep:tokio-tungstenite"]
test-support = ["dep:tokio-tungstenite"]
h2 = ["dep:hyper", "hyper-util/http2", "hyper/http2"]
http1 = ["dep:hyper", "hyper-util/http1", "hyper/http1"]
# Test-only WS client helpers (downstream deployment tests); implies
# `server` — the helpers live with the upgrade path.
test-support = ["server", "dep:tokio-tungstenite"]
# HTTP protocol features of the server connection driver (ADR-001).
h2 = ["server", "dep:hyper", "hyper-util/http2", "hyper/http2"]
http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"]
[dependencies]
alkcall = { version = "0.2", features = ["gateway"] }
arc-swap = "1"
axum = { version = "0.8", features = ["ws"] }
arc-swap = { version = "1", optional = true }
axum = { version = "0.8", optional = true, features = ["ws"] }
bytes = "1"
futures = "0.3"
http = "1"
httpdate = { version = "1", optional = true }
hyper = { version = "1", optional = true, features = ["server"] }
hyper-util = { version = "0.1", features = ["server", "service", "tokio"] }
httpdate = "1"
hyper-util = { version = "0.1", optional = true, features = ["server", "service", "tokio"] }
jsonschema = { version = "0.46", default-features = false }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
reqwest-middleware = "0.5"
reqwest-retry = "0.9"
parking_lot = "0.12"
percent-encoding = { version = "2", optional = true }
reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "stream", "rustls"] }
reqwest-middleware = { version = "0.5", optional = true }
reqwest-retry = { version = "0.9", optional = true }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "fs", "time", "sync"] }
tokio-tungstenite = { version = "0.29", optional = true, default-features = false, features = ["connect", "rustls-tls-webpki-roots", "handshake"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
yaml_serde = "0.10"
async-trait = "0.1"
tracing = "0.1"
thiserror = "2"
uuid = { version = "1", features = ["v4"] }
futures = "0.3"
http = "1"
http-body-util = "0.1"
url = "2"
percent-encoding = "2"
parking_lot = "0.12"
uuid = { version = "1", optional = true, features = ["v4"] }
url = { version = "2", optional = true }
yaml_serde = { version = "0.10", optional = true }
rmcp = { version = "1.8", optional = true, default-features = false, features = [
"client",
"server",
@@ -75,8 +94,24 @@ required-features = ["test-support"]
[[test]]
name = "from_mcp_integration"
required-features = ["mcp"]
required-features = ["server", "client", "mcp"]
[[test]]
name = "full_surface"
required-features = ["mcp", "test-support"]
required-features = ["server", "client", "openapi", "mcp", "test-support"]
[[test]]
name = "client_config_reload"
required-features = ["client"]
[[test]]
name = "client_tls"
required-features = ["client"]
[[test]]
name = "retry_after_budget"
required-features = ["client"]
[[test]]
name = "retry_policy_wire"
required-features = ["client"]

View File

@@ -138,6 +138,30 @@ concerns that make splitting them counterproductive:
argument is weaker — but the current design (ADR-036, ADR-023)
has them sharing the mapping.
## Amendment 1: feature-sided builds (`server` / `client`)
The colocation decision stands — one crate, one import path — but the
"the compile cost is paid once per workspace" mitigation proved
insufficient: a consumer that only imports (consumer side) was forced
to compile the axum/hyper server stack, and vice versa. The feature
graph now sides the crate without splitting it:
- `server` (default) — the axum `Router` host, the gateway routes,
the WS upgrade path, `to_openapi`, `to_mcp` (with `mcp`).
- `client` (default) — the outbound client host, the `from_*` import
adapters (`from_wss` additionally needs `wss`).
- `openapi` — the shared `OpenAPISpec` document model (implied by
both sides; `from_openapi` needs it, `to_openapi` needs it).
- `mcp` — `from_mcp` requires `client`, `to_mcp` requires `server`.
- `wss` — the tungstenite WS transport (`from_wss`; the tungstenite
flavor of the shared WS↔byte-stream adapter).
- `h2` / `http1` — hyper protocol features; imply `server`.
Default features keep both sides on (zero behavior change); lean
builds use `default-features = false` + the side they need. The
wire contract (gateway endpoints, ALPNs) is untouched — this is a
dependency-graph change, not a surface change.
## References
- [ADR-003](003-crate-decomposition.md) — crate decomposition (this

View File

@@ -40,7 +40,8 @@ same-protocol importer, with WSS as the transport instead of QUIC.**
- **Feature gate:** `wss = ["dep:tokio-tungstenite"]` (not default —
a process that never consumes over WSS should not compile a WS
client).
client). `from_wss` itself additionally requires the `client`
feature side (ADR-039 Amendment 1).
- **Shape:** implements `OperationAdapter`
([ADR-017](017-call-protocol-client-and-adapter-contract.md); record:
alkcall ADR-022):

View File

@@ -140,6 +140,7 @@ use reqwest::Method;
use serde_json::Value;
use url::Url;
use crate::adapters::input_validation::bounded_join;
use crate::adapters::input_validation::CompiledInputSchema;
use crate::client::SharedHttpClient;
@@ -184,43 +185,6 @@ 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";
/// Upper bound on how many list items an adapter error message echoes
/// (review 002 OAI-17), and the per-item string cap. A spec-derived list
/// (servers locations, placeholder names, declared keys) can be
/// arbitrarily large; an error echoing all of it turns a 100k-path
/// document into a multi-megabyte message. The shape is "first N + count
/// of the rest".
pub(crate) const ERROR_LIST_ITEMS: usize = 8;
pub(crate) const ERROR_ITEM_STRING_CAP: usize = 128;
/// Joins list items into an error-message fragment bounded in both item
/// count and item width: at most [`ERROR_LIST_ITEMS`] entries, each
/// truncated to [`ERROR_ITEM_STRING_CAP`] chars with a `…` marker, plus
/// a `, … (+N more)` suffix naming how many were suppressed.
pub(crate) fn bounded_join(items: &[String]) -> String {
let shown: Vec<String> = items
.iter()
.take(ERROR_LIST_ITEMS)
.map(|item| {
if item.chars().count() > ERROR_ITEM_STRING_CAP {
let truncated: String = item.chars().take(ERROR_ITEM_STRING_CAP).collect();
format!("{truncated}")
} else {
item.clone()
}
})
.collect();
if items.len() > ERROR_LIST_ITEMS {
format!(
"{}, … (+{} more)",
shown.join(", "),
items.len() - ERROR_LIST_ITEMS
)
} else {
shown.join(", ")
}
}
/// Import-time path-template validation shared by `from_jsonschema`
/// (review 001 OAI-09) and `from_openapi` (review 002 JS-02): every
/// `{placeholder}` must terminate with a `}` and carry a name. A
@@ -848,7 +812,7 @@ pub(crate) fn is_json_content_type(content_type: &str) -> bool {
/// (FWD-10): capped at [`ERROR_BODY_ECHO_CAP`] bytes, lossily decoded,
/// control characters (which could forge log or display framing) elided,
/// and truncated with a marker. The echo is never logged by this crate.
fn bounded_error_body(bytes: axum::body::Bytes) -> Option<String> {
fn bounded_error_body(bytes: bytes::Bytes) -> Option<String> {
if bytes.is_empty() {
return None;
}
@@ -874,7 +838,7 @@ fn bounded_error_body(bytes: axum::body::Bytes) -> Option<String> {
async fn read_body_capped(
response: reqwest::Response,
cap: usize,
) -> Result<axum::body::Bytes, BodyReadError> {
) -> Result<bytes::Bytes, BodyReadError> {
let mut stream = response.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {

View File

@@ -27,12 +27,13 @@ use async_trait::async_trait;
use serde_json::Value;
use super::forward::{
bounded_join, forward, forward_stream, validate_path_template, HttpAuthScheme,
HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER, HEADER_PARAM_MARKER_VALUE,
forward, forward_stream, validate_path_template, HttpAuthScheme, HttpServiceConfig,
GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER, HEADER_PARAM_MARKER_VALUE,
};
use super::openapi_spec::{
collect_ignored_schema_keys, OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS,
};
use crate::adapters::input_validation::bounded_join;
use crate::adapters::input_validation::CompiledInputSchema;
use crate::client::SharedHttpClient;

View File

@@ -169,7 +169,7 @@ impl WssSession {
/// The handle lives on the session; dropping the session detaches
/// the handle (tokio semantics) — the close signal already ends
/// the task on explicit drop, and the bounded sweep ends it on EOF.
#[cfg(test)]
#[cfg(all(test, feature = "server"))]
fn monitor_handle(&mut self) -> &mut tokio::task::JoinHandle<()> {
&mut self._monitor.monitor_task
}
@@ -186,7 +186,7 @@ impl WssSession {
#[cfg_attr(not(test), allow(dead_code))]
struct WssDropMonitor {
close_tx: Option<tokio::sync::oneshot::Sender<()>>,
#[cfg(test)]
#[cfg(all(test, feature = "server"))]
monitor_task: tokio::task::JoinHandle<()>,
}
@@ -276,7 +276,7 @@ impl WssSession {
let (close_tx, mut close_rx) = tokio::sync::oneshot::channel();
let pending = Arc::clone(call_connection.pending());
#[cfg_attr(not(test), allow(unused_variables))]
#[cfg_attr(not(all(test, feature = "server")), allow(unused_variables))]
let monitor_task = tokio::spawn(async move {
let mut eof_rx = pumps.read_eof();
let mut sweep = tokio::time::interval(PENDING_SWEEP_INTERVAL);
@@ -317,7 +317,7 @@ impl WssSession {
call_connection,
_monitor: WssDropMonitor {
close_tx: Some(close_tx),
#[cfg(test)]
#[cfg(all(test, feature = "server"))]
monitor_task,
},
})
@@ -349,7 +349,7 @@ impl OperationAdapter for FromWss {
}
}
#[cfg(test)]
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use alkcall::core::auth::{Identity, IdentityProvider};

View File

@@ -36,10 +36,51 @@
//! because the validator is captured in the same closure as the
//! schema it was compiled from.
#[cfg(feature = "client")]
use alkcall::protocol::wire::CallError;
#[cfg(feature = "client")]
use jsonschema::Validator;
#[cfg(feature = "client")]
use serde_json::Value;
/// Upper bound on how many list items an adapter error message echoes
/// (review 002 OAI-17), and the per-item string cap. A spec-derived list
/// (servers locations, placeholder names, declared keys) can be
/// arbitrarily large; an error echoing all of it turns a 100k-path
/// document into a multi-megabyte message. The shape is "first N + count
/// of the rest".
pub(crate) const ERROR_LIST_ITEMS: usize = 8;
pub(crate) const ERROR_ITEM_STRING_CAP: usize = 128;
/// Joins list items into an error-message fragment bounded in both item
/// count and item width: at most [`ERROR_LIST_ITEMS`] entries, each
/// truncated to [`ERROR_ITEM_STRING_CAP`] chars with a `…` marker, plus
/// a `, … (+N more)` suffix naming how many were suppressed.
pub(crate) fn bounded_join(items: &[String]) -> String {
let shown: Vec<String> = items
.iter()
.take(ERROR_LIST_ITEMS)
.map(|item| {
if item.chars().count() > ERROR_ITEM_STRING_CAP {
let truncated: String = item.chars().take(ERROR_ITEM_STRING_CAP).collect();
format!("{truncated}")
} else {
item.clone()
}
})
.collect();
if items.len() > ERROR_LIST_ITEMS {
format!(
"{}, … (+{} more)",
shown.join(", "),
items.len() - ERROR_LIST_ITEMS
)
} else {
shown.join(", ")
}
}
#[cfg(feature = "client")]
/// A call-time input validator compiled from an operation's
/// `input_schema`. `None`-free by construction: use
/// [`CompiledInputSchema::for_schema`] so operations without
@@ -50,6 +91,7 @@ pub(crate) struct CompiledInputSchema {
validator: std::sync::Arc<Validator>,
}
#[cfg(feature = "client")]
impl CompiledInputSchema {
/// Compile `input_schema` for call-time enforcement. Fails with a
/// schema-diagnostics string when the schema is not compilable —
@@ -90,6 +132,7 @@ impl CompiledInputSchema {
/// the compiled validator keeps the OAI-02 closed-by-default semantics
/// the allowlist enforces today. `true` and explicit schema values pass
/// through unchanged (the documented catch-all opt-in).
#[cfg(feature = "client")]
fn harden_closed_by_default(input_schema: &Value) -> Value {
if input_schema.get("additionalProperties").is_some() {
return input_schema.clone();
@@ -101,7 +144,7 @@ fn harden_closed_by_default(input_schema: &Value) -> Value {
hardened
}
#[cfg(test)]
#[cfg(all(test, feature = "client"))]
mod tests {
use super::*;
use serde_json::json;
@@ -217,6 +260,7 @@ mod tests {
}
}
#[cfg(all(test, feature = "client"))]
#[test]
fn marked_and_body_properties_are_ordinary_properties() {
use crate::adapters::forward::{GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER};

View File

@@ -2,30 +2,47 @@
//! (import external HTTP APIs as operations), `to_openapi` / `to_mcp`
//! (project local operations onto HTTP surfaces), `from_mcp`, and
//! `from_wss` (feature `wss`).
//!
//! Feature sides: the `from_*` import adapters compile under `client`;
//! `to_openapi` / `to_mcp` under `server`; the shared
//! [`OpenAPISpec`] document model under `openapi` (implied by both
//! sides).
pub mod forward;
pub mod from_jsonschema;
pub mod from_openapi;
#[cfg(any(feature = "client", all(feature = "server", feature = "openapi")))]
pub mod input_validation;
#[cfg(any(feature = "client", all(feature = "server", feature = "openapi")))]
pub mod openapi_spec;
#[cfg(feature = "client")]
pub mod forward;
#[cfg(feature = "client")]
pub mod from_jsonschema;
#[cfg(all(feature = "client", feature = "mcp"))]
pub mod from_mcp;
#[cfg(all(feature = "client", feature = "openapi"))]
pub mod from_openapi;
#[cfg(all(feature = "client", feature = "wss"))]
pub mod from_wss;
#[cfg(all(feature = "server", feature = "mcp"))]
pub mod to_mcp;
#[cfg(all(feature = "server", feature = "openapi"))]
pub mod to_openapi;
#[cfg(feature = "mcp")]
pub mod from_mcp;
#[cfg(feature = "wss")]
pub mod from_wss;
#[cfg(feature = "mcp")]
pub mod to_mcp;
#[cfg(feature = "client")]
pub use forward::{HttpAuthScheme, HttpServiceConfig};
#[cfg(feature = "client")]
pub use from_jsonschema::FromJsonSchema;
#[cfg(all(feature = "client", feature = "openapi"))]
pub use from_openapi::FromOpenAPI;
#[cfg(any(feature = "client", all(feature = "server", feature = "openapi")))]
pub use openapi_spec::OpenAPISpec;
#[cfg(all(feature = "server", feature = "openapi"))]
pub use to_openapi::to_openapi;
#[cfg(feature = "mcp")]
#[cfg(all(feature = "client", feature = "mcp"))]
pub use from_mcp::FromMCP;
#[cfg(feature = "wss")]
#[cfg(all(feature = "client", feature = "wss"))]
pub use from_wss::FromWss;
#[cfg(feature = "mcp")]
#[cfg(all(feature = "server", feature = "mcp"))]
pub use to_mcp::{to_mcp_service, ToMcpGateway, ToMcpService};

View File

@@ -44,11 +44,14 @@
//! otherwise hide is visible at import instead of surfacing as a
//! silent `/schema` overstatement.
use std::collections::{BTreeMap, HashMap, HashSet};
#[cfg(all(feature = "client", feature = "openapi"))]
use std::collections::HashSet;
use std::collections::{BTreeMap, HashMap};
use crate::adapters::forward::bounded_join;
use crate::adapters::input_validation::bounded_join;
use alkcall::client::AdapterError;
use serde_json::Value;
#[cfg(feature = "openapi")]
use yaml_serde::Value as YamlValue;
/// Maximum structural nesting depth for recursive `$ref` resolution
@@ -58,6 +61,7 @@ use yaml_serde::Value as YamlValue;
/// `$ref` hop chains are bounded separately by [`MAX_REF_HOP_DEPTH`] —
/// one hop per schema level is legitimate above this height (a 40-level
/// chain nests ~2 objects per level).
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 128;
/// Maximum `$ref` hop-chain length in one resolution (review 002 OAI-11).
@@ -66,6 +70,7 @@ pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 128;
/// dereferenced along the way. A linear schema chain of any realistic
/// size stays well under this; runaway chains fail with a clean
/// [`AdapterError::SchemaParse`].
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_HOP_DEPTH: usize = 64;
/// Maximum number of `Value` nodes materialized by one `resolve_refs_recursive`
@@ -76,6 +81,7 @@ pub(crate) const MAX_REF_HOP_DEPTH: usize = 64;
/// Neither the visited set nor the depth budget fires for that shape; this
/// budget does, failing import with a clean [`AdapterError::SchemaParse`]
/// instead of wedging with no error (or exhausting memory).
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000;
/// The `paths`-level HTTP methods the adapter models. `trace` is
@@ -90,6 +96,7 @@ pub(crate) const HTTP_METHODS: &[&str] =
/// first key present governs SSE detection and the output schema, so a
/// concrete status outranks the wildcard and the wildcard outranks
/// `default`.
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const SUCCESS_RESPONSE_KEYS: &[&str] = &[
"200", "201", "202", "203", "204", "205", "206", "226", "2XX", "default",
];
@@ -199,6 +206,7 @@ pub struct Components {
/// Recursion follows document structure (already bounded by
/// yaml_serde's parse-time recursion limit) and terminates because
/// YAML mappings are acyclic after alias expansion.
#[cfg(feature = "openapi")]
fn yaml_to_json_value(value: &YamlValue) -> Result<Value, String> {
match value {
YamlValue::Null => Ok(Value::Null),
@@ -233,6 +241,7 @@ fn yaml_to_json_value(value: &YamlValue) -> Result<Value, String> {
/// non-finite floats loudly (OAI-12): `Number::from_f64(∞)` is `None`,
/// so an unguarded conversion would advertise `null` where the document
/// declares `maximum: .inf`.
#[cfg(feature = "openapi")]
fn yaml_number_to_json(number: &yaml_serde::Number) -> Result<Value, String> {
if let Some(integer) = number.as_i64() {
return Ok(Value::Number(serde_json::Number::from(integer)));
@@ -253,6 +262,7 @@ fn yaml_number_to_json(number: &yaml_serde::Number) -> Result<Value, String> {
/// keys match what the JSON path requires (`200:` → `"200"`). Keys
/// without a round-trip-stable string form are rejected loudly:
/// nullish keys (`~`, empty) and collection keys (sequences, mappings).
#[cfg(feature = "openapi")]
fn yaml_key_pointer(key: &YamlValue) -> Result<String, String> {
match key {
YamlValue::String(s) => Ok(s.clone()),
@@ -343,6 +353,7 @@ impl OpenAPISpec {
/// null) and non-string mapping keys with no round-trip-stable
/// string form. Every rejection names the JSON pointer of the
/// offending value.
#[cfg(feature = "openapi")]
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> {
let yaml: YamlValue = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML: {e}"),
@@ -376,7 +387,10 @@ impl OpenAPISpec {
pub fn from_str(doc: &str) -> Result<Self, AdapterError> {
match serde_json::from_str::<Value>(doc) {
Ok(raw) => Self::from_value(raw),
#[cfg(feature = "openapi")]
Err(_) => Self::from_yaml(doc),
#[cfg(not(feature = "openapi"))]
Err(_) => Self::from_json(doc),
}
}
@@ -599,6 +613,7 @@ impl OpenAPISpec {
/// spec declaring the OAI-14 blocks would import silently-degraded
/// operations, so the import refuses where the semantics would be
/// lost; the gateway doc's own markers are inert here.
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn validate_import_loud_features(&self) -> Result<(), AdapterError> {
let mut callback_locations: Vec<String> = Vec::new();
let mut security_locations: Vec<String> = Vec::new();
@@ -700,10 +715,12 @@ impl OpenAPISpec {
/// an acyclic exponential shared-ref chain), a per-call node budget
/// counts every materialized `Value` node; exceeding it fails import
/// with a clean error naming the budget (OAI-11).
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0)
}
#[cfg(all(feature = "client", feature = "openapi"))]
fn resolve_refs_bounded(
&self,
schema: &Value,
@@ -805,12 +822,14 @@ impl OpenAPISpec {
/// cycle-free expansions (total-work bounding, review 002 OAI-11), and the
/// per-call node counter (the last-resort bound on inlined output size).
#[derive(Default)]
#[cfg(all(feature = "client", feature = "openapi"))]
struct RefResolution {
resolving: HashSet<String>,
memo: HashMap<String, Value>,
nodes: usize,
}
#[cfg(all(feature = "client", feature = "openapi"))]
fn count_nodes(value: &Value) -> usize {
match value {
Value::Object(map) => 1 + map.values().map(count_nodes).sum::<usize>(),
@@ -828,6 +847,7 @@ fn count_nodes(value: &Value) -> usize {
/// never honor. The walk visits only the *declared* schema (already
/// bounded by the resolver's budgets before this runs on resolved
/// output); it is linear in schema size.
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn collect_ignored_schema_keys(value: &Value, found: &mut Vec<String>) {
let mut stack = vec![value];
while let Some(current) = stack.pop() {
@@ -1094,7 +1114,7 @@ fn check_parameter_style(
}
}
#[cfg(test)]
#[cfg(all(test, feature = "client", feature = "openapi"))]
mod tests {
use super::*;
use crate::adapters::{FromOpenAPI, HttpServiceConfig};

View File

@@ -45,7 +45,8 @@ use rmcp::transport::{
};
use serde_json::{Map, Value};
use crate::gateway::{GatewayDispatch, MAX_BATCH_OPERATIONS};
use crate::gateway::MAX_BATCH_OPERATIONS;
use alkcall::gateway::GatewayDispatch;
const TOOL_SEARCH: &str = "search";
const TOOL_SCHEMA: &str = "schema";

View File

@@ -2,6 +2,23 @@
//! standard ALPNs (with WebSocket upgrade carrying the channels protocol)
//! and hosts the HTTP-backed call-protocol adapters.
//!
//! # Feature sides
//!
//! The crate serves two independent use cases, selectable via features:
//!
//! - `server` — the producer side: the axum `Router` host
//! ([`server`]), the gateway routes ([`gateway`]), the WebSocket
//! upgrade path ([`websocket`]), `to_openapi`, and `to_mcp` (with
//! `mcp`).
//! - `client` — the consumer side: the shared outbound client host
//! ([`client`]) and the import adapters (`from_jsonschema`,
//! `from_openapi`, `from_mcp` with `mcp`).
//!
//! Both default on; lean builds take `default-features = false` with
//! the side they need. The shared OpenAPI document model rides behind
//! `openapi` (implied by both sides); `openapi_spec` and `to_openapi`
//! compile with it alone.
//!
//! # Documentation gate (HY-02)
//!
//! Public-API items must be documented: this crate is pre-crates.io and
@@ -16,15 +33,22 @@
pub mod adapters;
/// The shared outbound client host: hot-reloadable reqwest stack with
/// same-host-only redirects, idempotent-only retries, and TLS config.
#[cfg(feature = "client")]
pub mod client;
/// The 6 fixed gateway endpoints and their shared dispatch spine —
/// the sole HTTP invoke path.
#[cfg(feature = "server")]
pub mod gateway;
/// The HTTP server host: the `HttpAdapter` router, auth, stealth decoy,
/// `/healthz`, `/openapi.json`.
#[cfg(feature = "server")]
pub mod server;
/// The WebSocket upgrade path carrying the native call-protocol
/// session (browsers; ADR-044, ADR-048).
/// session (browsers; ADR-044, ADR-048). The `upgrade` submodule
/// compiles under `server`; the shared byte-stream adapter also under
/// `wss` (the `from_wss` consumer seam).
#[cfg(any(feature = "server", feature = "wss", test))]
pub mod websocket;
#[cfg(feature = "server")]
pub use server::{decoy_fallback, decoy_method_not_allowed, healthz, DecoyConfig};

View File

@@ -555,7 +555,9 @@ impl HttpAdapter {
let service = TowerToHyperService::new(self.router.clone());
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(feature = "h2")]
const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(30);
#[cfg(feature = "h2")]
const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg_attr(not(feature = "h2"), allow(unused_mut))]

View File

@@ -85,6 +85,7 @@ use std::{
time::Duration,
};
#[cfg(feature = "server")]
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
use futures::channel::mpsc as futures_mpsc;
use futures::{SinkExt, StreamExt};
@@ -586,6 +587,10 @@ pub struct WsByteStream {
pub struct WsPumps {
read_task: tokio::task::JoinHandle<()>,
write_task: tokio::task::JoinHandle<()>,
#[cfg_attr(
all(any(test, feature = "wss"), not(feature = "client")),
allow(dead_code)
)]
#[cfg(any(test, feature = "wss"))]
read_eof: tokio::sync::watch::Sender<bool>,
}
@@ -605,14 +610,17 @@ impl WsPumps {
/// may be observed repeatedly. Used by `from_wss`'s
/// connection-drop monitor (ADR-070).
#[cfg(any(test, feature = "wss"))]
#[cfg_attr(not(feature = "client"), allow(dead_code))]
pub(crate) fn read_eof(&self) -> tokio::sync::watch::Receiver<bool> {
self.read_eof.subscribe()
}
}
/// axum's message/CloseFrame types as a [`WsFraming`] flavor.
#[cfg(feature = "server")]
struct AxumFraming;
#[cfg(feature = "server")]
impl WsFraming for AxumFraming {
type Bytes = axum::body::Bytes;
type Msg = AxumMessage;
@@ -647,6 +655,7 @@ impl WsFraming for AxumFraming {
/// Split a `WebSocket` into the byte stream + the pump tasks with the
/// default idle-read timeout (`crate::websocket::DEFAULT_WS_IDLE_TIMEOUT`).
/// See [`split_ws_to_bytes_idle`] for the configurable form.
#[cfg(feature = "server")]
pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
split_ws_to_bytes_idle(socket, Some(DEFAULT_WS_IDLE_TIMEOUT))
}
@@ -659,6 +668,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
/// write pump runs with [`crate::websocket::DEFAULT_WS_WRITE_TIMEOUT`]
/// — see [`split_ws_to_bytes_idle_with_write`] for the WS-18
/// configurable form.
#[cfg(feature = "server")]
pub fn split_ws_to_bytes_idle(
socket: WebSocket,
idle_timeout: Option<Duration>,
@@ -670,6 +680,7 @@ pub fn split_ws_to_bytes_idle(
/// window: `None` = the crate default
/// ([`DEFAULT_WS_WRITE_TIMEOUT`]),
/// `Some(d)` a deployment-set window.
#[cfg(feature = "server")]
pub fn split_ws_to_bytes_idle_with_write(
socket: WebSocket,
idle_timeout: Option<Duration>,
@@ -886,7 +897,7 @@ struct TungsteniteFraming;
#[cfg(any(test, feature = "wss"))]
impl WsFraming for TungsteniteFraming {
type Bytes = axum::body::Bytes;
type Bytes = bytes::Bytes;
type Msg = tokio_tungstenite::tungstenite::Message;
fn binary(msg: &Self::Msg) -> Option<&Self::Bytes> {
@@ -1848,7 +1859,7 @@ mod tests {
/// with `AxumFraming` over a fake axum `WebSocket` sink/stream pair
/// (no server needed): a `mpsc`-backed sink/stream pair standing in
/// for the split halves of `axum::extract::ws::WebSocket`.
#[cfg(test)]
#[cfg(all(test, feature = "server"))]
mod axum_framing_tests {
use super::*;
use futures::channel::mpsc as fut_mpsc;

View File

@@ -8,26 +8,38 @@
//! is the single seam between axum's WS and
//! alkcall's byte-oriented channels machinery — shared with the
//! `from_wss` consumer path ([ADR-070]).
//!
//! Feature sides: [`upgrade`] (and the axum flavor of the adapter)
//! compile under `server`; the tungstenite flavor compiles under `wss`
//! (and under `test-support`); both are available with both features.
#[cfg(any(feature = "server", feature = "wss", test))]
pub mod byte_adapter;
#[cfg(feature = "server")]
pub mod upgrade;
#[cfg(any(feature = "server", feature = "wss", test))]
pub use byte_adapter::{
split_ws_to_bytes, split_ws_to_bytes_idle, split_ws_to_bytes_idle_with_write, WsByteStream,
WsPumps, DEFAULT_WS_IDLE_TIMEOUT, DEFAULT_WS_WRITE_TIMEOUT, INBOUND_WS_FRAME_CAP,
WsByteStream, WsPumps, DEFAULT_WS_IDLE_TIMEOUT, DEFAULT_WS_WRITE_TIMEOUT, INBOUND_WS_FRAME_CAP,
INBOUND_WS_MESSAGE_CAP, MAX_CHUNK_LEN, PENDING_BUFFER_CAP, WS_GOING_AWAY, WS_MESSAGE_CAP,
WS_PROTOCOL_ERROR,
};
#[cfg(feature = "server")]
pub use byte_adapter::{
split_ws_to_bytes, split_ws_to_bytes_idle, split_ws_to_bytes_idle_with_write,
};
#[cfg(any(test, feature = "wss"))]
pub use byte_adapter::split_tungstenite_to_bytes;
#[cfg(any(test, feature = "wss"))]
#[cfg(feature = "server")]
#[allow(unused_imports)]
pub(crate) use upgrade::adapter_install_channel_zero;
#[cfg(feature = "server")]
pub use upgrade::{
run_channels_session, ws_bearer_auth, ws_upgrade_handler, ChannelsPolicy, SessionState,
WsSessions, WsTimeouts, DEFAULT_WS_MAX_SESSIONS,
};
#[cfg(any(test, feature = "test-support"))]
#[cfg(all(any(test, feature = "test-support"), feature = "server"))]
pub use upgrade::test_support::{frame_channel0_chunk, ChunkAssembler, FrameAssembler, WsClient};