Skip to main content

a2a_server/push/
store.rs

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