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:
15
AGENTS.md
15
AGENTS.md
@@ -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
1
Cargo.lock
generated
@@ -54,6 +54,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"bytes",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body-util",
|
||||
|
||||
77
Cargo.toml
77
Cargo.toml
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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";
|
||||
|
||||
26
src/lib.rs
26
src/lib.rs
@@ -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};
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user