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]
235 async fn set_and_get_within_tenant() {
236 let store = make_store().await;
237 TenantContext::scope("acme", async {
238 store
239 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
240 .await
241 .unwrap();
242 let config = store.get("task-1", "cfg-1").await.unwrap();
243 assert!(
244 config.is_some(),
245 "config should be retrievable within its tenant"
246 );
247 assert_eq!(config.unwrap().url, "https://example.com");
248 })
249 .await;
250 }
251
252 #[tokio::test]
253 async fn tenant_isolation_get() {
254 let store = make_store().await;
255 TenantContext::scope("tenant-a", async {
256 store
257 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
258 .await
259 .unwrap();
260 })
261 .await;
262
263 TenantContext::scope("tenant-b", async {
264 let result = store.get("task-1", "cfg-1").await.unwrap();
265 assert!(
266 result.is_none(),
267 "tenant-b should not see tenant-a's config"
268 );
269 })
270 .await;
271 }
272
273 #[tokio::test]
274 async fn tenant_isolation_list() {
275 let store = make_store().await;
276 TenantContext::scope("tenant-a", async {
277 store
278 .set(make_config("task-1", Some("c1"), "https://a.com/1"))
279 .await
280 .unwrap();
281 store
282 .set(make_config("task-1", Some("c2"), "https://a.com/2"))
283 .await
284 .unwrap();
285 })
286 .await;
287
288 TenantContext::scope("tenant-b", async {
289 store
290 .set(make_config("task-1", Some("c3"), "https://b.com/1"))
291 .await
292 .unwrap();
293 })
294 .await;
295
296 TenantContext::scope("tenant-a", async {
297 let configs = store.list("task-1").await.unwrap();
298 assert_eq!(configs.len(), 2, "tenant-a should see only its 2 configs");
299 })
300 .await;
301
302 TenantContext::scope("tenant-b", async {
303 let configs = store.list("task-1").await.unwrap();
304 assert_eq!(configs.len(), 1, "tenant-b should see only its 1 config");
305 })
306 .await;
307 }
308
309 #[tokio::test]
310 async fn tenant_isolation_delete() {
311 let store = make_store().await;
312 TenantContext::scope("tenant-a", async {
313 store
314 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
315 .await
316 .unwrap();
317 })
318 .await;
319
320 TenantContext::scope("tenant-b", async {
322 store.delete("task-1", "cfg-1").await.unwrap();
323 })
324 .await;
325
326 TenantContext::scope("tenant-a", async {
327 let config = store.get("task-1", "cfg-1").await.unwrap();
328 assert!(
329 config.is_some(),
330 "tenant-a's config should survive tenant-b's delete"
331 );
332 })
333 .await;
334 }
335
336 #[tokio::test]
337 async fn same_keys_different_tenants() {
338 let store = make_store().await;
339 TenantContext::scope("tenant-a", async {
340 store
341 .set(make_config("task-1", Some("cfg-1"), "https://a.com"))
342 .await
343 .unwrap();
344 })
345 .await;
346
347 TenantContext::scope("tenant-b", async {
348 store
349 .set(make_config("task-1", Some("cfg-1"), "https://b.com"))
350 .await
351 .unwrap();
352 })
353 .await;
354
355 TenantContext::scope("tenant-a", async {
356 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
357 assert_eq!(
358 config.url, "https://a.com",
359 "tenant-a should get its own config"
360 );
361 })
362 .await;
363
364 TenantContext::scope("tenant-b", async {
365 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
366 assert_eq!(
367 config.url, "https://b.com",
368 "tenant-b should get its own config"
369 );
370 })
371 .await;
372 }
373
374 #[tokio::test]
375 async fn overwrite_within_tenant() {
376 let store = make_store().await;
377 TenantContext::scope("acme", async {
378 store
379 .set(make_config("task-1", Some("cfg-1"), "https://old.com"))
380 .await
381 .unwrap();
382 store
383 .set(make_config("task-1", Some("cfg-1"), "https://new.com"))
384 .await
385 .unwrap();
386
387 let config = store.get("task-1", "cfg-1").await.unwrap().unwrap();
388 assert_eq!(
389 config.url, "https://new.com",
390 "overwrite should update the URL"
391 );
392 })
393 .await;
394 }
395
396 #[tokio::test]
397 async fn set_assigns_id_when_none() {
398 let store = make_store().await;
399 TenantContext::scope("acme", async {
400 let config = make_config("task-1", None, "https://example.com");
401 let result = store.set(config).await.unwrap();
402 assert!(
403 result.id.is_some(),
404 "set should assign an id when none is provided"
405 );
406 })
407 .await;
408 }
409
410 #[tokio::test]
411 async fn delete_nonexistent_is_ok() {
412 let store = make_store().await;
413 TenantContext::scope("acme", async {
414 let result = store.delete("no-task", "no-id").await;
415 assert!(
416 result.is_ok(),
417 "deleting a nonexistent config should not error"
418 );
419 })
420 .await;
421 }
422
423 #[test]
425 fn to_a2a_error_formats_message() {
426 let sqlite_err = sqlx::Error::RowNotFound;
427 let a2a_err = to_a2a_error(&sqlite_err);
428 let msg = format!("{a2a_err}");
429 assert!(
430 msg.contains("sqlite error"),
431 "error message should contain 'sqlite error': {msg}"
432 );
433 }
434
435 #[tokio::test]
436 async fn default_tenant_context_uses_empty_string() {
437 let store = make_store().await;
438 store
440 .set(make_config("task-1", Some("cfg-1"), "https://default.com"))
441 .await
442 .unwrap();
443 let config = store.get("task-1", "cfg-1").await.unwrap();
444 assert!(config.is_some(), "default (empty) tenant should work");
445 }
446}