a2a_protocol_server/push/
sqlite_config_store.rs1use std::future::Future;
11use std::pin::Pin;
12
13use a2a_protocol_types::error::{A2aError, A2aResult};
14use a2a_protocol_types::push::TaskPushNotificationConfig;
15use sqlx::sqlite::SqlitePool;
16
17use super::config_store::PushConfigStore;
18
19#[derive(Debug, Clone)]
34pub struct SqlitePushConfigStore {
35 pool: SqlitePool,
36}
37
38use crate::sqlite_pool::sqlite_pool;
39
40#[allow(clippy::needless_pass_by_value)]
42fn to_a2a_error(e: sqlx::Error) -> A2aError {
43 A2aError::internal(format!("sqlite error: {e}"))
44}
45
46impl SqlitePushConfigStore {
47 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
53 let pool = sqlite_pool(url).await?;
54 Self::from_pool(pool).await
55 }
56
57 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
63 sqlx::query(
64 "CREATE TABLE IF NOT EXISTS push_configs (
65 task_id TEXT NOT NULL,
66 id TEXT NOT NULL,
67 data TEXT NOT NULL,
68 PRIMARY KEY (task_id, id)
69 )",
70 )
71 .execute(&pool)
72 .await?;
73
74 Ok(Self { pool })
75 }
76}
77
78#[allow(clippy::manual_async_fn)]
79impl PushConfigStore for SqlitePushConfigStore {
80 fn set<'a>(
81 &'a self,
82 mut config: TaskPushNotificationConfig,
83 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
84 Box::pin(async move {
85 let Some(task_id) = config.task_id.clone() else {
89 return Err(A2aError::invalid_params(
90 "taskId is required to store a push notification config",
91 ));
92 };
93 let id = config
94 .id
95 .clone()
96 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
97 config.id = Some(id.clone());
98
99 let data = serde_json::to_string(&config)
100 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
101
102 sqlx::query(
103 "INSERT INTO push_configs (task_id, id, data)
104 VALUES (?1, ?2, ?3)
105 ON CONFLICT(task_id, id) DO UPDATE SET data = excluded.data",
106 )
107 .bind(&task_id)
108 .bind(&id)
109 .bind(&data)
110 .execute(&self.pool)
111 .await
112 .map_err(to_a2a_error)?;
113
114 Ok(config)
115 })
116 }
117
118 fn get<'a>(
119 &'a self,
120 task_id: &'a str,
121 id: &'a str,
122 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
123 {
124 Box::pin(async move {
125 let row: Option<(String,)> =
126 sqlx::query_as("SELECT data FROM push_configs WHERE task_id = ?1 AND id = ?2")
127 .bind(task_id)
128 .bind(id)
129 .fetch_optional(&self.pool)
130 .await
131 .map_err(to_a2a_error)?;
132
133 match row {
134 Some((data,)) => {
135 let config: TaskPushNotificationConfig = serde_json::from_str(&data)
136 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
137 Ok(Some(config))
138 }
139 None => Ok(None),
140 }
141 })
142 }
143
144 fn list<'a>(
145 &'a self,
146 task_id: &'a str,
147 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
148 Box::pin(async move {
149 let rows: Vec<(String,)> =
150 sqlx::query_as("SELECT data FROM push_configs WHERE task_id = ?1")
151 .bind(task_id)
152 .fetch_all(&self.pool)
153 .await
154 .map_err(to_a2a_error)?;
155
156 rows.into_iter()
157 .map(|(data,)| {
158 serde_json::from_str(&data)
159 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
160 })
161 .collect()
162 })
163 }
164
165 fn delete<'a>(
166 &'a self,
167 task_id: &'a str,
168 id: &'a str,
169 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
170 Box::pin(async move {
171 sqlx::query("DELETE FROM push_configs WHERE task_id = ?1 AND id = ?2")
172 .bind(task_id)
173 .bind(id)
174 .execute(&self.pool)
175 .await
176 .map_err(to_a2a_error)?;
177 Ok(())
178 })
179 }
180
181 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
182 Box::pin(async move {
183 let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM push_configs")
184 .fetch_one(&self.pool)
185 .await
186 .map_err(to_a2a_error)?;
187 Ok(Some(usize::try_from(total).unwrap_or(usize::MAX)))
188 })
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use a2a_protocol_types::push::TaskPushNotificationConfig;
196
197 async fn make_store() -> SqlitePushConfigStore {
198 SqlitePushConfigStore::new("sqlite::memory:")
199 .await
200 .expect("failed to create in-memory push config store")
201 }
202
203 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
204 TaskPushNotificationConfig {
205 tenant: None,
206 id: id.map(String::from),
207 task_id: Some(task_id.to_string()),
208 url: url.to_string(),
209 token: None,
210 authentication: None,
211 }
212 }
213
214 #[tokio::test]
215 async fn set_assigns_id_when_none() {
216 let store = make_store().await;
217 let config = make_config("task-1", None, "https://example.com/hook");
218 let result = store.set(config).await.expect("set should succeed");
219 assert!(
220 result.id.is_some(),
221 "set should assign an id when none is provided"
222 );
223 }
224
225 #[tokio::test]
226 async fn count_reflects_total_configs_across_tasks() {
227 let store = make_store().await;
228 assert_eq!(store.count().await.unwrap(), Some(0));
229 store
230 .set(make_config("task-a", Some("c1"), "https://example.com/1"))
231 .await
232 .unwrap();
233 store
234 .set(make_config("task-b", Some("c1"), "https://example.com/2"))
235 .await
236 .unwrap();
237 assert_eq!(
238 store.count().await.unwrap(),
239 Some(2),
240 "count spans distinct task ids"
241 );
242 store.delete("task-a", "c1").await.unwrap();
243 assert_eq!(store.count().await.unwrap(), Some(1));
244 }
245
246 #[tokio::test]
247 async fn set_preserves_explicit_id() {
248 let store = make_store().await;
249 let config = make_config("task-1", Some("my-id"), "https://example.com/hook");
250 let result = store.set(config).await.expect("set should succeed");
251 assert_eq!(
252 result.id.as_deref(),
253 Some("my-id"),
254 "set should preserve the explicit id"
255 );
256 }
257
258 #[tokio::test]
259 async fn set_then_get_round_trip() {
260 let store = make_store().await;
261 let config = make_config("task-1", Some("cfg-1"), "https://example.com/hook");
262 store.set(config).await.unwrap();
263
264 let retrieved = store.get("task-1", "cfg-1").await.unwrap();
265 let retrieved = retrieved.expect("config should exist after set");
266 assert_eq!(retrieved.task_id.as_deref(), Some("task-1"));
267 assert_eq!(retrieved.url, "https://example.com/hook");
268 assert_eq!(retrieved.id.as_deref(), Some("cfg-1"));
269 }
270
271 #[tokio::test]
272 async fn get_returns_none_for_missing_config() {
273 let store = make_store().await;
274 let result = store
275 .get("no-task", "no-id")
276 .await
277 .expect("get should succeed");
278 assert!(
279 result.is_none(),
280 "get should return None for a missing config"
281 );
282 }
283
284 #[tokio::test]
285 async fn overwrite_existing_config() {
286 let store = make_store().await;
287 store
288 .set(make_config(
289 "task-1",
290 Some("cfg-1"),
291 "https://example.com/v1",
292 ))
293 .await
294 .unwrap();
295 store
296 .set(make_config(
297 "task-1",
298 Some("cfg-1"),
299 "https://example.com/v2",
300 ))
301 .await
302 .unwrap();
303
304 let retrieved = store.get("task-1", "cfg-1").await.unwrap().unwrap();
305 assert_eq!(
306 retrieved.url, "https://example.com/v2",
307 "overwrite should update the URL"
308 );
309 }
310
311 #[tokio::test]
312 async fn list_returns_empty_for_unknown_task() {
313 let store = make_store().await;
314 let configs = store.list("no-such-task").await.unwrap();
315 assert!(
316 configs.is_empty(),
317 "list should return empty vec for unknown task"
318 );
319 }
320
321 #[tokio::test]
322 async fn list_returns_only_configs_for_given_task() {
323 let store = make_store().await;
324 store
325 .set(make_config("task-a", Some("c1"), "https://a.com/1"))
326 .await
327 .unwrap();
328 store
329 .set(make_config("task-a", Some("c2"), "https://a.com/2"))
330 .await
331 .unwrap();
332 store
333 .set(make_config("task-b", Some("c3"), "https://b.com/1"))
334 .await
335 .unwrap();
336
337 let a_configs = store.list("task-a").await.unwrap();
338 assert_eq!(a_configs.len(), 2, "task-a should have exactly 2 configs");
339
340 let b_configs = store.list("task-b").await.unwrap();
341 assert_eq!(b_configs.len(), 1, "task-b should have exactly 1 config");
342 }
343
344 #[tokio::test]
345 async fn delete_removes_config() {
346 let store = make_store().await;
347 store
348 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
349 .await
350 .unwrap();
351
352 store
353 .delete("task-1", "cfg-1")
354 .await
355 .expect("delete should succeed");
356
357 let result = store.get("task-1", "cfg-1").await.unwrap();
358 assert!(result.is_none(), "config should be gone after delete");
359 }
360
361 #[tokio::test]
362 async fn delete_nonexistent_is_ok() {
363 let store = make_store().await;
364 let result = store.delete("no-task", "no-id").await;
365 assert!(
366 result.is_ok(),
367 "deleting a nonexistent config should not error"
368 );
369 }
370
371 #[tokio::test]
372 async fn delete_does_not_affect_other_configs() {
373 let store = make_store().await;
374 store
375 .set(make_config("task-1", Some("c1"), "https://a.com"))
376 .await
377 .unwrap();
378 store
379 .set(make_config("task-1", Some("c2"), "https://b.com"))
380 .await
381 .unwrap();
382
383 store.delete("task-1", "c1").await.unwrap();
384
385 let remaining = store.list("task-1").await.unwrap();
386 assert_eq!(
387 remaining.len(),
388 1,
389 "only the deleted config should be removed"
390 );
391 assert_eq!(remaining[0].id.as_deref(), Some("c2"));
392 }
393
394 #[test]
396 fn to_a2a_error_formats_message() {
397 let sqlite_err = sqlx::Error::RowNotFound;
398 let a2a_err = to_a2a_error(sqlite_err);
399 let msg = format!("{a2a_err}");
400 assert!(
401 msg.contains("sqlite error"),
402 "error message should contain 'sqlite error': {msg}"
403 );
404 }
405
406 #[tokio::test]
407 async fn multiple_tasks_independent_configs() {
408 let store = make_store().await;
409 store
411 .set(make_config("task-a", Some("cfg-1"), "https://a.com"))
412 .await
413 .unwrap();
414 store
415 .set(make_config("task-b", Some("cfg-1"), "https://b.com"))
416 .await
417 .unwrap();
418
419 let a = store.get("task-a", "cfg-1").await.unwrap().unwrap();
420 assert_eq!(a.url, "https://a.com");
421
422 let b = store.get("task-b", "cfg-1").await.unwrap().unwrap();
423 assert_eq!(b.url, "https://b.com");
424 }
425}