Skip to main content

a2a_server/push/
sender.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3use a2a::*;
4use std::time::Duration;
5
6/// HTTP push notification sender.
7///
8/// Sends A2A streaming events to a push notification endpoint over HTTP POST.
9pub struct HttpPushSender {
10    client: reqwest::Client,
11    fail_on_error: bool,
12}
13
14/// Configuration for [`HttpPushSender`].
15pub struct HttpPushSenderConfig {
16    /// HTTP request timeout (default: 30s).
17    pub timeout: Duration,
18    /// If true, push sending errors abort execution.
19    pub fail_on_error: bool,
20}
21
22impl Default for HttpPushSenderConfig {
23    fn default() -> Self {
24        HttpPushSenderConfig {
25            timeout: Duration::from_secs(30),
26            fail_on_error: false,
27        }
28    }
29}
30
31impl HttpPushSender {
32    pub fn new(config: Option<HttpPushSenderConfig>) -> Self {
33        let config = config.unwrap_or_default();
34        let client = reqwest::Client::builder()
35            .timeout(config.timeout)
36            .build()
37            .expect("failed to create HTTP client");
38        HttpPushSender {
39            client,
40            fail_on_error: config.fail_on_error,
41        }
42    }
43
44    /// Send an event to the push notification endpoint.
45    pub async fn send_push(
46        &self,
47        config: &PushNotificationConfig,
48        event: StreamResponse,
49    ) -> Result<(), A2AError> {
50        let body = match serde_json::to_vec(&event) {
51            Ok(b) => b,
52            Err(e) => return self.handle_error(format!("failed to serialize event: {e}")),
53        };
54
55        let mut request = self
56            .client
57            .post(&config.url)
58            .header("Content-Type", "application/json")
59            .body(body);
60
61        if let Some(ref token) = config.token {
62            request = request.header("A2A-Notification-Token", token);
63        }
64
65        if let Some(ref auth) = config.authentication {
66            if let Some(ref creds) = auth.credentials {
67                match auth.scheme.to_lowercase().as_str() {
68                    "bearer" => {
69                        request = request.header("Authorization", format!("Bearer {creds}"));
70                    }
71                    "basic" => {
72                        request = request.header("Authorization", format!("Basic {creds}"));
73                    }
74                    _ => {}
75                }
76            }
77        }
78
79        match request.send().await {
80            Ok(resp) => {
81                if !resp.status().is_success() {
82                    return self
83                        .handle_error(format!("push endpoint returned status: {}", resp.status()));
84                }
85                Ok(())
86            }
87            Err(e) => self.handle_error(format!("failed to send push notification: {e}")),
88        }
89    }
90
91    fn handle_error(&self, msg: String) -> Result<(), A2AError> {
92        if self.fail_on_error {
93            Err(A2AError::internal(&msg))
94        } else {
95            tracing::error!("{}", msg);
96            Ok(())
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_default_config() {
107        let config = HttpPushSenderConfig::default();
108        assert_eq!(config.timeout, Duration::from_secs(30));
109        assert!(!config.fail_on_error);
110    }
111
112    #[test]
113    fn test_sender_new_default() {
114        let sender = HttpPushSender::new(None);
115        assert!(!sender.fail_on_error);
116    }
117
118    #[test]
119    fn test_sender_new_custom() {
120        let config = HttpPushSenderConfig {
121            timeout: Duration::from_secs(10),
122            fail_on_error: true,
123        };
124        let sender = HttpPushSender::new(Some(config));
125        assert!(sender.fail_on_error);
126    }
127
128    #[test]
129    fn test_handle_error_fail_on_error() {
130        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
131            timeout: Duration::from_secs(5),
132            fail_on_error: true,
133        }));
134        let result = sender.handle_error("test error".to_string());
135        assert!(result.is_err());
136    }
137
138    #[test]
139    fn test_handle_error_ignore() {
140        let sender = HttpPushSender::new(None);
141        let result = sender.handle_error("test error".to_string());
142        assert!(result.is_ok());
143    }
144
145    #[tokio::test]
146    async fn test_send_push_connection_refused_no_fail() {
147        use a2a::event::*;
148        // With fail_on_error=false, network errors are logged but Ok
149        let sender = HttpPushSender::new(None);
150        let config = PushNotificationConfig {
151            url: "http://127.0.0.1:1/callback".to_string(),
152            id: None,
153            token: Some("tok".to_string()),
154            authentication: Some(AuthenticationInfo {
155                scheme: "bearer".to_string(),
156                credentials: Some("secret".to_string()),
157            }),
158        };
159        let event = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
160            task_id: "t1".into(),
161            context_id: "c1".into(),
162            status: TaskStatus {
163                state: TaskState::Working,
164                message: None,
165                timestamp: None,
166            },
167            metadata: None,
168        });
169        let result = sender.send_push(&config, event).await;
170        // Should be Ok because fail_on_error is false
171        assert!(result.is_ok());
172    }
173
174    #[tokio::test]
175    async fn test_send_push_connection_refused_fail() {
176        use a2a::event::*;
177        let sender = HttpPushSender::new(Some(HttpPushSenderConfig {
178            timeout: std::time::Duration::from_millis(100),
179            fail_on_error: true,
180        }));
181        let config = PushNotificationConfig {
182            url: "http://127.0.0.1:1/callback".to_string(),
183            id: None,
184            token: None,
185            authentication: None,
186        };
187        let event = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
188            task_id: "t1".into(),
189            context_id: "c1".into(),
190            status: TaskStatus {
191                state: TaskState::Working,
192                message: None,
193                timestamp: None,
194            },
195            metadata: None,
196        });
197        let result = sender.send_push(&config, event).await;
198        assert!(result.is_err());
199    }
200
201    #[tokio::test]
202    async fn test_send_push_with_basic_auth() {
203        use a2a::event::*;
204        let sender = HttpPushSender::new(None);
205        let config = PushNotificationConfig {
206            url: "http://127.0.0.1:1/callback".to_string(),
207            id: None,
208            token: None,
209            authentication: Some(AuthenticationInfo {
210                scheme: "basic".to_string(),
211                credentials: Some("dXNlcjpwYXNz".to_string()),
212            }),
213        };
214        let event = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
215            task_id: "t1".into(),
216            context_id: "c1".into(),
217            status: TaskStatus {
218                state: TaskState::Working,
219                message: None,
220                timestamp: None,
221            },
222            metadata: None,
223        });
224        // Will fail to connect but exercises basic auth header path
225        let result = sender.send_push(&config, event).await;
226        assert!(result.is_ok()); // fail_on_error=false
227    }
228
229    #[tokio::test]
230    async fn test_send_push_with_unknown_auth_scheme() {
231        use a2a::event::*;
232        let sender = HttpPushSender::new(None);
233        let config = PushNotificationConfig {
234            url: "http://127.0.0.1:1/callback".to_string(),
235            id: None,
236            token: None,
237            authentication: Some(AuthenticationInfo {
238                scheme: "custom".to_string(),
239                credentials: Some("cred".to_string()),
240            }),
241        };
242        let event = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
243            task_id: "t1".into(),
244            context_id: "c1".into(),
245            status: TaskStatus {
246                state: TaskState::Working,
247                message: None,
248                timestamp: None,
249            },
250            metadata: None,
251        });
252        let result = sender.send_push(&config, event).await;
253        assert!(result.is_ok());
254    }
255}