1use a2a::*;
4use async_trait::async_trait;
5use std::collections::HashMap;
6use tokio::sync::RwLock;
7
8const MAX_PUSH_CONFIGS_PER_TASK: usize = 50;
13
14#[async_trait]
16pub trait PushConfigStore: Send + Sync + 'static {
17 async fn save(
19 &self,
20 config: TaskPushNotificationConfig,
21 ) -> Result<TaskPushNotificationConfig, A2AError>;
22
23 async fn get(
25 &self,
26 task_id: &str,
27 config_id: &str,
28 ) -> Result<TaskPushNotificationConfig, A2AError>;
29
30 async fn list(&self, task_id: &str) -> Result<Vec<TaskPushNotificationConfig>, A2AError>;
32
33 async fn delete(&self, task_id: &str, config_id: &str) -> Result<(), A2AError>;
35
36 async fn delete_all(&self, task_id: &str) -> Result<(), A2AError>;
38}
39
40pub struct InMemoryPushConfigStore {
42 configs: RwLock<HashMap<String, HashMap<String, TaskPushNotificationConfig>>>,
43}
44
45impl InMemoryPushConfigStore {
46 pub fn new() -> Self {
47 InMemoryPushConfigStore {
48 configs: RwLock::new(HashMap::new()),
49 }
50 }
51}
52
53impl Default for InMemoryPushConfigStore {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59#[async_trait]
60impl PushConfigStore for InMemoryPushConfigStore {
61 async fn save(
62 &self,
63 mut config: TaskPushNotificationConfig,
64 ) -> Result<TaskPushNotificationConfig, A2AError> {
65 if config.url.is_empty() {
66 return Err(A2AError::invalid_params("push config URL cannot be empty"));
67 }
68 if config.task_id.is_empty() {
69 return Err(A2AError::invalid_params(
70 "push config task_id cannot be empty",
71 ));
72 }
73
74 if config.id.is_none() {
75 config.id = Some(uuid::Uuid::now_v7().to_string());
76 }
77
78 let mut store = self.configs.write().await;
79 let task_configs = store.entry(config.task_id.clone()).or_default();
80 let config_id = config.id.clone().unwrap();
81 if !task_configs.contains_key(&config_id) && task_configs.len() >= MAX_PUSH_CONFIGS_PER_TASK
85 {
86 return Err(A2AError::invalid_params(format!(
87 "too many push notification configs for task (max {MAX_PUSH_CONFIGS_PER_TASK})"
88 )));
89 }
90 task_configs.insert(config_id, config.clone());
91 Ok(config)
92 }
93
94 async fn get(
95 &self,
96 task_id: &str,
97 config_id: &str,
98 ) -> Result<TaskPushNotificationConfig, A2AError> {
99 let store = self.configs.read().await;
100 store
101 .get(task_id)
102 .and_then(|configs| configs.get(config_id))
103 .cloned()
104 .ok_or_else(A2AError::push_notification_not_supported)
105 }
106
107 async fn list(&self, task_id: &str) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
108 let store = self.configs.read().await;
109 Ok(store
110 .get(task_id)
111 .map(|configs| configs.values().cloned().collect())
112 .unwrap_or_default())
113 }
114
115 async fn delete(&self, task_id: &str, config_id: &str) -> Result<(), A2AError> {
116 let mut store = self.configs.write().await;
117 if let Some(configs) = store.get_mut(task_id) {
118 configs.remove(config_id);
119 }
120 Ok(())
121 }
122
123 async fn delete_all(&self, task_id: &str) -> Result<(), A2AError> {
124 let mut store = self.configs.write().await;
125 store.remove(task_id);
126 Ok(())
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn make_config(task_id: &str, url: &str) -> TaskPushNotificationConfig {
135 TaskPushNotificationConfig {
136 url: url.to_string(),
137 id: None,
138 task_id: task_id.to_string(),
139 token: None,
140 authentication: None,
141 tenant: None,
142 }
143 }
144
145 #[tokio::test]
146 async fn test_save_and_get() {
147 let store = InMemoryPushConfigStore::new();
148 let config = store
149 .save(make_config("t1", "https://example.com/hook"))
150 .await
151 .unwrap();
152 assert!(config.id.is_some());
153
154 let got = store.get("t1", config.id.as_ref().unwrap()).await.unwrap();
155 assert_eq!(got.url, "https://example.com/hook");
156 }
157
158 #[tokio::test]
159 async fn test_save_empty_url() {
160 let store = InMemoryPushConfigStore::new();
161 let result = store.save(make_config("t1", "")).await;
162 assert!(result.is_err());
163 }
164
165 #[tokio::test]
166 async fn test_save_empty_task_id() {
167 let store = InMemoryPushConfigStore::new();
168 let result = store
169 .save(make_config("", "https://example.com/hook"))
170 .await;
171 assert!(result.is_err());
172 }
173
174 #[tokio::test]
175 async fn test_save_with_existing_id() {
176 let store = InMemoryPushConfigStore::new();
177 let mut config = make_config("t1", "https://example.com/hook");
178 config.id = Some("my-id".to_string());
179 let saved = store.save(config).await.unwrap();
180 assert_eq!(saved.id.as_deref(), Some("my-id"));
181 }
182
183 #[tokio::test]
184 async fn test_get_not_found() {
185 let store = InMemoryPushConfigStore::new();
186 let result = store.get("t0", "nonexistent").await;
187 assert!(result.is_err());
188 }
189
190 #[tokio::test]
191 async fn test_list() {
192 let store = InMemoryPushConfigStore::new();
193 store
194 .save(make_config("t1", "https://a.com"))
195 .await
196 .unwrap();
197 store
198 .save(make_config("t1", "https://b.com"))
199 .await
200 .unwrap();
201 store
202 .save(make_config("t2", "https://c.com"))
203 .await
204 .unwrap();
205
206 let configs = store.list("t1").await.unwrap();
207 assert_eq!(configs.len(), 2);
208
209 let configs = store.list("t2").await.unwrap();
210 assert_eq!(configs.len(), 1);
211
212 let configs = store.list("t3").await.unwrap();
213 assert_eq!(configs.len(), 0);
214 }
215
216 #[tokio::test]
217 async fn test_delete() {
218 let store = InMemoryPushConfigStore::new();
219 let config = store
220 .save(make_config("t1", "https://a.com"))
221 .await
222 .unwrap();
223 let config_id = config.id.unwrap();
224
225 store.delete("t1", &config_id).await.unwrap();
226 let result = store.get("t1", &config_id).await;
227 assert!(result.is_err());
228 }
229
230 #[tokio::test]
231 async fn test_delete_all() {
232 let store = InMemoryPushConfigStore::new();
233 store
234 .save(make_config("t1", "https://a.com"))
235 .await
236 .unwrap();
237 store
238 .save(make_config("t1", "https://b.com"))
239 .await
240 .unwrap();
241
242 store.delete_all("t1").await.unwrap();
243 let configs = store.list("t1").await.unwrap();
244 assert_eq!(configs.len(), 0);
245 }
246
247 #[tokio::test]
248 async fn test_save_caps_configs_per_task() {
249 let store = InMemoryPushConfigStore::new();
250 for i in 0..MAX_PUSH_CONFIGS_PER_TASK {
251 store
252 .save(make_config("t1", &format!("https://example.com/hook/{i}")))
253 .await
254 .unwrap();
255 }
256
257 let result = store
259 .save(make_config("t1", "https://example.com/over-limit"))
260 .await;
261 assert!(result.is_err());
262 assert_eq!(result.unwrap_err().code, error_code::INVALID_PARAMS);
263
264 store
266 .save(make_config("t2", "https://example.com/hook"))
267 .await
268 .unwrap();
269 }
270
271 #[tokio::test]
272 async fn test_save_can_replace_existing_config_at_cap() {
273 let store = InMemoryPushConfigStore::new();
274 let mut first = make_config("t1", "https://example.com/hook/0");
275 first.id = Some("cfg-keep".to_string());
276 store.save(first).await.unwrap();
277 for i in 1..MAX_PUSH_CONFIGS_PER_TASK {
278 store
279 .save(make_config("t1", &format!("https://example.com/hook/{i}")))
280 .await
281 .unwrap();
282 }
283 assert_eq!(
284 store.list("t1").await.unwrap().len(),
285 MAX_PUSH_CONFIGS_PER_TASK
286 );
287
288 let mut config = make_config("t1", "https://example.com/replaced");
290 config.id = Some("cfg-keep".to_string());
291 let saved = store.save(config).await.unwrap();
292 assert_eq!(saved.id.as_deref(), Some("cfg-keep"));
293 assert_eq!(saved.url, "https://example.com/replaced");
294 assert_eq!(
295 store.list("t1").await.unwrap().len(),
296 MAX_PUSH_CONFIGS_PER_TASK
297 );
298 }
299}