a2a_protocol_server/push/
tenant_sqlite_config_store.rs1use std::future::Future;
14use std::pin::Pin;
15
16use a2a_protocol_types::error::{A2aError, A2aResult};
17use a2a_protocol_types::push::TaskPushNotificationConfig;
18use sqlx::sqlite::SqlitePool;
19
20use super::config_store::PushConfigStore;
21use crate::store::tenant::TenantContext;
22
23#[derive(Debug, Clone)]
39pub struct TenantAwareSqlitePushConfigStore {
40 pool: SqlitePool,
41}
42
43use crate::sqlite_pool::sqlite_pool;
44
45fn to_a2a_error(e: &sqlx::Error) -> A2aError {
46 A2aError::internal(format!("sqlite error: {e}"))
47}
48
49impl TenantAwareSqlitePushConfigStore {
50 pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
56 let pool = sqlite_pool(url).await?;
57 Self::from_pool(pool).await
58 }
59
60 pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
66 sqlx::query(
67 "CREATE TABLE IF NOT EXISTS tenant_push_configs (
68 tenant_id TEXT NOT NULL DEFAULT '',
69 task_id TEXT NOT NULL,
70 id TEXT NOT NULL,
71 data TEXT NOT NULL,
72 PRIMARY KEY (tenant_id, task_id, id)
73 )",
74 )
75 .execute(&pool)
76 .await?;
77
78 Ok(Self { pool })
79 }
80}
81
82#[allow(clippy::manual_async_fn)]
83impl PushConfigStore for TenantAwareSqlitePushConfigStore {
84 fn set<'a>(
85 &'a self,
86 mut config: TaskPushNotificationConfig,
87 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
88 Box::pin(async move {
89 let tenant = TenantContext::current();
90 let Some(task_id) = config.task_id.clone() else {
94 return Err(A2aError::invalid_params(
95 "taskId is required to store a push notification config",
96 ));
97 };
98 let id = config
99 .id
100 .clone()
101 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
102 config.id = Some(id.clone());
103
104 let data = serde_json::to_string(&config)
105 .map_err(|e| A2aError::internal(format!("serialize: {e}")))?;
106
107 sqlx::query(
108 "INSERT INTO tenant_push_configs (tenant_id, task_id, id, data)
109 VALUES (?1, ?2, ?3, ?4)
110 ON CONFLICT(tenant_id, task_id, id) DO UPDATE SET data = excluded.data",
111 )
112 .bind(&tenant)
113 .bind(&task_id)
114 .bind(&id)
115 .bind(&data)
116 .execute(&self.pool)
117 .await
118 .map_err(|e| to_a2a_error(&e))?;
119
120 Ok(config)
121 })
122 }
123
124 fn get<'a>(
125 &'a self,
126 task_id: &'a str,
127 id: &'a str,
128 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
129 {
130 Box::pin(async move {
131 let tenant = TenantContext::current();
132 let row: Option<(String,)> = sqlx::query_as(
133 "SELECT data FROM tenant_push_configs WHERE tenant_id = ?1 AND task_id = ?2 AND id = ?3",
134 )
135 .bind(&tenant)
136 .bind(task_id)
137 .bind(id)
138 .fetch_optional(&self.pool)
139 .await
140 .map_err(|e| to_a2a_error(&e))?;
141
142 match row {
143 Some((data,)) => {
144 let config: TaskPushNotificationConfig = serde_json::from_str(&data)
145 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))?;
146 Ok(Some(config))
147 }
148 None => Ok(None),
149 }
150 })
151 }
152
153 fn list<'a>(
154 &'a self,
155 task_id: &'a str,
156 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
157 Box::pin(async move {
158 let tenant = TenantContext::current();
159 let rows: Vec<(String,)> = sqlx::query_as(
160 "SELECT data FROM tenant_push_configs WHERE tenant_id = ?1 AND task_id = ?2",
161 )
162 .bind(&tenant)
163 .bind(task_id)
164 .fetch_all(&self.pool)
165 .await
166 .map_err(|e| to_a2a_error(&e))?;
167
168 rows.into_iter()
169 .map(|(data,)| {
170 serde_json::from_str(&data)
171 .map_err(|e| A2aError::internal(format!("deserialize: {e}")))
172 })
173 .collect()
174 })
175 }
176
177 fn delete<'a>(
178 &'a self,
179 task_id: &'a str,
180 id: &'a str,
181 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
182 Box::pin(async move {
183 let tenant = TenantContext::current();
184 sqlx::query(
185 "DELETE FROM tenant_push_configs WHERE tenant_id = ?1 AND task_id = ?2 AND id = ?3",
186 )
187 .bind(&tenant)
188 .bind(task_id)
189 .bind(id)
190 .execute(&self.pool)
191 .await
192 .map_err(|e| to_a2a_error(&e))?;
193 Ok(())
194 })
195 }
196
197 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
198 Box::pin(async move {
199 let tenant = TenantContext::current();
201 let (total,): (i64,) =
202 sqlx::query_as("SELECT COUNT(*) FROM tenant_push_configs WHERE tenant_id = ?1")
203 .bind(&tenant)
204 .fetch_one(&self.pool)
205 .await
206 .map_err(|e| to_a2a_error(&e))?;
207 Ok(Some(usize::try_from(total).unwrap_or(usize::MAX)))
208 })
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use a2a_protocol_types::push::TaskPushNotificationConfig;
216
217 async fn make_store() -> TenantAwareSqlitePushConfigStore {
218 TenantAwareSqlitePushConfigStore::new("sqlite::memory:")
219 .await
220 .expect("failed to create in-memory tenant push config store")
221 }
222
223 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
224 TaskPushNotificationConfig {
225 tenant: None,
226 id: id.map(String::from),
227 task_id: Some(task_id.to_string()),
228 url: url.to_string(),
229 token: None,
230 authentication: None,
231 }
232 }
233
234 #[tokio::test]
238 async fn count_is_per_tenant() {
239 let store = make_store().await;
240 TenantContext::scope("acme", async {
241 for task in ["t1", "t2"] {
242 store
243 .set(make_config(task, None, "https://a.example.com"))
244 .await
245 .unwrap();
246 }
247 })
248 .await;
249 TenantContext::scope("globex", async {
250 store
251 .set(make_config("t1", None, "https://g.example.com"))
252 .await
253 .unwrap();
254 })
255 .await;
256 let acme = TenantContext::scope("acme", store.count()).await.unwrap();
257 let globex = TenantContext::scope("globex", store.count()).await.unwrap();
258 let unseen = TenantContext::scope("initech", store.count())
259 .await
260 .unwrap();
261 assert_eq!((acme, globex, unseen), (Some(2), Some(1), Some(0)));
262 }
263
264 #[tokio::test]
265 async fn set_and_get_within_tenant() {
266 let store = make_store().await;
267 TenantContext::scope("acme", async {
268 store
269 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
270 .await
271 .unwrap();
272 let config = store.get("task-1", "cfg-1").await.unwrap();
273 assert!(
274 config.is_some(),
275 "config should be retrievable within its tenant"
276 );
277 assert_eq!(config.unwrap().url, "https://example.com");
278 })
279 .await;
280 }
281
282 #[tokio::test]
283 async fn tenant_isolation_get() {
284 let store = make_store().await;
285 TenantContext::scope("tenant-a", async {
286 store
287 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
288 .await
289 .unwrap();
290 })
291 .await;
292
293 TenantContext::scope("tenant-b", async {
294 let result = store.get("task-1", "cfg-1").await.unwrap();
295 assert!(
296 result.is_none(),
297 "tenant-b should not see tenant-a's config"
298 );
299 })
300 .await;
301 }
302
303 #[tokio::test]
304 async fn tenant_isolation_list() {
305 let store = make_store().await;
306 TenantContext::scope("tenant-a", async {
307 store
308 .set(make_config("task-1", Some("c1"), "https://a.com/1"))
309 .await
310 .unwrap();
311 store
312 .set(make_config("task-1", Some("c2"), "https://a.com/2"))
313 .await
314 .unwrap();
315 })
316 .await;
317
318 TenantContext::scope("tenant-b", async {
319 store
320 .set(make_config("task-1", Some("c3"), "https://b.com/1"))
321 .await
322 .unwrap();
323 })
324 .await;
325
326 TenantContext::scope("tenant-a", async {
327 let configs = store.list("task-1").await.unwrap();
328 assert_eq!(configs.len(), 2, "tenant-a should see only its 2 configs");
329 })
330 .await;
331
332 TenantContext::scope("tenant-b", async {
333 let configs = store.list("task-1").await.unwrap();
334 assert_eq!(configs.len(), 1, "tenant-b should see only its 1 config");
335 })
336 .await;
337 }
338
339 #[tokio::test]
340 async fn tenant_isolation_delete() {
341 let store = make_store().await;
342 TenantContext::scope("tenant-a", async {
343 store
344 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
345 .await
346 .unwrap();
347 })
348 .await;
349
350 TenantContext::scope("tenant-b", async {
352 store.delete("task-1", "cfg-1").await.unwrap();
353 })
354 .await;
355
356 TenantContext::scope("tenant-a", async {
357 let config = store.get("task-1", "cfg-1").await.unwrap();
358 assert!(
359 config.is_some(),
360 "tenant-a's config should survive tenant-b's delete"
361 );
362 })
363 .await;
364 }
365
366 #[tokio::test]
367 async fn same_keys_different_tenants() {
368 let store = make_store().await;
369 TenantContext::scope("tenant-a", async {
370 store
371 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
372 .await
373 .unwrap();
374 })
375 .await;
376
377 TenantContext::scope("tenant-b", async {
378 store
379 .set(make_config("task-1", Some("cfg-1"), "https://b.com"))
380 .await
381 .unwrap();
382 })
383 .await;
384
385 TenantContext::scope("tenant-a", async {
386 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
387 assert_eq!(
388 config.url, "https://a.com",
389 "tenant-a should get its own config"
390 );
391 })
392 .await;
393
394 TenantContext::scope("tenant-b", async {
395 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
396 assert_eq!(
397 config.url, "https://b.com",
398 "tenant-b should get its own config"
399 );
400 })
401 .await;
402 }
403
404 #[tokio::test]
405 async fn overwrite_within_tenant() {
406 let store = make_store().await;
407 TenantContext::scope("acme", async {
408 store
409 .set(make_config("task-1", Some("cfg-1"), "https://old.com"))
410 .await
411 .unwrap();
412 store
413 .set(make_config("task-1", Some("cfg-1"), "https://new.com"))
414 .await
415 .unwrap();
416
417 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
418 assert_eq!(
419 config.url, "https://new.com",
420 "overwrite should update the URL"
421 );
422 })
423 .await;
424 }
425
426 #[tokio::test]
427 async fn set_assigns_id_when_none() {
428 let store = make_store().await;
429 TenantContext::scope("acme", async {
430 let config = make_config("task-1", None, "https://example.com");
431 let result = store.set(config).await.unwrap();
432 assert!(
433 result.id.is_some(),
434 "set should assign an id when none is provided"
435 );
436 })
437 .await;
438 }
439
440 #[tokio::test]
441 async fn delete_nonexistent_is_ok() {
442 let store = make_store().await;
443 TenantContext::scope("acme", async {
444 let result = store.delete("no-task", "no-id").await;
445 assert!(
446 result.is_ok(),
447 "deleting a nonexistent config should not error"
448 );
449 })
450 .await;
451 }
452
453 #[test]
455 fn to_a2a_error_formats_message() {
456 let sqlite_err = sqlx::Error::RowNotFound;
457 let a2a_err = to_a2a_error(&sqlite_err);
458 let msg = format!("{a2a_err}");
459 assert!(
460 msg.contains("sqlite error"),
461 "error message should contain 'sqlite error': {msg}"
462 );
463 }
464
465 #[tokio::test]
466 async fn default_tenant_context_uses_empty_string() {
467 let store = make_store().await;
468 store
470 .set(make_config("task-1", Some("cfg-1"), "https://default.com"))
471 .await
472 .unwrap();
473 let config = store.get("task-1", "cfg-1").await.unwrap();
474 assert!(config.is_some(), "default (empty) tenant should work");
475 }
476}