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
254const fn server_error_status(err: &crate::error::ServerError) -> u16 {
255 use crate::error::ServerError;
256
257 match err {
258 ServerError::TaskNotFound(_) | ServerError::MethodNotFound(_) => 404,
259 ServerError::InvalidParams(_) | ServerError::Serialization(_) => 400,
260 ServerError::InvalidStateTransition { .. } | ServerError::TaskNotCancelable(_) => 409,
261 ServerError::PushNotSupported => 501,
262 ServerError::PayloadTooLarge(_) => 413,
263 ServerError::Overloaded(_) => 503,
266 _ => 500,
267 }
268}
269
270fn handler_error_to_response(err: &crate::error::ServerError) -> axum::response::Response {
271 a2a_error_to_response(err, server_error_status(err))
272}
273
274fn hyper_sse_to_axum(
279 resp: hyper::Response<http_body_util::combinators::BoxBody<Bytes, Infallible>>,
280) -> axum::response::Response {
281 let (parts, body) = resp.into_parts();
282 let axum_body = Body::new(body);
283 axum::response::Response::from_parts(parts, axum_body)
284}
285
286async fn handle_tasks_catchall(
299 State(state): State<A2aState>,
300 method: axum::http::Method,
301 Path(rest): Path<String>,
302 headers: axum::http::HeaderMap,
303 TimedBody(body): TimedBody,
304) -> axum::response::Response {
305 let hdrs = extract_headers(&headers);
306 let segments: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
307
308 match (method.as_str(), segments.as_slice()) {
309 ("GET", [id]) if !id.contains(':') => handle_get_task_inner(&state, id, &hdrs).await,
311
312 ("POST", [id_action]) if id_action.ends_with(":cancel") => {
314 let id = &id_action[..id_action.len() - ":cancel".len()];
315 handle_cancel_task_inner(&state, id, &hdrs).await
316 }
317
318 ("GET" | "POST", [id_action]) if id_action.ends_with(":subscribe") => {
320 let id = &id_action[..id_action.len() - ":subscribe".len()];
321 handle_subscribe_inner(&state, id, &hdrs).await
322 }
323
324 ("POST", [task_id, "pushNotificationConfigs"]) => {
326 handle_create_push_config_inner(&state, task_id, &hdrs, body).await
327 }
328
329 ("GET", [task_id, "pushNotificationConfigs"]) => {
331 handle_list_push_configs_inner(&state, task_id, &hdrs).await
332 }
333
334 ("GET", [task_id, "pushNotificationConfigs", config_id]) => {
336 handle_get_push_config_inner(&state, task_id, config_id, &hdrs).await
337 }
338
339 ("DELETE", [task_id, "pushNotificationConfigs", config_id]) => {
341 handle_delete_push_config_inner(&state, task_id, config_id, &hdrs).await
342 }
343
344 _ => a2a_error_to_response(&"not found", 404),
345 }
346}
347
348async fn handle_send_message(
351 State(state): State<A2aState>,
352 headers: axum::http::HeaderMap,
353 TimedBody(body): TimedBody,
354) -> axum::response::Response {
355 handle_send_inner(&state, false, &headers, body).await
356}
357
358async fn handle_stream_message(
359 State(state): State<A2aState>,
360 headers: axum::http::HeaderMap,
361 TimedBody(body): TimedBody,
362) -> axum::response::Response {
363 handle_send_inner(&state, true, &headers, body).await
364}
365
366async fn handle_list_tasks(
367 State(state): State<A2aState>,
368 Query(query): Query<HashMap<String, String>>,
369 headers: axum::http::HeaderMap,
370) -> axum::response::Response {
371 let hdrs = extract_headers(&headers);
372 let params = a2a_protocol_types::params::ListTasksParams {
373 tenant: None,
374 context_id: query.get("contextId").cloned(),
375 status: query
376 .get("status")
377 .and_then(|s| serde_json::from_value(serde_json::Value::String(s.clone())).ok()),
378 page_size: query.get("pageSize").and_then(|v| v.parse().ok()),
379 page_token: query.get("pageToken").cloned(),
380 status_timestamp_after: query.get("statusTimestampAfter").cloned(),
381 include_artifacts: query.get("includeArtifacts").and_then(|v| v.parse().ok()),
382 history_length: query.get("historyLength").and_then(|v| v.parse().ok()),
383 };
384 match state.handler.on_list_tasks(params, Some(&hdrs)).await {
385 Ok(result) => axum::Json(result).into_response(),
386 Err(e) => handler_error_to_response(&e),
387 }
388}
389
390async fn handle_extended_card(
391 State(state): State<A2aState>,
392 headers: axum::http::HeaderMap,
393) -> axum::response::Response {
394 let hdrs = extract_headers(&headers);
395 match state.handler.on_get_extended_agent_card(Some(&hdrs)).await {
396 Ok(card) => axum::Json(card).into_response(),
397 Err(e) => handler_error_to_response(&e),
398 }
399}
400
401async fn handle_agent_card(State(state): State<A2aState>) -> axum::response::Response {
402 state.handler.agent_card.as_ref().map_or_else(
403 || a2a_error_to_response(&"agent card not configured", 404),
404 |card| axum::Json(card).into_response(),
405 )
406}
407
408async fn handle_health() -> axum::response::Response {
415 axum::Json(serde_json::json!({"status": "ok"})).into_response()
416}
417
418async fn handle_ready(State(state): State<A2aState>) -> axum::response::Response {
436 match state.handler.task_store_health().await {
437 Ok(()) => axum::Json(serde_json::json!({"status": "ready"})).into_response(),
438 Err(e) => (
439 axum::http::StatusCode::SERVICE_UNAVAILABLE,
440 axum::Json(serde_json::json!({
441 "status": "not_ready",
442 "reason": e.metric_label(),
443 })),
444 )
445 .into_response(),
446 }
447}
448
449async fn handle_send_inner(
452 state: &A2aState,
453 streaming: bool,
454 headers: &axum::http::HeaderMap,
455 body: Bytes,
456) -> axum::response::Response {
457 let hdrs = extract_headers(headers);
458 let params: a2a_protocol_types::params::MessageSendParams = match serde_json::from_slice(&body)
459 {
460 Ok(p) => p,
461 Err(e) => return a2a_error_to_response(&e, 400),
462 };
463 match state
464 .handler
465 .on_send_message(params, streaming, Some(&hdrs))
466 .await
467 {
468 Ok(SendMessageResult::Response(resp)) => axum::Json(resp).into_response(),
469 Ok(SendMessageResult::Stream(reader)) => hyper_sse_to_axum(build_sse_response(
470 reader,
471 Some(state.config.sse_keep_alive_interval),
472 Some(state.config.sse_channel_capacity),
473 None, )),
475 Err(e) => handler_error_to_response(&e),
476 }
477}
478
479async fn handle_get_task_inner(
480 state: &A2aState,
481 id: &str,
482 hdrs: &HashMap<String, String>,
483) -> axum::response::Response {
484 let params = a2a_protocol_types::params::TaskQueryParams {
485 tenant: None,
486 id: id.to_owned(),
487 history_length: None,
488 };
489 match state.handler.on_get_task(params, Some(hdrs)).await {
490 Ok(task) => axum::Json(task).into_response(),
491 Err(e) => handler_error_to_response(&e),
492 }
493}
494
495async fn handle_cancel_task_inner(
496 state: &A2aState,
497 id: &str,
498 hdrs: &HashMap<String, String>,
499) -> axum::response::Response {
500 let params = a2a_protocol_types::params::CancelTaskParams {
501 tenant: None,
502 id: id.to_owned(),
503 metadata: None,
504 };
505 match state.handler.on_cancel_task(params, Some(hdrs)).await {
506 Ok(task) => axum::Json(task).into_response(),
507 Err(e) => handler_error_to_response(&e),
508 }
509}
510
511async fn handle_subscribe_inner(
512 state: &A2aState,
513 id: &str,
514 hdrs: &HashMap<String, String>,
515) -> axum::response::Response {
516 let params = a2a_protocol_types::params::TaskIdParams {
517 tenant: None,
518 id: id.to_owned(),
519 };
520 match state.handler.on_resubscribe(params, Some(hdrs)).await {
521 Ok(reader) => hyper_sse_to_axum(build_sse_response(
522 reader,
523 Some(state.config.sse_keep_alive_interval),
524 Some(state.config.sse_channel_capacity),
525 None, )),
527 Err(e) => handler_error_to_response(&e),
528 }
529}
530
531async fn handle_create_push_config_inner(
532 state: &A2aState,
533 task_id: &str,
534 hdrs: &HashMap<String, String>,
535 body: Bytes,
536) -> axum::response::Response {
537 let mut value: serde_json::Value = match serde_json::from_slice(&body) {
538 Ok(v) => v,
539 Err(e) => return a2a_error_to_response(&e, 400),
540 };
541 if let Some(obj) = value.as_object_mut() {
542 obj.entry("taskId")
543 .or_insert_with(|| serde_json::Value::String(task_id.to_owned()));
544 }
545 let config: a2a_protocol_types::push::TaskPushNotificationConfig =
546 match serde_json::from_value(value) {
547 Ok(c) => c,
548 Err(e) => return a2a_error_to_response(&e, 400),
549 };
550 match state.handler.on_set_push_config(config, Some(hdrs)).await {
551 Ok(result) => axum::Json(result).into_response(),
552 Err(e) => handler_error_to_response(&e),
553 }
554}
555
556async fn handle_get_push_config_inner(
557 state: &A2aState,
558 task_id: &str,
559 config_id: &str,
560 hdrs: &HashMap<String, String>,
561) -> axum::response::Response {
562 let params = a2a_protocol_types::params::GetPushConfigParams {
563 tenant: None,
564 task_id: task_id.to_owned(),
565 id: config_id.to_owned(),
566 };
567 match state.handler.on_get_push_config(params, Some(hdrs)).await {
568 Ok(config) => axum::Json(config).into_response(),
569 Err(e) => handler_error_to_response(&e),
570 }
571}
572
573async fn handle_list_push_configs_inner(
574 state: &A2aState,
575 task_id: &str,
576 hdrs: &HashMap<String, String>,
577) -> axum::response::Response {
578 match state
579 .handler
580 .on_list_push_configs(task_id, None, Some(hdrs))
581 .await
582 {
583 Ok(configs) => {
584 let resp = a2a_protocol_types::responses::ListPushConfigsResponse {
585 configs,
586 next_page_token: None,
587 };
588 axum::Json(resp).into_response()
589 }
590 Err(e) => handler_error_to_response(&e),
591 }
592}
593
594async fn handle_delete_push_config_inner(
595 state: &A2aState,
596 task_id: &str,
597 config_id: &str,
598 hdrs: &HashMap<String, String>,
599) -> axum::response::Response {
600 let params = a2a_protocol_types::params::DeletePushConfigParams {
601 tenant: None,
602 task_id: task_id.to_owned(),
603 id: config_id.to_owned(),
604 };
605 match state
606 .handler
607 .on_delete_push_config(params, Some(hdrs))
608 .await
609 {
610 Ok(()) => axum::Json(serde_json::json!({})).into_response(),
611 Err(e) => handler_error_to_response(&e),
612 }
613}
614
615#[cfg(test)]
618mod tests {
619 use super::*;
620
621 fn catchall_state() -> A2aState {
637 let handler = Arc::new(
638 crate::builder::RequestHandlerBuilder::new({
639 struct Noop;
640 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
641 Noop
642 })
643 .build()
644 .unwrap(),
645 );
646 A2aState {
647 handler,
648 config: Arc::new(super::super::DispatchConfig::default()),
649 }
650 }
651
652 async fn seed_task(state: &A2aState, id: &str) {
653 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
654 let task = Task {
655 id: TaskId::new(id),
656 context_id: ContextId::new("ctx"),
657 status: TaskStatus::new(TaskState::Submitted),
658 history: None,
659 artifacts: None,
660 metadata: None,
661 };
662 state.handler.task_store.save(&task).await.unwrap();
663 }
664
665 async fn dispatch_tail(state: &A2aState, method: &str, rest: &str) -> axum::http::StatusCode {
666 let response = handle_tasks_catchall(
667 State(state.clone()),
668 axum::http::Method::from_bytes(method.as_bytes()).unwrap(),
669 Path(rest.to_owned()),
670 axum::http::HeaderMap::new(),
671 TimedBody(Bytes::new()),
672 )
673 .await;
674 response.status()
675 }
676
677 #[tokio::test]
681 async fn catchall_routes_cancel_and_strips_the_suffix() {
682 let state = catchall_state();
683 seed_task(&state, "task-abc").await;
684
685 assert_eq!(
687 dispatch_tail(&state, "POST", "task-abc:cancel").await,
688 axum::http::StatusCode::OK,
689 "POST /tasks/task-abc:cancel must cancel task-abc"
690 );
691 assert_eq!(
694 dispatch_tail(&state, "POST", "missing-xyz:cancel").await,
695 axum::http::StatusCode::NOT_FOUND,
696 "an unknown task id must 404 rather than resolve to a truncated one"
697 );
698 }
699
700 #[tokio::test]
704 async fn catchall_routes_subscribe_and_strips_the_suffix() {
705 let state = catchall_state();
706 seed_task(&state, "task-abc").await;
707
708 assert_eq!(
724 dispatch_tail(&state, "GET", "task-abc:subscribe").await,
725 axum::http::StatusCode::OK,
726 "GET /tasks/task-abc:subscribe must subscribe to task-abc"
727 );
728 assert_eq!(
729 dispatch_tail(&state, "GET", "missing-xyz:subscribe").await,
730 axum::http::StatusCode::NOT_FOUND,
731 "subscribe on an unknown id must still 404"
732 );
733 }
734
735 #[tokio::test]
744 async fn catchall_post_without_a_colon_action_falls_through() {
745 let state = catchall_state();
746 seed_task(&state, "tid").await;
747
748 assert_eq!(
750 dispatch_tail(&state, "POST", "tidZZZZZZZ").await,
751 axum::http::StatusCode::NOT_FOUND,
752 "a POST with no colon action must not be routed to CancelTask"
753 );
754 assert_eq!(
756 dispatch_tail(&state, "POST", "tidZZZZZZZZZZ").await,
757 axum::http::StatusCode::NOT_FOUND,
758 "a POST with no colon action must not be routed to SubscribeToTask"
759 );
760 }
761
762 #[tokio::test]
766 async fn catchall_plain_get_does_not_swallow_colon_actions() {
767 let state = catchall_state();
768 seed_task(&state, "task-abc").await;
769
770 assert_eq!(
771 dispatch_tail(&state, "GET", "task-abc").await,
772 axum::http::StatusCode::OK,
773 "GET /tasks/task-abc must fetch the task"
774 );
775 assert_eq!(
779 dispatch_tail(&state, "POST", "task-abc:cancel").await,
780 axum::http::StatusCode::OK,
781 "a colon action must not be captured by the plain `GetTask` arm"
782 );
783 }
784
785 #[test]
786 fn extract_headers_lowercases_names() {
787 let mut map = axum::http::HeaderMap::new();
788 map.insert("X-Request-ID", "abc".parse().unwrap());
789 map.insert("content-type", "application/json".parse().unwrap());
790
791 let result = extract_headers(&map);
792 assert_eq!(result.get("x-request-id").unwrap(), "abc");
793 assert_eq!(result.get("content-type").unwrap(), "application/json");
794 }
795
796 #[test]
797 fn extract_headers_skips_non_utf8_values() {
798 let mut map = axum::http::HeaderMap::new();
799 map.insert("good", "valid".parse().unwrap());
800 let result = extract_headers(&map);
802 assert_eq!(result.len(), 1);
803 assert_eq!(result.get("good").unwrap(), "valid");
804 }
805
806 #[test]
807 fn extract_headers_empty_map() {
808 let map = axum::http::HeaderMap::new();
809 let result = extract_headers(&map);
810 assert!(result.is_empty());
811 }
812
813 #[test]
814 fn a2a_state_is_clone() {
815 fn assert_clone<T: Clone>() {}
816 assert_clone::<A2aState>();
817 }
818
819 #[test]
820 fn server_error_status_task_not_found() {
821 use crate::error::ServerError;
822 assert_eq!(
823 server_error_status(&ServerError::TaskNotFound("t".into())),
824 404
825 );
826 }
827
828 #[test]
829 fn server_error_status_method_not_found() {
830 use crate::error::ServerError;
831 assert_eq!(
832 server_error_status(&ServerError::MethodNotFound("m".into())),
833 404
834 );
835 }
836
837 #[test]
838 fn server_error_status_invalid_params() {
839 use crate::error::ServerError;
840 assert_eq!(
841 server_error_status(&ServerError::InvalidParams("p".into())),
842 400
843 );
844 }
845
846 #[test]
847 fn server_error_status_serialization() {
848 use crate::error::ServerError;
849 let err = ServerError::Serialization(serde_json::from_str::<String>("bad").unwrap_err());
850 assert_eq!(server_error_status(&err), 400);
851 }
852
853 #[test]
854 fn server_error_status_task_not_cancelable() {
855 use crate::error::ServerError;
856 assert_eq!(
857 server_error_status(&ServerError::TaskNotCancelable("t".into())),
858 409
859 );
860 }
861
862 #[test]
863 fn server_error_status_invalid_state_transition() {
864 use crate::error::ServerError;
865 let err = ServerError::InvalidStateTransition {
866 task_id: "t".into(),
867 from: a2a_protocol_types::task::TaskState::Working,
868 to: a2a_protocol_types::task::TaskState::Submitted,
869 };
870 assert_eq!(server_error_status(&err), 409);
871 }
872
873 #[test]
874 fn server_error_status_push_not_supported() {
875 use crate::error::ServerError;
876 assert_eq!(server_error_status(&ServerError::PushNotSupported), 501);
877 }
878
879 #[test]
880 fn server_error_status_payload_too_large() {
881 use crate::error::ServerError;
882 assert_eq!(
883 server_error_status(&ServerError::PayloadTooLarge("big".into())),
884 413
885 );
886 }
887
888 #[test]
889 fn server_error_status_overloaded() {
890 use crate::error::ServerError;
891 assert_eq!(
894 server_error_status(&ServerError::Overloaded("at capacity".into())),
895 503
896 );
897 }
898
899 #[test]
900 fn server_error_status_internal() {
901 use crate::error::ServerError;
902 assert_eq!(
903 server_error_status(&ServerError::Internal("oops".into())),
904 500
905 );
906 }
907
908 #[test]
909 fn a2a_error_to_response_returns_correct_status() {
910 let resp = a2a_error_to_response(&"test error", 400);
911 assert_eq!(resp.status().as_u16(), 400);
912 }
913
914 #[test]
915 fn a2a_error_to_response_returns_json_body() {
916 let resp = a2a_error_to_response(&"not found", 404);
917 assert_eq!(resp.status().as_u16(), 404);
918 }
919
920 #[test]
921 fn a2a_error_to_response_invalid_status_falls_back_to_500() {
922 let resp = a2a_error_to_response(&"bad status", 1000);
924 assert_eq!(resp.status().as_u16(), 500);
925 }
926
927 #[test]
928 fn handler_error_to_response_maps_correctly() {
929 use crate::error::ServerError;
930 let resp = handler_error_to_response(&ServerError::TaskNotFound("t1".into()));
931 assert_eq!(resp.status().as_u16(), 404);
932
933 let resp = handler_error_to_response(&ServerError::InvalidParams("bad".into()));
934 assert_eq!(resp.status().as_u16(), 400);
935
936 let resp = handler_error_to_response(&ServerError::Internal("oops".into()));
937 assert_eq!(resp.status().as_u16(), 500);
938 }
939
940 #[test]
941 fn a2a_router_new_creates_with_defaults() {
942 use crate::builder::RequestHandlerBuilder;
944
945 struct NoopExecutor;
946 impl crate::executor::AgentExecutor for NoopExecutor {
947 fn execute<'a>(
948 &'a self,
949 _ctx: &'a crate::request_context::RequestContext,
950 _queue: &'a dyn crate::streaming::EventQueueWriter,
951 ) -> std::pin::Pin<
952 Box<
953 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
954 + Send
955 + 'a,
956 >,
957 > {
958 Box::pin(async { Ok(()) })
959 }
960 }
961
962 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
963 let router = A2aRouter::new(handler);
964 let _axum_router = router.into_router();
966 }
967
968 #[test]
969 fn a2a_router_with_config() {
970 use crate::builder::RequestHandlerBuilder;
971
972 struct NoopExecutor;
973 impl crate::executor::AgentExecutor for NoopExecutor {
974 fn execute<'a>(
975 &'a self,
976 _ctx: &'a crate::request_context::RequestContext,
977 _queue: &'a dyn crate::streaming::EventQueueWriter,
978 ) -> std::pin::Pin<
979 Box<
980 dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>>
981 + Send
982 + 'a,
983 >,
984 > {
985 Box::pin(async { Ok(()) })
986 }
987 }
988
989 let handler = Arc::new(RequestHandlerBuilder::new(NoopExecutor).build().unwrap());
990 let config =
991 super::super::DispatchConfig::default().with_max_request_body_size(8 * 1024 * 1024);
992 let router = A2aRouter::with_config(handler, config);
993 let _axum_router = router.into_router();
994 }
995}
996
997#[cfg(test)]
1006mod readiness_tests {
1007 use std::future::Future;
1008 use std::pin::Pin;
1009
1010 use a2a_protocol_types::error::{A2aError, A2aResult};
1011 use a2a_protocol_types::params::ListTasksParams;
1012 use a2a_protocol_types::responses::TaskListResponse;
1013 use a2a_protocol_types::task::{Task, TaskId};
1014 use axum::http::StatusCode;
1015
1016 use crate::store::TaskStore;
1017
1018 use super::*;
1019
1020 struct UnreachableStore;
1022
1023 impl TaskStore for UnreachableStore {
1024 fn save<'a>(
1025 &'a self,
1026 _task: &'a Task,
1027 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1028 Box::pin(async { Err(A2aError::internal("connection refused")) })
1029 }
1030 fn get<'a>(
1031 &'a self,
1032 _id: &'a TaskId,
1033 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> {
1034 Box::pin(async { Err(A2aError::internal("connection refused")) })
1035 }
1036 fn list<'a>(
1037 &'a self,
1038 _p: &'a ListTasksParams,
1039 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> {
1040 Box::pin(async { Err(A2aError::internal("connection refused")) })
1041 }
1042 fn insert_if_absent<'a>(
1043 &'a self,
1044 _task: &'a Task,
1045 ) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> {
1046 Box::pin(async { Err(A2aError::internal("connection refused")) })
1047 }
1048 fn delete<'a>(
1049 &'a self,
1050 _id: &'a TaskId,
1051 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
1052 Box::pin(async { Err(A2aError::internal("connection refused")) })
1053 }
1054 fn count<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<u64>> + Send + 'a>> {
1055 Box::pin(async { Err(A2aError::internal("connection refused")) })
1056 }
1057 }
1058
1059 fn state_with(store: Option<UnreachableStore>) -> A2aState {
1060 struct Noop;
1061 crate::agent_executor!(Noop, |_ctx, _q| async { Ok(()) });
1062
1063 let builder = crate::builder::RequestHandlerBuilder::new(Noop);
1064 let builder = match store {
1065 Some(s) => builder.with_task_store(s),
1066 None => builder,
1067 };
1068 A2aState {
1069 handler: Arc::new(builder.build().expect("build handler")),
1070 config: Arc::new(super::super::DispatchConfig::default()),
1071 }
1072 }
1073
1074 async fn read_response(resp: axum::response::Response) -> (StatusCode, String) {
1078 let status = resp.status();
1079 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1080 .await
1081 .expect("body");
1082 (status, String::from_utf8_lossy(&bytes).into_owned())
1083 }
1084
1085 #[tokio::test]
1086 async fn ready_reports_ok_when_the_store_answers() {
1087 let (status, body) = read_response(handle_ready(State(state_with(None))).await).await;
1088 assert_eq!(status, StatusCode::OK);
1089 assert!(body.contains("\"ready\""), "unexpected body: {body}");
1090 }
1091
1092 #[tokio::test]
1093 async fn ready_reports_503_when_the_store_is_unreachable() {
1094 let (status, body) =
1095 read_response(handle_ready(State(state_with(Some(UnreachableStore)))).await).await;
1096
1097 assert_eq!(
1098 status,
1099 StatusCode::SERVICE_UNAVAILABLE,
1100 "an unreachable store must drain traffic from this replica"
1101 );
1102 assert!(body.contains("not_ready"), "unexpected body: {body}");
1103 assert!(body.contains("internal_error"), "unexpected body: {body}");
1106 assert!(
1107 !body.contains("connection refused"),
1108 "the store's message must not be echoed to an unauthenticated probe: {body}"
1109 );
1110 }
1111
1112 #[tokio::test]
1115 async fn health_stays_ok_when_the_store_is_unreachable() {
1116 let (status, body) = read_response(handle_health().await).await;
1117
1118 assert_eq!(
1119 status,
1120 StatusCode::OK,
1121 "liveness must not depend on a downstream"
1122 );
1123 assert!(body.contains("\"ok\""), "unexpected body: {body}");
1124 }
1125}