1use a2a::*;
4use async_trait::async_trait;
5use std::collections::HashMap;
6use tokio::sync::RwLock;
7
8#[async_trait]
10pub trait PushConfigStore: Send + Sync + 'static {
11 async fn save(
13 &self,
14 config: TaskPushNotificationConfig,
15 ) -> Result<TaskPushNotificationConfig, A2AError>;
16
17 async fn get(
19 &self,
20 task_id: &str,
21 config_id: &str,
22 ) -> Result<TaskPushNotificationConfig, A2AError>;
23
24 async fn list(&self, task_id: &str) -> Result<Vec<TaskPushNotificationConfig>, A2AError>;
26
27 async fn delete(&self, task_id: &str, config_id: &str) -> Result<(), A2AError>;
29
30 async fn delete_all(&self, task_id: &str) -> Result<(), A2AError>;
32}
33
34pub struct InMemoryPushConfigStore {
36 configs: RwLock<HashMap<String, HashMap<String, TaskPushNotificationConfig>>>,
37}
38
39impl InMemoryPushConfigStore {
40 pub fn new() -> Self {
41 InMemoryPushConfigStore {
42 configs: RwLock::new(HashMap::new()),
43 }
44 }
45}
46
47impl Default for InMemoryPushConfigStore {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53#[async_trait]
54impl PushConfigStore for InMemoryPushConfigStore {
55 async fn save(
56 &self,
57 mut config: TaskPushNotificationConfig,
58 ) -> Result<TaskPushNotificationConfig, A2AError> {
59 if config.url.is_empty() {
60 return Err(A2AError::invalid_params("push config URL cannot be empty"));
61 }
62 if config.task_id.is_empty() {
63 return Err(A2AError::invalid_params(
64 "push config task_id cannot be empty",
65 ));
66 }
67
68 if config.id.is_none() {
69 config.id = Some(uuid::Uuid::now_v7().to_string());
70 }
71
72 let mut store = self.configs.write().await;
73 let task_configs = store.entry(config.task_id.clone()).or_default();
74 let config_id = config.id.clone().unwrap();
75 task_configs.insert(config_id, config.clone());
76 Ok(config)
77 }
78
79 async fn get(
80 &self,
81 task_id: &str,
82 config_id: &str,
83 ) -> Result<TaskPushNotificationConfig, A2AError> {
84 let store = self.configs.read().await;
85 store
86 .get(task_id)
87 .and_then(|configs| configs.get(config_id))
88 .cloned()
89 .ok_or_else(A2AError::push_notification_not_supported)
90 }
91
92 async fn list(&self, task_id: &str) -> Result<Vec<TaskPushNotificationConfig>, A2AError> {
93 let store = self.configs.read().await;
94 Ok(store
95 .get(task_id)
96 .map(|configs| configs.values().cloned().collect())
97 .unwrap_or_default())
98 }
99
100 async fn delete(&self, task_id: &str, config_id: &str) -> Result<(), A2AError> {
101 let mut store = self.configs.write().await;
102 if let Some(configs) = store.get_mut(task_id) {
103 configs.remove(config_id);
104 }
105 Ok(())
106 }
107
108 async fn delete_all(&self, task_id: &str) -> Result<(), A2AError> {
109 let mut store = self.configs.write().await;
110 store.remove(task_id);
111 Ok(())
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn make_config(task_id: &str, url: &str) -> TaskPushNotificationConfig {
120 TaskPushNotificationConfig {
121 url: url.to_string(),
122 id: None,
123 task_id: task_id.to_string(),
124 token: None,
125 authentication: None,
126 tenant: None,
127 }
128 }
129
130 #[tokio::test]
131 async fn test_save_and_get() {
132 let store = InMemoryPushConfigStore::new();
133 let config = store
134 .save(make_config("t1", "https://example.com/hook"))
135 .await
136 .unwrap();
137 assert!(config.id.is_some());
138
139 let got = store.get("t1", config.id.as_ref().unwrap()).await.unwrap();
140 assert_eq!(got.url, "https://example.com/hook");
141 }
142
143 #[tokio::test]
144 async fn test_save_empty_url() {
145 let store = InMemoryPushConfigStore::new();
146 let result = store.save(make_config("t1", "")).await;
147 assert!(result.is_err());
148 }
149
150 #[tokio::test]
151 async fn test_save_empty_task_id() {
152 let store = InMemoryPushConfigStore::new();
153 let result = store
154 .save(make_config("", "https://example.com/hook"))
155 .await;
156 assert!(result.is_err());
157 }
158
159 #[tokio::test]
160 async fn test_save_with_existing_id() {
161 let store = InMemoryPushConfigStore::new();
162 let mut config = make_config("t1", "https://example.com/hook");
163 config.id = Some("my-id".to_string());
164 let saved = store.save(config).await.unwrap();
165 assert_eq!(saved.id.as_deref(), Some("my-id"));
166 }
167
168 #[tokio::test]
169 async fn test_get_not_found() {
170 let store = InMemoryPushConfigStore::new();
171 let result = store.get("t0", "nonexistent").await;
172 assert!(result.is_err());
173 }
174
175 #[tokio::test]
176 async fn test_list() {
177 let store = InMemoryPushConfigStore::new();
178 store
179 .save(make_config("t1", "https://a.com"))
180 .await
181 .unwrap();
182 store
183 .save(make_config("t1", "https://b.com"))
184 .await
185 .unwrap();
186 store
187 .save(make_config("t2", "https://c.com"))
188 .await
189 .unwrap();
190
191 let configs = store.list("t1").await.unwrap();
192 assert_eq!(configs.len(), 2);
193
194 let configs = store.list("t2").await.unwrap();
195 assert_eq!(configs.len(), 1);
196
197 let configs = store.list("t3").await.unwrap();
198 assert_eq!(configs.len(), 0);
199 }
200
201 #[tokio::test]
202 async fn test_delete() {
203 let store = InMemoryPushConfigStore::new();
204 let config = store
205 .save(make_config("t1", "https://a.com"))
206 .await
207 .unwrap();
208 let config_id = config.id.unwrap();
209
210 store.delete("t1", &config_id).await.unwrap();
211 let result = store.get("t1", &config_id).await;
212 assert!(result.is_err());
213 }
214
215 #[tokio::test]
216 async fn test_delete_all() {
217 let store = InMemoryPushConfigStore::new();
218 store
219 .save(make_config("t1", "https://a.com"))
220 .await
221 .unwrap();
222 store
223 .save(make_config("t1", "https://b.com"))
224 .await
225 .unwrap();
226
227 store.delete_all("t1").await.unwrap();
228 let configs = store.list("t1").await.unwrap();
229 assert_eq!(configs.len(), 0);
230 }
231}