use std::pin::Pin;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use futures_core::Stream;
use futures_util::StreamExt;
use crate::adapters::secondary::a2a::A2aMethodTable;
use crate::application::a2a::handler::{A2aError, A2aRequestContext};
use crate::application::jsonrpc::MethodTable;
use crate::domain::a2a::streaming_events::StreamingEvent;
use crate::domain::a2a::version::A2aVersion;
pub trait A2aStreamingRequestHandler: Send + Sync + 'static {
fn dispatch_stream(
&self,
method: crate::adapters::secondary::a2a::A2aMethod,
params: Option<serde_json::Value>,
context: &A2aRequestContext,
) -> Pin<Box<dyn Stream<Item = Result<StreamingEvent, A2aError>> + Send>>;
}
pub fn serve_streaming<H>(path: &str, handler: Arc<H>, protocol_version: A2aVersion) -> Router
where
H: A2aStreamingRequestHandler + ?Sized + 'static,
{
let state = StreamingState {
handler,
protocol_version,
};
Router::new()
.route(path, post(handle_streaming_request::<H>))
.with_state(Arc::new(state))
}
struct StreamingState<H: A2aStreamingRequestHandler + ?Sized + 'static> {
handler: Arc<H>,
protocol_version: A2aVersion,
}
struct ParsedRequest {
id: Option<serde_json::Value>,
method: crate::adapters::secondary::a2a::A2aMethod,
params: Option<serde_json::Value>,
}
#[expect(
clippy::result_large_err,
reason = "this is the one place that returns both shapes"
)]
async fn parse_envelope(request: Request<Body>) -> Result<ParsedRequest, Response> {
let content_type = request
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let Some(content_type) = content_type else {
return Err((StatusCode::BAD_REQUEST, "missing content-type").into_response());
};
if !content_type.starts_with("application/json") {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("content-type must be application/json, got {content_type}"),
)
.into_response());
}
let body = axum::body::to_bytes(request.into_body(), 64 * 1024 * 1024)
.await
.map_err(|_| (StatusCode::BAD_REQUEST, "failed to read request body").into_response())?;
let envelope: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("invalid JSON-RPC envelope: {error}"),
)
.into_response()
})?;
let envelope_obj = envelope.as_object().ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
"JSON-RPC envelope must be an object",
)
.into_response()
})?;
let id = envelope_obj.get("id").cloned();
let method_name = envelope_obj
.get("method")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| (StatusCode::BAD_REQUEST, "missing or non-string `method`").into_response())?
.to_owned();
let params = envelope_obj.get("params").cloned();
let method = A2aMethodTable::TABLE.lookup(&method_name).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
format!("unknown method: {method_name}"),
)
.into_response()
})?;
Ok(ParsedRequest { id, method, params })
}
async fn handle_streaming_request<H>(
State(state): State<Arc<StreamingState<H>>>,
request: Request<Body>,
) -> Response
where
H: A2aStreamingRequestHandler + ?Sized + 'static,
{
let ParsedRequest { id, method, params } = match parse_envelope(request).await {
Ok(parsed) => parsed,
Err(response) => return response,
};
let context = A2aRequestContext {
protocol_version: state.protocol_version,
};
let event_stream = state.handler.dispatch_stream(method, params, &context);
let id_for_sse = id;
let sse_stream: Pin<Box<dyn Stream<Item = Result<axum::body::Bytes, std::io::Error>> + Send>> =
Box::pin(event_stream.map(move |event_result| {
let envelope = match event_result {
Ok(event) => serde_json::json!({
"jsonrpc": "2.0",
"id": &id_for_sse,
"result": event,
}),
Err(error) => serde_json::json!({
"jsonrpc": "2.0",
"id": &id_for_sse,
"error": {
"code": error.code(),
"message": error.message(),
},
}),
};
let line = match serde_json::to_string(&envelope) {
Ok(s) => format!("data: {s}\n\n"),
Err(e) => format!("data: {{\"error\":\"failed to serialise: {e}\"}}\n\n"),
};
Ok(axum::body::Bytes::from(line))
}));
let body = Body::from_stream(sse_stream);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.body(body)
.unwrap_or_else(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to build response",
)
.into_response()
})
}
#[cfg(test)]
mod tests {
use std::pin::Pin;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures_core::Stream;
use futures_util::stream;
use tower::ServiceExt;
use super::{A2aStreamingRequestHandler, serve_streaming};
use crate::adapters::secondary::a2a::A2aMethod;
use crate::application::a2a::handler::{A2aError, A2aRequestContext};
use crate::domain::a2a::streaming_events::{StreamingEvent, TaskStatusUpdateEvent};
use crate::domain::a2a::task::{TaskState, TaskStatus};
use crate::domain::a2a::version::A2aVersion;
type TestError = Box<dyn std::error::Error>;
type TestStream = Pin<Box<dyn Stream<Item = Result<StreamingEvent, A2aError>> + Send>>;
fn sample_status_event(final_flag: bool) -> TaskStatusUpdateEvent {
let mut event = TaskStatusUpdateEvent::new(
"t-1",
"c-1",
TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
);
if final_flag {
event = event.with_final();
}
event
}
struct FixedStreamHandler {
events: Vec<StreamingEvent>,
}
impl A2aStreamingRequestHandler for FixedStreamHandler {
fn dispatch_stream(
&self,
_method: A2aMethod,
_params: Option<serde_json::Value>,
_context: &A2aRequestContext,
) -> TestStream {
let events: Vec<Result<StreamingEvent, A2aError>> =
self.events.iter().cloned().map(Ok).collect();
Box::pin(stream::iter(events))
}
}
async fn read_response_body(response: axum::response::Response) -> Result<String, TestError> {
let body = response.into_body();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
String::from_utf8(bytes.to_vec()).map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })
}
#[tokio::test]
async fn streaming_response_emits_events_as_sse() -> Result<(), TestError> {
let handler: Arc<dyn A2aStreamingRequestHandler> = Arc::new(FixedStreamHandler {
events: vec![
StreamingEvent::Status(sample_status_event(false)),
StreamingEvent::Status(sample_status_event(true)),
],
});
let app: Router = serve_streaming("/stream", handler, A2aVersion::V1_0);
let request = Request::builder()
.method("POST")
.uri("/stream")
.header("content-type", "application/json")
.body(Body::from(
r#"{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{}}"#,
))
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let response = app
.oneshot(request)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("text/event-stream")
);
let body = read_response_body(response).await?;
let events: Vec<&str> = body.split("\n\n").filter(|s| !s.is_empty()).collect();
assert_eq!(events.len(), 2, "two events");
for event in &events {
assert!(
event.starts_with("data: "),
"expected SSE `data:` prefix, got {event:?}"
);
}
let first = events.first().copied().unwrap_or("");
assert!(first.contains(r#""final":false"#));
let second = events.get(1).copied().unwrap_or("");
assert!(second.contains(r#""final":true"#));
Ok(())
}
#[tokio::test]
async fn streaming_response_uses_the_request_id() -> Result<(), TestError> {
let handler: Arc<dyn A2aStreamingRequestHandler> = Arc::new(FixedStreamHandler {
events: vec![StreamingEvent::Status(sample_status_event(false))],
});
let app: Router = serve_streaming("/stream", handler, A2aVersion::V1_0);
let request = Request::builder()
.method("POST")
.uri("/stream")
.header("content-type", "application/json")
.body(Body::from(
r#"{"jsonrpc":"2.0","id":42,"method":"SendStreamingMessage","params":{}}"#,
))
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let response = app
.oneshot(request)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let body = read_response_body(response).await?;
assert!(
body.contains(r#""id":42"#),
"the response must carry the request id"
);
Ok(())
}
#[tokio::test]
async fn non_json_content_type_returns_415() -> Result<(), TestError> {
let handler: Arc<dyn A2aStreamingRequestHandler> =
Arc::new(FixedStreamHandler { events: vec![] });
let app: Router = serve_streaming("/stream", handler, A2aVersion::V1_0);
let request = Request::builder()
.method("POST")
.uri("/stream")
.header("content-type", "text/plain")
.body(Body::from("hello"))
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let response = app
.oneshot(request)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
Ok(())
}
#[tokio::test]
async fn unknown_method_returns_400() -> Result<(), TestError> {
let handler: Arc<dyn A2aStreamingRequestHandler> =
Arc::new(FixedStreamHandler { events: vec![] });
let app: Router = serve_streaming("/stream", handler, A2aVersion::V1_0);
let request = Request::builder()
.method("POST")
.uri("/stream")
.header("content-type", "application/json")
.body(Body::from(
r#"{"jsonrpc":"2.0","id":1,"method":"NotARealMethod","params":{}}"#,
))
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let response = app
.oneshot(request)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
Ok(())
}
}