1use std::collections::HashMap;
78use std::convert::Infallible;
79use std::sync::Arc;
80
81use axum::body::Body;
82use axum::extract::{Path, Query, State};
83use axum::response::IntoResponse;
84use axum::routing::{get, post};
85use axum::Router;
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 a2a_error_to_response(err: &dyn std::fmt::Display, status: u16) -> axum::response::Response {
245 let body = serde_json::json!({ "error": err.to_string() });
246 (
247 axum::http::StatusCode::from_u16(status)
248 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
249 axum::Json(body),
250 )
251 .into_response()
252}
253
254fn server_error_status(err: &crate::error::ServerError) -> u16 {
269 use crate::error::ServerError;
270
271 match err {
272 ServerError::PayloadTooLarge(_) => 413,
273 ServerError::Overloaded(_) => 503,
274 other => other.to_a2a_error().code.http_status(),
275 }
276}
277
278fn handler_error_to_response(err: &crate::error::ServerError) -> axum::response::Response {
279 a2a_error_to_response(err, server_error_status(err))
280}
281
282fn hyper_sse_to_axum(
287 resp: hyper::Response<http_body_util::combinators::BoxBody<Bytes, Infallible>>,
288) -> axum::response::Response {
289 let (parts, body) = resp.into_parts();
290 let axum_body = Body::new(body);
291 axum::response::Response::from_parts(parts, axum_body)
292}
293
294async fn handle_tasks_catchall(
307 State(state): State<A2aState>,
308 method: axum::http::Method,
309 Path(rest): Path<String>,
310 headers: axum::http::HeaderMap,
311 TimedBody(body): TimedBody,
312) -> axum::response::Response {
313 let hdrs = extract_headers(&headers);
314 let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
315
316 match (method.as_str(), segments.as_slice()) {
317 ("GET", [id]) if !id.contains(':') => handle_get_task_inner(&state, id, &hdrs).await,
319
320 ("POST", [id_action]) if id_action.ends_with(":cancel") => {
322 let id = &id_action[..id_action.len() - ":cancel".len()];
323 handle_cancel_task_inner(&state, id, &hdrs).await
324 }
325
326 ("GET" | "POST", [id_action]) if id_action.ends_with(":subscribe") => {
328 let id = &id_action[..id_action.len() - ":subscribe".len()];
329 handle_subscribe_inner(&state, id, &hdrs).await
330 }
331
332 ("POST", [task_id, "pushNotificationConfigs"]) => {
334 handle_create_push_config_inner(&state, task_id, &hdrs, body).await
335 }
336
337 ("GET", [task_id, "pushNotificationConfigs"]) => {
339 handle_list_push_configs_inner(&state, task_id, &hdrs).await
340 }
341
342 ("GET", [task_id, "pushNotificationConfigs", config_id]) => {
344 handle_get_push_config_inner(&state, task_id, config_id, &hdrs).await
345 }
346
347 ("DELETE", [task_id, "pushNotificationConfigs", config_id]) => {
349 handle_delete_push_config_inner(&state, task_id, config_id, &hdrs).await
350 }
351
352 _ => a2a_error_to_response(&"not found", 404),
353 }
354}
355
356async fn handle_send_message(
359 State(state): State<A2aState>,
360 headers: axum::http::HeaderMap,
361 TimedBody(body): TimedBody,
362) -> axum::response::Response {
363 handle_send_inner(&state, false, &headers, body).await
364}
365
366async fn handle_stream_message(
367 State(state): State<A2aState>,
368 headers: axum::http::HeaderMap,
369 TimedBody(body): TimedBody,
370) -> axum::response::Response {
371 handle_send_inner(&state, true, &headers, body).await
372}
373
374async fn handle_list_tasks(
375 State(state): State<A2aState>,
376 Query(query): Query<HashMap<String, String>>,
377 headers: axum::http::HeaderMap,
378) -> axum::response::Response {
379 let hdrs = extract_headers(&headers);
380 let params = a2a_protocol_types::params::ListTasksParams {
381 tenant: None,
382 context_id: query.get("contextId").cloned(),
383 status: query
384 .get("status")
385 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.clone())).ok()),
386 page_size: query.get("pageSize").and_then(|v| v.parse().ok()),
387 page_token: query.get("pageToken").cloned(),
388 status_timestamp_after: query.get("statusTimestampAfter").cloned(),
389 include_artifacts: query.get("includeArtifacts").and_then(|v| v.parse().ok()),
390 history_length: query.get("historyLength").and_then(|v| v.parse().ok()),
391 };
392 match state.handler.on_list_tasks(params, Some(&hdrs)).await {
393 Ok(result) => axum::Json(result).into_response(),
394 Err(e) => handler_error_to_response(&e),
395 }
396}
397
398async fn handle_extended_card(
399 State(state): State<A2aState>,
400 headers: axum::http::HeaderMap,
401) -> axum::response::Response {
402 let hdrs = extract_headers(&headers);
403 match state.handler.on_get_extended_agent_card(Some(&hdrs)).await {
404 Ok(card) => axum::Json(card).into_response(),
405 Err(e) => handler_error_to_response(&e),
406 }
407}
408
409async fn handle_agent_card(State(state): State<A2aState>) -> axum::response::Response {
410 state.handler.agent_card.as_ref().map_or_else(
411 || a2a_error_to_response(&"agent card not configured", 404),
412 |card| axum::Json(card).into_response(),
413 )
414}
415
416async fn handle_health() -> axum::response::Response {
423 axum::Json(serde_json::json!({"status": "ok"})).into_response()
424}
425
426async fn handle_ready(State(state): State<A2aState>) -> axum::response::Response {
444 match state.handler.task_store_health().await {
445 Ok(()) => axum::Json(serde_json::json!({"status": "ready"})).into_response(),
446 Err(e) => (
447 axum::http::StatusCode::SERVICE_UNAVAILABLE,
448 axum::Json(serde_json::json!({
449 "status": "not_ready",
450 "reason": e.metric_label(),
451 })),
452 )
453 .into_response(),
454 }
455}
456
457async fn handle_send_inner(
460 state: &A2aState,
461 streaming: bool,
462 headers: &axum::http::HeaderMap,
463 body: Bytes,
464) -> axum::response::Response {
465 let hdrs = extract_headers(headers);
466 let params: a2a_protocol_types::params::MessageSendParams = match serde_json::from_slice(&body)
467 {
468 Ok(p) => p,
469 Err(e) => return a2a_error_to_response(&e, 400),
470 };
471 match state
472 .handler
473 .on_send_message(params, streaming, Some(&hdrs))
474 .await
475 {
476 Ok(SendMessageResult::Response(resp)) => axum::Json(resp).into_response(),
477 Ok(SendMessageResult::Stream(reader)) => hyper_sse_to_axum(build_sse_response(
478 reader,
479 Some(state.config.sse_keep_alive_interval),
480 Some(state.config.sse_channel_capacity),
481 None, )),
483 Err(e) => handler_error_to_response(&e),
484 }
485}
486
487async fn handle_get_task_inner(
488 state: &A2aState,
489 id: &str,
490 hdrs: &HashMap<String, String>,
491) -> axum::response::Response {
492 let params = a2a_protocol_types::params::TaskQueryParams {
493 tenant: None,
494 id: id.to_owned(),
495 history_length: None,
496 };
497 match state.handler.on_get_task(params, Some(hdrs)).await {
498 Ok(task) => axum::Json(task).into_response(),
499 Err(e) => handler_error_to_response(&e),
500 }
501}
502
503async fn handle_cancel_task_inner(
504 state: &A2aState,
505 id: &str,
506 hdrs: &HashMap<String, String>,
507) -> axum::response::Response {
508 let params = a2a_protocol_types::params::CancelTaskParams {
509 tenant: None,
510 id: id.to_owned(),
511 metadata: None,
512 };
513 match state.handler.on_cancel_task(params, Some(hdrs)).await {
514 Ok(task) => axum::Json(task).into_response(),
515 Err(e) => handler_error_to_response(&e),
516 }
517}
518
519async fn handle_subscribe_inner(
520 state: &A2aState,
521 id: &str,
522 hdrs: &HashMap<String, String>,
523) -> axum::response::Response {
524 let params = a2a_protocol_types::params::TaskIdParams {
525 tenant: None,
526 id: id.to_owned(),
527 };
528 match state.handler.on_resubscribe(params, Some(hdrs)).await {
529 Ok(reader) => hyper_sse_to_axum(build_sse_response(
530 reader,
531 Some(state.config.sse_keep_alive_interval),
532 Some(state.config.sse_channel_capacity),
533 None, )),
535 Err(e) => handler_error_to_response(&e),
536 }
537}
538
539async fn handle_create_push_config_inner(
540 state: &A2aState,
541 task_id: &str,
542 hdrs: &HashMap<String, String>,
543 body: Bytes,
544) -> axum::response::Response {
545 let mut value: serde_json::Value = match serde_json::from_slice(&body) {
546 Ok(v) => v,
547 Err(e) => return a2a_error_to_response(&e, 400),
548 };
549 if let Some(obj) = value.as_object_mut() {
550 obj.entry("taskId")
551 .or_insert_with(|| serde_json::Value::String(task_id.to_owned()));
552 }
553 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
554 match serde_json::from_value(value) {
555 Ok(c) => c,
556 Err(e) => return a2a_error_to_response(&e, 400),
557 };
558 match state.handler.on_set_push_config(config, Some(hdrs)).await {
559 Ok(result) => axum::Json(result).into_response(),
560 Err(e) => handler_error_to_response(&e),
561 }
562}
563
564async fn handle_get_push_config_inner(
565 state: &A2aState,
566 task_id: &str,
567 config_id: &str,
568 hdrs: &HashMap<String, String>,
569) -> axum::response::Response {
570 let params = a2a_protocol_types::params::GetPushConfigParams {
571 tenant: None,
572 task_id: task_id.to_owned(),
573 id: config_id.to_owned(),
574 };
575 match state.handler.on_get_push_config(params, Some(hdrs)).await {
576 Ok(config) => axum::Json(config).into_response(),
577 Err(e) => handler_error_to_response(&e),
578 }
579}
580
581async fn handle_list_push_configs_inner(
582 state: &A2aState,
583 task_id: &str,
584 hdrs: &HashMap<String, String>,
585) -> axum::response::Response {
586 match state
587 .handler
588 .on_list_push_configs(task_id, None, Some(hdrs))
589 .await
590 {
591 Ok(configs) => {
592 let resp = a2a_protocol_types::responses::ListPushConfigsResponse {
593 configs,
594 next_page_token: None,
595 };
596 axum::Json(resp).into_response()
597 }
598 Err(e) => handler_error_to_response(&e),
599 }
600}
601
602async fn handle_delete_push_config_inner(
603 state: &A2aState,
604 task_id: &str,
605 config_id: &str,
606 hdrs: &HashMap<String, String>,
607) -> axum::response::Response {
608 let params = a2a_protocol_types::params::DeletePushConfigParams {
609 tenant: None,
610 task_id: task_id.to_owned(),
611 id: config_id.to_owned(),
612 };
613 match state
614 .handler
615 .on_delete_push_config(params, Some(hdrs))
616 .await
617 {
618 Ok(()) => axum::Json(serde_json::json!({})).into_response(),
619 Err(e) => handler_error_to_response(&e),
620 }
621}
622
623#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn catchall_state() -> A2aState {
645 let handler = Arc::new(
646 crate::builder::RequestHandlerBuilder::new({
647 struct Noop;
648 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
649 Noop
650 })
651 .build()
652 .unwrap(),
653 );
654 A2aState {
655 handler,
656 config: Arc::new(super::super::DispatchConfig::default()),
657 }
658 }
659
660 async fn seed_task(state: &A2aState, id: &str) {
661 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
662 let task = Task {
663 id: TaskId::new(id),
664 context_id: ContextId::new("ctx"),
665 status: TaskStatus::new(TaskState::Submitted),
666 history: None,
667 artifacts: None,
668 metadata: None,
669 };
670 state.handler.task_store.save(&task).await.unwrap();
671 }
672
673 async fn dispatch_tail(state: &A2aState, method: &str, rest: &str) -> axum::http::StatusCode {
674 let response = handle_tasks_catchall(
675 State(state.clone()),
676 axum::http::Method::from_bytes(method.as_bytes()).unwrap(),
677 Path(rest.to_owned()),
678 axum::http::HeaderMap::new(),
679 TimedBody(Bytes::new()),
680 )
681 .await;
682 response.status()
683 }
684
685 #[tokio::test]
689 async fn catchall_routes_cancel_and_strips_the_suffix() {
690 let state = catchall_state();
691 seed_task(&state, "task-abc").await;
692
693 assert_eq!(
695 dispatch_tail(&state, "POST", "task-abc:cancel").await,
696 axum::http::StatusCode::OK,
697 "POST /tasks/task-abc:cancel must cancel task-abc"
698 );
699 assert_eq!(
702 dispatch_tail(&state, "POST", "missing-xyz:cancel").await,
703 axum::http::StatusCode::NOT_FOUND,
704 "an unknown task id must 404 rather than resolve to a truncated one"
705 );
706 }
707
708 #[tokio::test]
712 async fn catchall_routes_subscribe_and_strips_the_suffix() {
713 let state = catchall_state();
714 seed_task(&state, "task-abc").await;
715
716 assert_eq!(
732 dispatch_tail(&state, "GET", "task-abc:subscribe").await,
733 axum::http::StatusCode::OK,
734 "GET /tasks/task-abc:subscribe must subscribe to task-abc"
735 );
736 assert_eq!(
737 dispatch_tail(&state, "GET", "missing-xyz:subscribe").await,
738 axum::http::StatusCode::NOT_FOUND,
739 "subscribe on an unknown id must still 404"
740 );
741 }
742
743 #[tokio::test]
752 async fn catchall_post_without_a_colon_action_falls_through() {
753 let state = catchall_state();
754 seed_task(&state, "tid").await;
755
756 assert_eq!(
758 dispatch_tail(&state, "POST", "tidZZZZZZZ").await,
759 axum::http::StatusCode::NOT_FOUND,
760 "a POST with no colon action must not be routed to CancelTask"
761 );
762 assert_eq!(
764 dispatch_tail(&state, "POST", "tidZZZZZZZZZZ").await,
765 axum::http::StatusCode::NOT_FOUND,
766 "a POST with no colon action must not be routed to SubscribeToTask"
767 );
768 }
769
770 #[tokio::test]
774 async fn catchall_plain_get_does_not_swallow_colon_actions() {
775 let state = catchall_state();
776 seed_task(&state, "task-abc").await;
777
778 assert_eq!(
779 dispatch_tail(&state, "GET", "task-abc").await,
780 axum::http::StatusCode::OK,
781 "GET /tasks/task-abc must fetch the task"
782 );
783 assert_eq!(
787 dispatch_tail(&state, "POST", "task-abc:cancel").await,
788 axum::http::StatusCode::OK,
789 "a colon action must not be captured by the plain `GetTask` arm"
790 );
791 }
792
793 #[test]
794 fn extract_headers_lowercases_names() {
795 let mut map = axum::http::HeaderMap::new();
796 map.insert("X-Request-ID", "abc".parse().unwrap());
797 map.insert("content-type", "application/json".parse().unwrap());
798
799 let result = extract_headers(&map);
800 assert_eq!(result.get("x-request-id").unwrap(), "abc");
801 assert_eq!(result.get("content-type").unwrap(), "application/json");
802 }
803
804 #[test]
805 fn extract_headers_skips_non_utf8_values() {
806 let mut map = axum::http::HeaderMap::new();
807 map.insert("good", "valid".parse().unwrap());
808 let result = extract_headers(&map);
810 assert_eq!(result.len(), 1);
811 assert_eq!(result.get("good").unwrap(), "valid");
812 }
813
814 #[test]
815 fn extract_headers_empty_map() {
816 let map = axum::http::HeaderMap::new();
817 let result = extract_headers(&map);
818 assert!(result.is_empty());
819 }
820
821 #[test]
822 fn a2a_state_is_clone() {
823 fn assert_clone<T: Clone>() {}
824 assert_clone::<A2aState>();
825 }
826
827 #[test]
828 fn server_error_status_task_not_found() {
829 use crate::error::ServerError;
830 assert_eq!(
831 server_error_status(&ServerError::TaskNotFound("t".into())),
832 404
833 );
834 }
835
836 #[test]
837 fn server_error_status_method_not_found() {
838 use crate::error::ServerError;
839 assert_eq!(
840 server_error_status(&ServerError::MethodNotFound("m".into())),
841 404
842 );
843 }
844
845 #[test]
846 fn server_error_status_invalid_params() {
847 use crate::error::ServerError;
848 assert_eq!(
849 server_error_status(&ServerError::InvalidParams("p".into())),
850 400
851 );
852 }
853
854 #[test]
855 fn server_error_status_serialization() {
856 use crate::error::ServerError;
857 let err = ServerError::Serialization(serde_json::from_str::<String>("bad").unwrap_err());
858 assert_eq!(server_error_status(&err), 400);
859 }
860
861 #[test]
862 fn server_error_status_task_not_cancelable() {
863 use crate::error::ServerError;
864 assert_eq!(
865 server_error_status(&ServerError::TaskNotCancelable("t".into())),
866 400
867 );
868 }
869
870 #[test]
875 fn server_error_status_agrees_with_the_shared_5_4_table() {
876 use crate::error::ServerError;
877 let cases = [
878 ServerError::TaskNotFound("t".into()),
879 ServerError::TaskNotCancelable("t".into()),
880 ServerError::PushNotSupported,
881 ServerError::UnsupportedOperation("op".into()),
882 ServerError::InvalidParams("p".into()),
883 ServerError::MethodNotFound("m".into()),
884 ];
885 for err in cases {
886 assert_eq!(
887 server_error_status(&err),
888 err.to_a2a_error().code.http_status(),
889 "adapter disagrees with ErrorCode::http_status for {err:?}"
890 );
891 }
892 }
893
894 #[test]
895 fn server_error_status_invalid_state_transition() {
896 use crate::error::ServerError;
897 let err = ServerError::InvalidStateTransition {
898 task_id: "t".into(),
899 from: a2a_protocol_types::task::TaskState::Working,
900 to: a2a_protocol_types::task::TaskState::Submitted,
901 };
902 assert_eq!(server_error_status(&err), 400);
905 }
906
907 #[test]
908 fn server_error_status_push_not_supported() {
909 use crate::error::ServerError;
910 assert_eq!(server_error_status(&ServerError::PushNotSupported), 400);
914 }
915
916 #[test]
917 fn server_error_status_payload_too_large() {
918 use crate::error::ServerError;
919 assert_eq!(
920 server_error_status(&ServerError::PayloadTooLarge("big".into())),
921 413
922 );
923 }
924
925 #[test]
926 fn server_error_status_overloaded() {
927 use crate::error::ServerError;
928 assert_eq!(
931 server_error_status(&ServerError::Overloaded("at capacity".into())),
932 503
933 );
934 }
935
936 #[test]
937 fn server_error_status_internal() {
938 use crate::error::ServerError;
939 assert_eq!(
940 server_error_status(&ServerError::Internal("oops".into())),
941 500
942 );
943 }
944
945 #[test]
946 fn a2a_error_to_response_returns_correct_status() {
947 let resp = a2a_error_to_response(&"test error", 400);
948 assert_eq!(resp.status().as_u16(), 400);
949 }
950
951 #[test]
952 fn a2a_error_to_response_returns_json_body() {
953 let resp = a2a_error_to_response(&"not found", 404);
954 assert_eq!(resp.status().as_u16(), 404);
955 }
956
957 #[test]
958 fn a2a_error_to_response_invalid_status_falls_back_to_500() {
959 let resp = a2a_error_to_response(&"bad status", 1000);
961 assert_eq!(resp.status().as_u16(), 500);
962 }
963
964 #[test]
965 fn handler_error_to_response_maps_correctly() {
966 use crate::error::ServerError;
967 let resp = handler_error_to_response(&ServerError::TaskNotFound("t1".into()));
968 assert_eq!(resp.status().as_u16(), 404);
969
970 let resp = handler_error_to_response(&ServerError::InvalidParams("bad".into()));
971 assert_eq!(resp.status().as_u16(), 400);
972
973 let resp = handler_error_to_response(&ServerError::Internal("oops".into()));
974 assert_eq!(resp.status().as_u16(), 500);
975 }
976
977 #[test]
978 fn a2a_router_new_creates_with_defaults() {
979 use crate::builder::RequestHandlerBuilder;
981
982 struct NoopExecutor;
983 impl crate::executor::AgentExecutor for NoopExecutor {
984 fn execute<'a>(
985 &'a self,
986 _ctx: &'a crate::request_context::RequestContext,
987 _queue: &'a dyn crate::streaming::EventQueueWriter,
988 ) -> std::pin::Pin<
989 Box<
990 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
991 + Send
992 + 'a,
993 >,
994 > {
995 Box::pin(async { Ok(()) })
996 }
997 }
998
999 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
1000 let router = A2aRouter::new(handler);
1001 let _axum_router = router.into_router();
1003 }
1004
1005 #[test]
1006 fn a2a_router_with_config() {
1007 use crate::builder::RequestHandlerBuilder;
1008
1009 struct NoopExecutor;
1010 impl crate::executor::AgentExecutor for NoopExecutor {
1011 fn execute<'a>(
1012 &'a self,
1013 _ctx: &'a crate::request_context::RequestContext,
1014 _queue: &'a dyn crate::streaming::EventQueueWriter,
1015 ) -> std::pin::Pin<
1016 Box<
1017 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
1018 + Send
1019 + 'a,
1020 >,
1021 > {
1022 Box::pin(async { Ok(()) })
1023 }
1024 }
1025
1026 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
1027 let config =
1028 super::super::DispatchConfig::default().with_max_request_body_size(8 * 1024 * 1024);
1029 let router = A2aRouter::with_config(handler, config);
1030 let _axum_router = router.into_router();
1031 }
1032}
1033
1034#[cfg(test)]
1043mod readiness_tests {
1044 use std::future::Future;
1045 use std::pin::Pin;
1046
1047 use a2a_protocol_types::error::{A2aError, A2aResult};
1048 use a2a_protocol_types::params::ListTasksParams;
1049 use a2a_protocol_types::responses::TaskListResponse;
1050 use a2a_protocol_types::task::{Task, TaskId};
1051 use axum::http::StatusCode;
1052
1053 use crate::store::TaskStore;
1054
1055 use super::*;
1056
1057 struct UnreachableStore;
1059
1060 impl TaskStore for UnreachableStore {
1061 fn save<'a>(
1062 &'a self,
1063 _task: &'a Task,
1064 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1065 Box::pin(async { Err(A2aError::internal("connection refused")) })
1066 }
1067 fn get<'a>(
1068 &'a self,
1069 _id: &'a TaskId,
1070 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
1071 Box::pin(async { Err(A2aError::internal("connection refused")) })
1072 }
1073 fn list<'a>(
1074 &'a self,
1075 _p: &'a ListTasksParams,
1076 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
1077 Box::pin(async { Err(A2aError::internal("connection refused")) })
1078 }
1079 fn insert_if_absent<'a>(
1080 &'a self,
1081 _task: &'a Task,
1082 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
1083 Box::pin(async { Err(A2aError::internal("connection refused")) })
1084 }
1085 fn delete<'a>(
1086 &'a self,
1087 _id: &'a TaskId,
1088 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1089 Box::pin(async { Err(A2aError::internal("connection refused")) })
1090 }
1091 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
1092 Box::pin(async { Err(A2aError::internal("connection refused")) })
1093 }
1094 }
1095
1096 fn state_with(store: Option<UnreachableStore>) -> A2aState {
1097 struct Noop;
1098 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
1099
1100 let builder = crate::builder::RequestHandlerBuilder::new(Noop);
1101 let builder = match store {
1102 Some(s) => builder.with_task_store(s),
1103 None => builder,
1104 };
1105 A2aState {
1106 handler: Arc::new(builder.build().expect("build handler")),
1107 config: Arc::new(super::super::DispatchConfig::default()),
1108 }
1109 }
1110
1111 async fn read_response(resp: axum::response::Response) -> (StatusCode, String) {
1115 let status = resp.status();
1116 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1117 .await
1118 .expect("body");
1119 (status, String::from_utf8_lossy(&bytes).into_owned())
1120 }
1121
1122 #[tokio::test]
1123 async fn ready_reports_ok_when_the_store_answers() {
1124 let (status, body) = read_response(handle_ready(State(state_with(None))).await).await;
1125 assert_eq!(status, StatusCode::OK);
1126 assert!(body.contains("\"ready\""), "unexpected body: {body}");
1127 }
1128
1129 #[tokio::test]
1130 async fn ready_reports_503_when_the_store_is_unreachable() {
1131 let (status, body) =
1132 read_response(handle_ready(State(state_with(Some(UnreachableStore)))).await).await;
1133
1134 assert_eq!(
1135 status,
1136 StatusCode::SERVICE_UNAVAILABLE,
1137 "an unreachable store must drain traffic from this replica"
1138 );
1139 assert!(body.contains("not_ready"), "unexpected body: {body}");
1140 assert!(body.contains("internal_error"), "unexpected body: {body}");
1143 assert!(
1144 !body.contains("connection refused"),
1145 "the store's message must not be echoed to an unauthenticated probe: {body}"
1146 );
1147 }
1148
1149 #[tokio::test]
1152 async fn health_stays_ok_when_the_store_is_unreachable() {
1153 let (status, body) = read_response(handle_health().await).await;
1154
1155 assert_eq!(
1156 status,
1157 StatusCode::OK,
1158 "liveness must not depend on a downstream"
1159 );
1160 assert!(body.contains("\"ok\""), "unexpected body: {body}");
1161 }
1162}