#[cfg(feature = "axum")]
pub mod axum_adapter;
pub mod cors;
#[cfg(feature = "grpc")]
pub mod grpc;
pub mod jsonrpc;
pub mod rest;
#[cfg(feature = "websocket")]
pub mod websocket;
pub use cors::CorsConfig;
#[cfg(feature = "grpc")]
pub use grpc::{GrpcConfig, GrpcDispatcher};
pub use jsonrpc::JsonRpcDispatcher;
pub use rest::RestDispatcher;
#[cfg(feature = "websocket")]
pub use websocket::WebSocketDispatcher;
#[derive(Debug, Clone)]
pub struct DispatchConfig {
pub max_request_body_size: usize,
pub body_read_timeout: std::time::Duration,
pub max_query_string_length: usize,
pub sse_keep_alive_interval: std::time::Duration,
pub sse_channel_capacity: usize,
pub max_batch_size: usize,
pub require_version_header: bool,
}
impl Default for DispatchConfig {
fn default() -> Self {
Self {
max_request_body_size: 4 * 1024 * 1024,
body_read_timeout: std::time::Duration::from_secs(30),
max_query_string_length: 4096,
sse_keep_alive_interval: std::time::Duration::from_secs(30),
sse_channel_capacity: 64,
max_batch_size: 100,
require_version_header: true,
}
}
}
impl DispatchConfig {
#[must_use]
pub const fn with_max_request_body_size(mut self, size: usize) -> Self {
self.max_request_body_size = size;
self
}
#[must_use]
pub const fn with_body_read_timeout(mut self, timeout: std::time::Duration) -> Self {
self.body_read_timeout = timeout;
self
}
#[must_use]
pub const fn with_max_query_string_length(mut self, length: usize) -> Self {
self.max_query_string_length = length;
self
}
#[must_use]
pub const fn with_sse_keep_alive_interval(mut self, interval: std::time::Duration) -> Self {
self.sse_keep_alive_interval = interval;
self
}
#[must_use]
pub const fn with_sse_channel_capacity(mut self, capacity: usize) -> Self {
self.sse_channel_capacity = capacity;
self
}
#[must_use]
pub const fn with_max_batch_size(mut self, size: usize) -> Self {
self.max_batch_size = size;
self
}
#[must_use]
pub const fn accept_missing_version_header(mut self) -> Self {
self.require_version_header = false;
self
}
}
pub const A2A_VERSION_METADATA_KEY: &str = "a2a-version";
pub fn validate_version_metadata<S: std::hash::BuildHasher>(
metadata: &std::collections::HashMap<String, String, S>,
require: bool,
) -> Result<(), a2a_protocol_types::error::A2aError> {
let value = metadata
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(A2A_VERSION_METADATA_KEY))
.map(|(_, v)| v.as_str());
validate_version_header(value, require)
}
pub(crate) fn validate_version_header(
value: Option<&str>,
require: bool,
) -> Result<(), a2a_protocol_types::error::A2aError> {
let v = value.unwrap_or("").trim();
if v.is_empty() {
if require {
return Err(a2a_protocol_types::error::A2aError::version_not_supported(
"A2A version '0.3' is not supported by this server; expected '1.0' (send the A2A-Version header)",
));
}
return Ok(());
}
let major = v.split('.').next().and_then(|s| s.parse::<u32>().ok());
if major == Some(1) {
return Ok(());
}
Err(a2a_protocol_types::error::A2aError::version_not_supported(
format!("unsupported A2A version: {v}; this server supports 1.x"),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn default_values() {
let config = DispatchConfig::default();
assert_eq!(config.max_request_body_size, 4 * 1024 * 1024);
assert_eq!(config.body_read_timeout, Duration::from_secs(30));
assert_eq!(config.max_query_string_length, 4096);
assert_eq!(config.sse_keep_alive_interval, Duration::from_secs(30));
assert_eq!(config.sse_channel_capacity, 64);
assert_eq!(config.max_batch_size, 100);
}
#[test]
fn with_max_request_body_size_sets_value() {
let config = DispatchConfig::default().with_max_request_body_size(8 * 1024 * 1024);
assert_eq!(config.max_request_body_size, 8 * 1024 * 1024);
}
#[test]
fn with_body_read_timeout_sets_value() {
let config = DispatchConfig::default().with_body_read_timeout(Duration::from_secs(60));
assert_eq!(config.body_read_timeout, Duration::from_secs(60));
}
#[test]
fn with_max_query_string_length_sets_value() {
let config = DispatchConfig::default().with_max_query_string_length(8192);
assert_eq!(config.max_query_string_length, 8192);
}
#[test]
fn with_sse_keep_alive_interval_sets_value() {
let config =
DispatchConfig::default().with_sse_keep_alive_interval(Duration::from_secs(15));
assert_eq!(config.sse_keep_alive_interval, Duration::from_secs(15));
}
#[test]
fn with_sse_channel_capacity_sets_value() {
let config = DispatchConfig::default().with_sse_channel_capacity(128);
assert_eq!(config.sse_channel_capacity, 128);
}
#[test]
fn with_max_batch_size_sets_value() {
let config = DispatchConfig::default().with_max_batch_size(50);
assert_eq!(config.max_batch_size, 50);
}
#[test]
fn builder_chaining() {
let config = DispatchConfig::default()
.with_max_request_body_size(1024)
.with_body_read_timeout(Duration::from_secs(10))
.with_max_query_string_length(2048)
.with_sse_keep_alive_interval(Duration::from_secs(5))
.with_sse_channel_capacity(32)
.with_max_batch_size(25);
assert_eq!(config.max_request_body_size, 1024);
assert_eq!(config.body_read_timeout, Duration::from_secs(10));
assert_eq!(config.max_query_string_length, 2048);
assert_eq!(config.sse_keep_alive_interval, Duration::from_secs(5));
assert_eq!(config.sse_channel_capacity, 32);
assert_eq!(config.max_batch_size, 25);
}
#[test]
fn debug_format() {
let config = DispatchConfig::default();
let debug = format!("{config:?}");
assert!(debug.contains("DispatchConfig"));
assert!(debug.contains("max_request_body_size"));
assert!(debug.contains("body_read_timeout"));
assert!(debug.contains("max_query_string_length"));
assert!(debug.contains("sse_keep_alive_interval"));
assert!(debug.contains("sse_channel_capacity"));
assert!(debug.contains("max_batch_size"));
}
#[test]
fn version_metadata_matches_key_case_insensitively() {
for key in ["a2a-version", "A2A-Version", "A2A-VERSION", "a2a-Version"] {
let mut md = std::collections::HashMap::new();
md.insert(key.to_string(), "1.0".to_string());
assert!(
validate_version_metadata(&md, true).is_ok(),
"key spelling {key} should be recognised"
);
}
}
#[test]
fn version_metadata_rejects_unsupported_version() {
let mut md = std::collections::HashMap::new();
md.insert("a2a-version".to_string(), "0.3".to_string());
let err = validate_version_metadata(&md, true).expect_err("0.3 is not supported");
assert!(
err.message.contains("0.3"),
"the error should name the version it rejected, got: {}",
err.message
);
}
#[test]
fn version_metadata_accepts_any_1x_including_patch() {
for v in ["1.0", "1.4", "1.0.2", " 1.0 "] {
let mut md = std::collections::HashMap::new();
md.insert("a2a-version".to_string(), v.to_string());
assert!(
validate_version_metadata(&md, true).is_ok(),
"{v} is a 1.x version and should be accepted"
);
}
}
#[test]
fn version_metadata_absent_follows_the_require_flag() {
let empty = std::collections::HashMap::new();
assert!(
validate_version_metadata(&empty, false).is_ok(),
"require=false is the gRPC posture: absent means a legacy client, accept it"
);
assert!(
validate_version_metadata(&empty, true).is_err(),
"require=true is the HTTP posture: absent means 0.3 per 3.6.2, reject it"
);
}
#[test]
fn version_metadata_treats_empty_value_as_absent() {
let mut md = std::collections::HashMap::new();
md.insert("a2a-version".to_string(), " ".to_string());
assert!(validate_version_metadata(&md, true).is_err());
assert!(validate_version_metadata(&md, false).is_ok());
}
#[test]
fn version_metadata_ignores_unrelated_keys() {
let mut md = std::collections::HashMap::new();
md.insert("authorization".to_string(), "Bearer x".to_string());
md.insert("x-tenant-id".to_string(), "acme".to_string());
assert!(
validate_version_metadata(&md, false).is_ok(),
"no version key present, and require=false accepts that"
);
}
}