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