1use std::collections::HashMap;
78use std::convert::Infallible;
79use std::sync::Arc;
80
81use axum::Router;
82use axum::body::Body;
83use axum::extract::{Path, Query, State};
84use axum::response::IntoResponse;
85use axum::routing::{get, post};
86use bytes::Bytes;
87
88use crate::handler::{RequestHandler, SendMessageResult};
89use crate::streaming::build_sse_response;
90
91pub struct A2aRouter {
117 handler: Arc<RequestHandler>,
118 config: super::DispatchConfig,
119}
120
121impl A2aRouter {
122 #[must_use]
124 pub fn new(handler: Arc<RequestHandler>) -> Self {
125 Self {
126 handler,
127 config: super::DispatchConfig::default(),
128 }
129 }
130
131 #[must_use]
133 pub const fn with_config(handler: Arc<RequestHandler>, config: super::DispatchConfig) -> Self {
134 Self { handler, config }
135 }
136
137 pub fn into_router(self) -> Router {
142 let max_body = self.config.max_request_body_size;
147 let state = A2aState {
148 handler: self.handler,
149 config: Arc::new(self.config),
150 };
151
152 Router::new()
153 .route("/message:send", post(handle_send_message))
155 .route("/message:stream", post(handle_stream_message))
156 .route("/tasks", get(handle_list_tasks))
158 .route("/tasks/{*rest}", axum::routing::any(handle_tasks_catchall))
163 .route("/extendedAgentCard", get(handle_extended_card))
165 .route("/.well-known/agent-card.json", get(handle_agent_card))
167 .route("/health", get(handle_health))
169 .route("/ready", get(handle_ready))
170 .with_state(state)
171 .layer(axum::extract::DefaultBodyLimit::max(max_body))
172 }
173}
174
175pub(super) struct TimedBody(pub(super) Bytes);
197
198impl axum::extract::FromRequest<A2aState> for TimedBody {
199 type Rejection = axum::response::Response;
200
201 async fn from_request(
202 req: axum::extract::Request,
203 state: &A2aState,
204 ) -> Result<Self, Self::Rejection> {
205 let deadline = state.config.body_read_timeout;
206 match tokio::time::timeout(deadline, Bytes::from_request(req, state)).await {
207 Ok(Ok(bytes)) => Ok(Self(bytes)),
208 Ok(Err(rejection)) => Err(rejection.into_response()),
212 Err(_) => Err((
213 axum::http::StatusCode::REQUEST_TIMEOUT,
214 format!("request body not fully received within {deadline:?}"),
215 )
216 .into_response()),
217 }
218 }
219}
220
221#[derive(Clone)]
224struct A2aState {
225 handler: Arc<RequestHandler>,
226 config: Arc<super::DispatchConfig>,
227}
228
229fn extract_headers(headers: &axum::http::HeaderMap) -> HashMap<String, String> {
232 headers
233 .iter()
234 .filter_map(|(k, v)| {
235 v.to_str()
236 .ok()
237 .map(|val| (k.as_str().to_lowercase(), val.to_owned()))
238 })
239 .collect()
240}
241
242fn plain_error(status: u16, message: &str) -> axum::response::Response {
251 hyper_to_axum(crate::dispatch::rest::error_json_response(status, message))
252}
253
254fn handler_error_to_response(err: &crate::error::ServerError) -> axum::response::Response {
260 hyper_to_axum(crate::dispatch::rest::server_error_to_response(err))
261}
262
263fn bad_body(err: &serde_json::Error) -> axum::response::Response {
266 plain_error(400, &err.to_string())
267}
268
269fn a2a_json<T: serde::Serialize>(value: &T) -> axum::response::Response {
277 hyper_to_axum(crate::dispatch::rest::json_ok_response(value))
278}
279
280fn hyper_to_axum(
281 resp: hyper::Response<http_body_util::combinators::BoxBody<Bytes, Infallible>>,
282) -> axum::response::Response {
283 let (parts, body) = resp.into_parts();
284 let axum_body = Body::new(body);
285 axum::response::Response::from_parts(parts, axum_body)
286}
287
288#[allow(clippy::too_many_lines, clippy::literal_string_with_formatting_args)]
303async fn handle_tasks_catchall(
304 State(state): State<A2aState>,
305 method: axum::http::Method,
306 Path(rest): Path<String>,
307 headers: axum::http::HeaderMap,
308 TimedBody(body): TimedBody,
309) -> axum::response::Response {
310 let hdrs = extract_headers(&headers);
311 let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
312
313 match (method.as_str(), segments.as_slice()) {
314 ("GET", [id]) if !id.contains(':') => {
316 rpc_call(
317 &state.handler,
318 "GetTask",
319 (method.as_str(), "/tasks/{id}"),
320 &hdrs,
321 handle_get_task_inner(&state, id, &hdrs),
322 )
323 .await
324 }
325
326 ("POST", [id_action]) if id_action.ends_with(":cancel") => {
328 let id = &id_action[..id_action.len() - ":cancel".len()];
329 rpc_call(
330 &state.handler,
331 "CancelTask",
332 (method.as_str(), "/tasks/{id}:cancel"),
333 &hdrs,
334 handle_cancel_task_inner(&state, id, &hdrs),
335 )
336 .await
337 }
338
339 ("GET" | "POST", [id_action]) if id_action.ends_with(":subscribe") => {
341 let id = &id_action[..id_action.len() - ":subscribe".len()];
342 rpc_call(
343 &state.handler,
344 "SubscribeToTask",
345 (method.as_str(), "/tasks/{id}:subscribe"),
346 &hdrs,
347 handle_subscribe_inner(&state, id, &hdrs),
348 )
349 .await
350 }
351
352 ("POST", [task_id, "pushNotificationConfigs"]) => {
354 rpc_call(
355 &state.handler,
356 "CreateTaskPushNotificationConfig",
357 (method.as_str(), "/tasks/{task_id}/pushNotificationConfigs"),
358 &hdrs,
359 handle_create_push_config_inner(&state, task_id, &hdrs, body),
360 )
361 .await
362 }
363
364 ("GET", [task_id, "pushNotificationConfigs"]) => {
366 rpc_call(
367 &state.handler,
368 "ListTaskPushNotificationConfigs",
369 (method.as_str(), "/tasks/{task_id}/pushNotificationConfigs"),
370 &hdrs,
371 handle_list_push_configs_inner(&state, task_id, &hdrs),
372 )
373 .await
374 }
375
376 ("GET", [task_id, "pushNotificationConfigs", config_id]) => {
378 rpc_call(
379 &state.handler,
380 "GetTaskPushNotificationConfig",
381 (
382 method.as_str(),
383 "/tasks/{task_id}/pushNotificationConfigs/{config_id}",
384 ),
385 &hdrs,
386 handle_get_push_config_inner(&state, task_id, config_id, &hdrs),
387 )
388 .await
389 }
390
391 ("DELETE", [task_id, "pushNotificationConfigs", config_id]) => {
393 rpc_call(
394 &state.handler,
395 "DeleteTaskPushNotificationConfig",
396 (
397 method.as_str(),
398 "/tasks/{task_id}/pushNotificationConfigs/{config_id}",
399 ),
400 &hdrs,
401 handle_delete_push_config_inner(&state, task_id, config_id, &hdrs),
402 )
403 .await
404 }
405
406 _ => plain_error(404, "not found"),
407 }
408}
409
410fn rpc_call(
417 handler: &RequestHandler,
418 method: &str,
419 (http_method, route): (&str, &str),
420 hdrs: &HashMap<String, String>,
421 call: impl std::future::Future<Output = axum::response::Response>,
422) -> impl std::future::Future<Output = axum::response::Response> {
423 crate::rpc_span::ServerSpan::open(
424 handler,
425 crate::rpc_span::RpcSystem::HttpJson,
426 method,
427 Some(hdrs),
428 )
429 .with_http(http_method, route)
430 .run_response(call)
431}
432
433async fn handle_send_message(
434 State(state): State<A2aState>,
435 headers: axum::http::HeaderMap,
436 TimedBody(body): TimedBody,
437) -> axum::response::Response {
438 let hdrs = extract_headers(&headers);
439 rpc_call(
440 &state.handler,
441 "SendMessage",
442 ("POST", "/message:send"),
443 &hdrs,
444 handle_send_inner(&state, false, &hdrs, body),
445 )
446 .await
447}
448
449async fn handle_stream_message(
450 State(state): State<A2aState>,
451 headers: axum::http::HeaderMap,
452 TimedBody(body): TimedBody,
453) -> axum::response::Response {
454 let hdrs = extract_headers(&headers);
455 rpc_call(
456 &state.handler,
457 "SendStreamingMessage",
458 ("POST", "/message:stream"),
459 &hdrs,
460 handle_send_inner(&state, true, &hdrs, body),
461 )
462 .await
463}
464
465async fn handle_list_tasks(
466 State(state): State<A2aState>,
467 Query(query): Query<HashMap<String, String>>,
468 headers: axum::http::HeaderMap,
469) -> axum::response::Response {
470 let hdrs = extract_headers(&headers);
471 let params = a2a_protocol_types::params::ListTasksParams {
472 tenant: None,
473 context_id: query.get("contextId").cloned(),
474 status: query
475 .get("status")
476 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.clone())).ok()),
477 page_size: query.get("pageSize").and_then(|v| v.parse().ok()),
478 page_token: query.get("pageToken").cloned(),
479 status_timestamp_after: query.get("statusTimestampAfter").cloned(),
480 include_artifacts: query.get("includeArtifacts").and_then(|v| v.parse().ok()),
481 history_length: query.get("historyLength").and_then(|v| v.parse().ok()),
482 };
483 rpc_call(
484 &state.handler,
485 "ListTasks",
486 ("GET", "/tasks"),
487 &hdrs,
488 async {
489 match state.handler.on_list_tasks(params, Some(&hdrs)).await {
490 Ok(result) => a2a_json(&result),
491 Err(e) => handler_error_to_response(&e),
492 }
493 },
494 )
495 .await
496}
497
498async fn handle_extended_card(
499 State(state): State<A2aState>,
500 headers: axum::http::HeaderMap,
501) -> axum::response::Response {
502 let hdrs = extract_headers(&headers);
503 rpc_call(
504 &state.handler,
505 "GetExtendedAgentCard",
506 ("GET", "/extendedAgentCard"),
507 &hdrs,
508 async {
509 match state.handler.on_get_extended_agent_card(Some(&hdrs)).await {
510 Ok(card) => a2a_json(&card),
511 Err(e) => handler_error_to_response(&e),
512 }
513 },
514 )
515 .await
516}
517
518async fn handle_agent_card(State(state): State<A2aState>) -> axum::response::Response {
519 state.handler.agent_card.as_ref().map_or_else(
520 || plain_error(404, "agent card not configured"),
521 |card| axum::Json(card).into_response(),
522 )
523}
524
525async fn handle_health() -> axum::response::Response {
532 axum::Json(serde_json::json!({"status": "ok"})).into_response()
533}
534
535async fn handle_ready(State(state): State<A2aState>) -> axum::response::Response {
553 match state.handler.task_store_health().await {
554 Ok(()) => axum::Json(serde_json::json!({"status": "ready"})).into_response(),
555 Err(e) => (
556 axum::http::StatusCode::SERVICE_UNAVAILABLE,
557 axum::Json(serde_json::json!({
558 "status": "not_ready",
559 "reason": e.metric_label(),
560 })),
561 )
562 .into_response(),
563 }
564}
565
566async fn handle_send_inner(
569 state: &A2aState,
570 streaming: bool,
571 hdrs: &HashMap<String, String>,
572 body: Bytes,
573) -> axum::response::Response {
574 let params: a2a_protocol_types::params::MessageSendParams = match serde_json::from_slice(&body)
575 {
576 Ok(p) => p,
577 Err(e) => return bad_body(&e),
578 };
579 match state
580 .handler
581 .on_send_message(params, streaming, Some(hdrs))
582 .await
583 {
584 Ok(SendMessageResult::Response(resp)) => a2a_json(&resp),
585 Ok(SendMessageResult::Stream(reader)) => hyper_to_axum(build_sse_response(
586 reader,
587 Some(state.config.sse_keep_alive_interval),
588 Some(state.config.sse_channel_capacity),
589 None, )),
591 Err(e) => handler_error_to_response(&e),
592 }
593}
594
595async fn handle_get_task_inner(
596 state: &A2aState,
597 id: &str,
598 hdrs: &HashMap<String, String>,
599) -> axum::response::Response {
600 let params = a2a_protocol_types::params::TaskQueryParams {
601 tenant: None,
602 id: id.to_owned(),
603 history_length: None,
604 };
605 match state.handler.on_get_task(params, Some(hdrs)).await {
606 Ok(task) => a2a_json(&task),
607 Err(e) => handler_error_to_response(&e),
608 }
609}
610
611async fn handle_cancel_task_inner(
612 state: &A2aState,
613 id: &str,
614 hdrs: &HashMap<String, String>,
615) -> axum::response::Response {
616 let params = a2a_protocol_types::params::CancelTaskParams {
617 tenant: None,
618 id: id.to_owned(),
619 metadata: None,
620 };
621 match state.handler.on_cancel_task(params, Some(hdrs)).await {
622 Ok(task) => a2a_json(&task),
623 Err(e) => handler_error_to_response(&e),
624 }
625}
626
627async fn handle_subscribe_inner(
628 state: &A2aState,
629 id: &str,
630 hdrs: &HashMap<String, String>,
631) -> axum::response::Response {
632 let params = a2a_protocol_types::params::TaskIdParams {
633 tenant: None,
634 id: id.to_owned(),
635 };
636 match state.handler.on_resubscribe(params, Some(hdrs)).await {
637 Ok(reader) => hyper_to_axum(build_sse_response(
638 reader,
639 Some(state.config.sse_keep_alive_interval),
640 Some(state.config.sse_channel_capacity),
641 None, )),
643 Err(e) => handler_error_to_response(&e),
644 }
645}
646
647async fn handle_create_push_config_inner(
648 state: &A2aState,
649 task_id: &str,
650 hdrs: &HashMap<String, String>,
651 body: Bytes,
652) -> axum::response::Response {
653 let mut value: serde_json::Value = match serde_json::from_slice(&body) {
654 Ok(v) => v,
655 Err(e) => return bad_body(&e),
656 };
657 if let Some(obj) = value.as_object_mut() {
658 obj.entry("taskId")
659 .or_insert_with(|| serde_json::Value::String(task_id.to_owned()));
660 }
661 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
662 match serde_json::from_value(value) {
663 Ok(c) => c,
664 Err(e) => return bad_body(&e),
665 };
666 match state.handler.on_set_push_config(config, Some(hdrs)).await {
667 Ok(result) => a2a_json(&result),
668 Err(e) => handler_error_to_response(&e),
669 }
670}
671
672async fn handle_get_push_config_inner(
673 state: &A2aState,
674 task_id: &str,
675 config_id: &str,
676 hdrs: &HashMap<String, String>,
677) -> axum::response::Response {
678 let params = a2a_protocol_types::params::GetPushConfigParams {
679 tenant: None,
680 task_id: task_id.to_owned(),
681 id: config_id.to_owned(),
682 };
683 match state.handler.on_get_push_config(params, Some(hdrs)).await {
684 Ok(config) => a2a_json(&config),
685 Err(e) => handler_error_to_response(&e),
686 }
687}
688
689async fn handle_list_push_configs_inner(
690 state: &A2aState,
691 task_id: &str,
692 hdrs: &HashMap<String, String>,
693) -> axum::response::Response {
694 match state
695 .handler
696 .on_list_push_configs(task_id, None, Some(hdrs))
697 .await
698 {
699 Ok(configs) => {
700 let resp = a2a_protocol_types::responses::ListPushConfigsResponse {
701 configs,
702 next_page_token: None,
703 };
704 a2a_json(&resp)
705 }
706 Err(e) => handler_error_to_response(&e),
707 }
708}
709
710async fn handle_delete_push_config_inner(
711 state: &A2aState,
712 task_id: &str,
713 config_id: &str,
714 hdrs: &HashMap<String, String>,
715) -> axum::response::Response {
716 let params = a2a_protocol_types::params::DeletePushConfigParams {
717 tenant: None,
718 task_id: task_id.to_owned(),
719 id: config_id.to_owned(),
720 };
721 match state
722 .handler
723 .on_delete_push_config(params, Some(hdrs))
724 .await
725 {
726 Ok(()) => a2a_json(&serde_json::json!({})),
727 Err(e) => handler_error_to_response(&e),
728 }
729}
730
731#[cfg(test)]
734mod tests {
735 use super::*;
736
737 fn catchall_state() -> A2aState {
753 let handler = Arc::new(
754 crate::builder::RequestHandlerBuilder::new({
755 struct Noop;
756 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
757 Noop
758 })
759 .build()
760 .unwrap(),
761 );
762 A2aState {
763 handler,
764 config: Arc::new(super::super::DispatchConfig::default()),
765 }
766 }
767
768 async fn seed_task(state: &A2aState, id: &str) {
769 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
770 let task = Task {
771 id: TaskId::new(id),
772 context_id: ContextId::new("ctx"),
773 status: TaskStatus::new(TaskState::Submitted),
774 history: None,
775 artifacts: None,
776 metadata: None,
777 };
778 state.handler.task_store.save(&task).await.unwrap();
779 }
780
781 async fn dispatch_tail(state: &A2aState, method: &str, rest: &str) -> axum::http::StatusCode {
782 let response = handle_tasks_catchall(
783 State(state.clone()),
784 axum::http::Method::from_bytes(method.as_bytes()).unwrap(),
785 Path(rest.to_owned()),
786 axum::http::HeaderMap::new(),
787 TimedBody(Bytes::new()),
788 )
789 .await;
790 response.status()
791 }
792
793 #[tokio::test]
797 async fn catchall_routes_cancel_and_strips_the_suffix() {
798 let state = catchall_state();
799 seed_task(&state, "task-abc").await;
800
801 assert_eq!(
803 dispatch_tail(&state, "POST", "task-abc:cancel").await,
804 axum::http::StatusCode::OK,
805 "POST /tasks/task-abc:cancel must cancel task-abc"
806 );
807 assert_eq!(
810 dispatch_tail(&state, "POST", "missing-xyz:cancel").await,
811 axum::http::StatusCode::NOT_FOUND,
812 "an unknown task id must 404 rather than resolve to a truncated one"
813 );
814 }
815
816 #[tokio::test]
820 async fn catchall_routes_subscribe_and_strips_the_suffix() {
821 let state = catchall_state();
822 seed_task(&state, "task-abc").await;
823
824 assert_eq!(
840 dispatch_tail(&state, "GET", "task-abc:subscribe").await,
841 axum::http::StatusCode::OK,
842 "GET /tasks/task-abc:subscribe must subscribe to task-abc"
843 );
844 assert_eq!(
845 dispatch_tail(&state, "GET", "missing-xyz:subscribe").await,
846 axum::http::StatusCode::NOT_FOUND,
847 "subscribe on an unknown id must still 404"
848 );
849 }
850
851 #[tokio::test]
860 async fn catchall_post_without_a_colon_action_falls_through() {
861 let state = catchall_state();
862 seed_task(&state, "tid").await;
863
864 assert_eq!(
866 dispatch_tail(&state, "POST", "tidZZZZZZZ").await,
867 axum::http::StatusCode::NOT_FOUND,
868 "a POST with no colon action must not be routed to CancelTask"
869 );
870 assert_eq!(
872 dispatch_tail(&state, "POST", "tidZZZZZZZZZZ").await,
873 axum::http::StatusCode::NOT_FOUND,
874 "a POST with no colon action must not be routed to SubscribeToTask"
875 );
876 }
877
878 #[tokio::test]
882 async fn catchall_plain_get_does_not_swallow_colon_actions() {
883 let state = catchall_state();
884 seed_task(&state, "task-abc").await;
885
886 assert_eq!(
887 dispatch_tail(&state, "GET", "task-abc").await,
888 axum::http::StatusCode::OK,
889 "GET /tasks/task-abc must fetch the task"
890 );
891 assert_eq!(
895 dispatch_tail(&state, "POST", "task-abc:cancel").await,
896 axum::http::StatusCode::OK,
897 "a colon action must not be captured by the plain `GetTask` arm"
898 );
899 }
900
901 #[test]
902 fn extract_headers_lowercases_names() {
903 let mut map = axum::http::HeaderMap::new();
904 map.insert("X-Request-ID", "abc".parse().unwrap());
905 map.insert("content-type", "application/json".parse().unwrap());
906
907 let result = extract_headers(&map);
908 assert_eq!(result.get("x-request-id").unwrap(), "abc");
909 assert_eq!(result.get("content-type").unwrap(), "application/json");
910 }
911
912 #[test]
913 fn extract_headers_skips_non_utf8_values() {
914 let mut map = axum::http::HeaderMap::new();
915 map.insert("good", "valid".parse().unwrap());
916 let result = extract_headers(&map);
918 assert_eq!(result.len(), 1);
919 assert_eq!(result.get("good").unwrap(), "valid");
920 }
921
922 #[test]
923 fn extract_headers_empty_map() {
924 let map = axum::http::HeaderMap::new();
925 let result = extract_headers(&map);
926 assert!(result.is_empty());
927 }
928
929 #[test]
930 fn a2a_state_is_clone() {
931 fn assert_clone<T: Clone>() {}
932 assert_clone::<A2aState>();
933 }
934
935 #[test]
936 fn handler_error_status_task_not_found() {
937 use crate::error::ServerError;
938 assert_eq!(
939 handler_error_to_response(&ServerError::TaskNotFound("t".into()))
940 .status()
941 .as_u16(),
942 404
943 );
944 }
945
946 #[test]
947 fn handler_error_status_method_not_found() {
948 use crate::error::ServerError;
949 assert_eq!(
950 handler_error_to_response(&ServerError::MethodNotFound("m".into()))
951 .status()
952 .as_u16(),
953 404
954 );
955 }
956
957 #[test]
958 fn handler_error_status_invalid_params() {
959 use crate::error::ServerError;
960 assert_eq!(
961 handler_error_to_response(&ServerError::InvalidParams("p".into()))
962 .status()
963 .as_u16(),
964 400
965 );
966 }
967
968 #[test]
969 fn handler_error_status_serialization() {
970 use crate::error::ServerError;
971 let err = ServerError::Serialization(serde_json::from_str::<String>("bad").unwrap_err());
972 assert_eq!(handler_error_to_response(&err).status().as_u16(), 400);
973 }
974
975 #[test]
976 fn handler_error_status_task_not_cancelable() {
977 use crate::error::ServerError;
978 assert_eq!(
979 handler_error_to_response(&ServerError::TaskNotCancelable("t".into()))
980 .status()
981 .as_u16(),
982 400
983 );
984 }
985
986 #[test]
991 fn handler_error_status_agrees_with_the_shared_5_4_table() {
992 use crate::error::ServerError;
993 let cases = [
994 ServerError::TaskNotFound("t".into()),
995 ServerError::TaskNotCancelable("t".into()),
996 ServerError::PushNotSupported,
997 ServerError::UnsupportedOperation("op".into()),
998 ServerError::InvalidParams("p".into()),
999 ServerError::MethodNotFound("m".into()),
1000 ];
1001 for err in cases {
1002 assert_eq!(
1003 handler_error_to_response(&err).status().as_u16(),
1004 err.to_a2a_error().code.http_status(),
1005 "adapter disagrees with ErrorCode::http_status for {err:?}"
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn handler_error_status_invalid_state_transition() {
1012 use crate::error::ServerError;
1013 let err = ServerError::InvalidStateTransition {
1014 task_id: "t".into(),
1015 from: a2a_protocol_types::task::TaskState::Working,
1016 to: a2a_protocol_types::task::TaskState::Submitted,
1017 };
1018 assert_eq!(handler_error_to_response(&err).status().as_u16(), 400);
1021 }
1022
1023 #[test]
1024 fn handler_error_status_push_not_supported() {
1025 use crate::error::ServerError;
1026 assert_eq!(
1030 handler_error_to_response(&ServerError::PushNotSupported)
1031 .status()
1032 .as_u16(),
1033 400
1034 );
1035 }
1036
1037 #[test]
1038 fn handler_error_status_payload_too_large() {
1039 use crate::error::ServerError;
1040 assert_eq!(
1041 handler_error_to_response(&ServerError::PayloadTooLarge("big".into()))
1042 .status()
1043 .as_u16(),
1044 413
1045 );
1046 }
1047
1048 #[test]
1049 fn handler_error_status_overloaded() {
1050 use crate::error::ServerError;
1051 assert_eq!(
1054 handler_error_to_response(&ServerError::Overloaded("at capacity".into()))
1055 .status()
1056 .as_u16(),
1057 503
1058 );
1059 }
1060
1061 #[test]
1062 fn handler_error_status_internal() {
1063 use crate::error::ServerError;
1064 assert_eq!(
1065 handler_error_to_response(&ServerError::Internal("oops".into()))
1066 .status()
1067 .as_u16(),
1068 500
1069 );
1070 }
1071
1072 async fn body_of(resp: axum::response::Response) -> serde_json::Value {
1073 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1074 .await
1075 .unwrap();
1076 serde_json::from_slice(&bytes).unwrap()
1077 }
1078
1079 #[tokio::test]
1081 async fn a_plain_error_is_an_aip_193_status() {
1082 let resp = plain_error(404, "not found");
1083 assert_eq!(resp.status().as_u16(), 404);
1084 assert_eq!(
1085 body_of(resp).await,
1086 serde_json::json!({"error": {"code": 404, "status": "NOT_FOUND", "message": "not found"}})
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn a_handler_error_carries_its_error_info() {
1092 use crate::error::ServerError;
1093 let body = body_of(handler_error_to_response(&ServerError::TaskNotFound(
1094 "t1".into(),
1095 )))
1096 .await;
1097 assert_eq!(body["error"]["code"], 404);
1098 assert_eq!(body["error"]["status"], "NOT_FOUND");
1099 assert_eq!(body["error"]["details"][0]["reason"], "TASK_NOT_FOUND");
1100 }
1101
1102 #[test]
1103 fn handler_error_to_response_maps_correctly() {
1104 use crate::error::ServerError;
1105 let resp = handler_error_to_response(&ServerError::TaskNotFound("t1".into()));
1106 assert_eq!(resp.status().as_u16(), 404);
1107
1108 let resp = handler_error_to_response(&ServerError::InvalidParams("bad".into()));
1109 assert_eq!(resp.status().as_u16(), 400);
1110
1111 let resp = handler_error_to_response(&ServerError::Internal("oops".into()));
1112 assert_eq!(resp.status().as_u16(), 500);
1113 }
1114
1115 #[test]
1116 fn a2a_router_new_creates_with_defaults() {
1117 use crate::builder::RequestHandlerBuilder;
1119
1120 struct NoopExecutor;
1121 impl crate::executor::AgentExecutor for NoopExecutor {
1122 fn execute<'a>(
1123 &'a self,
1124 _ctx: &'a crate::request_context::RequestContext,
1125 _queue: &'a dyn crate::streaming::EventQueueWriter,
1126 ) -> std::pin::Pin<
1127 Box<
1128 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
1129 + Send
1130 + 'a,
1131 >,
1132 > {
1133 Box::pin(async { Ok(()) })
1134 }
1135 }
1136
1137 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
1138 let router = A2aRouter::new(handler);
1139 let _axum_router = router.into_router();
1141 }
1142
1143 #[test]
1144 fn a2a_router_with_config() {
1145 use crate::builder::RequestHandlerBuilder;
1146
1147 struct NoopExecutor;
1148 impl crate::executor::AgentExecutor for NoopExecutor {
1149 fn execute<'a>(
1150 &'a self,
1151 _ctx: &'a crate::request_context::RequestContext,
1152 _queue: &'a dyn crate::streaming::EventQueueWriter,
1153 ) -> std::pin::Pin<
1154 Box<
1155 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
1156 + Send
1157 + 'a,
1158 >,
1159 > {
1160 Box::pin(async { Ok(()) })
1161 }
1162 }
1163
1164 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
1165 let config =
1166 super::super::DispatchConfig::default().with_max_request_body_size(8 * 1024 * 1024);
1167 let router = A2aRouter::with_config(handler, config);
1168 let _axum_router = router.into_router();
1169 }
1170}
1171
1172#[cfg(test)]
1181mod readiness_tests {
1182 use std::future::Future;
1183 use std::pin::Pin;
1184
1185 use a2a_protocol_types::error::{A2aError, A2aResult};
1186 use a2a_protocol_types::params::ListTasksParams;
1187 use a2a_protocol_types::responses::TaskListResponse;
1188 use a2a_protocol_types::task::{Task, TaskId};
1189 use axum::http::StatusCode;
1190
1191 use crate::store::TaskStore;
1192
1193 use super::*;
1194
1195 struct UnreachableStore;
1197
1198 impl TaskStore for UnreachableStore {
1199 fn save<'a>(
1200 &'a self,
1201 _task: &'a Task,
1202 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1203 Box::pin(async { Err(A2aError::internal("connection refused")) })
1204 }
1205 fn get<'a>(
1206 &'a self,
1207 _id: &'a TaskId,
1208 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
1209 Box::pin(async { Err(A2aError::internal("connection refused")) })
1210 }
1211 fn list<'a>(
1212 &'a self,
1213 _p: &'a ListTasksParams,
1214 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
1215 Box::pin(async { Err(A2aError::internal("connection refused")) })
1216 }
1217 fn insert_if_absent<'a>(
1218 &'a self,
1219 _task: &'a Task,
1220 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
1221 Box::pin(async { Err(A2aError::internal("connection refused")) })
1222 }
1223 fn delete<'a>(
1224 &'a self,
1225 _id: &'a TaskId,
1226 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1227 Box::pin(async { Err(A2aError::internal("connection refused")) })
1228 }
1229 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
1230 Box::pin(async { Err(A2aError::internal("connection refused")) })
1231 }
1232 }
1233
1234 fn state_with(store: Option<UnreachableStore>) -> A2aState {
1235 struct Noop;
1236 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
1237
1238 let builder = crate::builder::RequestHandlerBuilder::new(Noop);
1239 let builder = match store {
1240 Some(s) => builder.with_task_store(s),
1241 None => builder,
1242 };
1243 A2aState {
1244 handler: Arc::new(builder.build().expect("build handler")),
1245 config: Arc::new(super::super::DispatchConfig::default()),
1246 }
1247 }
1248
1249 async fn read_response(resp: axum::response::Response) -> (StatusCode, String) {
1253 let status = resp.status();
1254 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1255 .await
1256 .expect("body");
1257 (status, String::from_utf8_lossy(&bytes).into_owned())
1258 }
1259
1260 #[tokio::test]
1261 async fn ready_reports_ok_when_the_store_answers() {
1262 let (status, body) = read_response(handle_ready(State(state_with(None))).await).await;
1263 assert_eq!(status, StatusCode::OK);
1264 assert!(body.contains("\"ready\""), "unexpected body: {body}");
1265 }
1266
1267 #[tokio::test]
1268 async fn ready_reports_503_when_the_store_is_unreachable() {
1269 let (status, body) =
1270 read_response(handle_ready(State(state_with(Some(UnreachableStore)))).await).await;
1271
1272 assert_eq!(
1273 status,
1274 StatusCode::SERVICE_UNAVAILABLE,
1275 "an unreachable store must drain traffic from this replica"
1276 );
1277 assert!(body.contains("not_ready"), "unexpected body: {body}");
1278 assert!(body.contains("internal_error"), "unexpected body: {body}");
1281 assert!(
1282 !body.contains("connection refused"),
1283 "the store's message must not be echoed to an unauthenticated probe: {body}"
1284 );
1285 }
1286
1287 #[tokio::test]
1290 async fn health_stays_ok_when_the_store_is_unreachable() {
1291 let (status, body) = read_response(handle_health().await).await;
1292
1293 assert_eq!(
1294 status,
1295 StatusCode::OK,
1296 "liveness must not depend on a downstream"
1297 );
1298 assert!(body.contains("\"ok\""), "unexpected body: {body}");
1299 }
1300}