a2a_protocol_server/push/
config_store.rs1use std::collections::HashMap;
9use std::future::Future;
10use std::pin::Pin;
11
12use a2a_protocol_types::error::A2aResult;
13use a2a_protocol_types::push::TaskPushNotificationConfig;
14use tokio::sync::RwLock;
15
16pub trait PushConfigStore: Send + Sync + 'static {
20 fn set<'a>(
26 &'a self,
27 config: TaskPushNotificationConfig,
28 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>>;
29
30 fn get<'a>(
36 &'a self,
37 task_id: &'a str,
38 id: &'a str,
39 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>;
40
41 fn list<'a>(
47 &'a self,
48 task_id: &'a str,
49 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>>;
50
51 fn delete<'a>(
57 &'a self,
58 task_id: &'a str,
59 id: &'a str,
60 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>>;
61
62 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
79 Box::pin(async { Ok(None) })
80 }
81}
82
83const DEFAULT_MAX_PUSH_CONFIGS_PER_TASK: usize = 100;
85
86const DEFAULT_MAX_TOTAL_PUSH_CONFIGS: usize = 100_000;
89
90#[derive(Debug)]
95pub struct InMemoryPushConfigStore {
96 configs: RwLock<HashMap<(String, String), TaskPushNotificationConfig>>,
97 task_counts: RwLock<HashMap<String, usize>>,
99 max_configs_per_task: usize,
101 max_total_configs: usize,
103}
104
105impl Default for InMemoryPushConfigStore {
106 fn default() -> Self {
107 Self {
108 configs: RwLock::new(HashMap::new()),
109 task_counts: RwLock::new(HashMap::new()),
110 max_configs_per_task: DEFAULT_MAX_PUSH_CONFIGS_PER_TASK,
111 max_total_configs: DEFAULT_MAX_TOTAL_PUSH_CONFIGS,
112 }
113 }
114}
115
116impl InMemoryPushConfigStore {
117 #[must_use]
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 #[must_use]
125 pub fn with_max_configs_per_task(max: usize) -> Self {
126 Self {
127 configs: RwLock::new(HashMap::new()),
128 task_counts: RwLock::new(HashMap::new()),
129 max_configs_per_task: max,
130 max_total_configs: DEFAULT_MAX_TOTAL_PUSH_CONFIGS,
131 }
132 }
133
134 #[must_use]
139 pub const fn with_max_total_configs(mut self, max: usize) -> Self {
140 self.max_total_configs = max;
141 self
142 }
143}
144
145#[allow(clippy::manual_async_fn)]
146impl PushConfigStore for InMemoryPushConfigStore {
147 fn set<'a>(
148 &'a self,
149 mut config: TaskPushNotificationConfig,
150 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>> {
151 Box::pin(async move {
152 let Some(task_id) = config.task_id.clone() else {
155 return Err(a2a_protocol_types::error::A2aError::invalid_params(
156 "taskId is required to store a push notification config",
157 ));
158 };
159 let id = config
161 .id
162 .clone()
163 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
164 config.id = Some(id.clone());
165
166 let key = (task_id.clone(), id);
167 let mut store = self.configs.write().await;
168 let mut counts = self.task_counts.write().await;
169
170 let is_new = !store.contains_key(&key);
172 if is_new {
173 let total = store.len();
175 if total >= self.max_total_configs {
176 drop(counts);
177 drop(store);
178 return Err(a2a_protocol_types::error::A2aError::invalid_params(
179 format!(
180 "global push config limit exceeded: {total} configs (max {})",
181 self.max_total_configs,
182 ),
183 ));
184 }
185 let count = counts.get(&task_id).copied().unwrap_or(0);
188 let max = self.max_configs_per_task;
189 if count >= max {
190 drop(counts);
191 drop(store);
192 return Err(a2a_protocol_types::error::A2aError::invalid_params(
193 format!(
194 "push config limit exceeded: task {task_id} already has {count} configs (max {max})"
195 ),
196 ));
197 }
198 }
199
200 store.insert(key, config.clone());
201 if is_new {
202 *counts.entry(task_id).or_insert(0) += 1;
203 }
204 drop(counts);
205 drop(store);
206 Ok(config)
207 })
208 }
209
210 fn get<'a>(
211 &'a self,
212 task_id: &'a str,
213 id: &'a str,
214 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
215 {
216 Box::pin(async move {
217 let store = self.configs.read().await;
218 let key = (task_id.to_owned(), id.to_owned());
219 let result = store.get(&key).cloned();
220 drop(store);
221 Ok(result)
222 })
223 }
224
225 fn list<'a>(
226 &'a self,
227 task_id: &'a str,
228 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
229 Box::pin(async move {
230 let store = self.configs.read().await;
231 let mut configs: Vec<_> = store
232 .iter()
233 .filter(|((tid, _), _)| tid == task_id)
234 .map(|(_, v)| v.clone())
235 .collect();
236 drop(store);
237 configs.sort_by(|a, b| a.task_id.cmp(&b.task_id).then_with(|| a.id.cmp(&b.id)));
239 Ok(configs)
240 })
241 }
242
243 fn delete<'a>(
244 &'a self,
245 task_id: &'a str,
246 id: &'a str,
247 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
248 Box::pin(async move {
249 let mut store = self.configs.write().await;
250 let mut counts = self.task_counts.write().await;
251 let key = (task_id.to_owned(), id.to_owned());
252 if store.remove(&key).is_some() {
253 if let Some(count) = counts.get_mut(task_id) {
255 *count = count.saturating_sub(1);
256 if *count == 0 {
257 counts.remove(task_id);
258 }
259 }
260 }
261 drop(counts);
262 drop(store);
263 Ok(())
264 })
265 }
266
267 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
268 Box::pin(async move { Ok(Some(self.configs.read().await.len())) })
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use a2a_protocol_types::push::TaskPushNotificationConfig;
276
277 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
278 TaskPushNotificationConfig {
279 tenant: None,
280 id: id.map(String::from),
281 task_id: Some(task_id.to_string()),
282 url: url.to_string(),
283 token: None,
284 authentication: None,
285 }
286 }
287
288 #[tokio::test]
294 async fn a_store_without_count_reports_none() {
295 struct RequiredOnly;
296 impl PushConfigStore for RequiredOnly {
297 fn set<'a>(
298 &'a self,
299 config: TaskPushNotificationConfig,
300 ) -> Pin<Box<dyn Future<Output = A2aResult<TaskPushNotificationConfig>> + Send + 'a>>
301 {
302 Box::pin(async move { Ok(config) })
303 }
304 fn get<'a>(
305 &'a self,
306 _task_id: &'a str,
307 _id: &'a str,
308 ) -> Pin<
309 Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>,
310 > {
311 Box::pin(async { Ok(None) })
312 }
313 fn list<'a>(
314 &'a self,
315 _task_id: &'a str,
316 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>>
317 {
318 Box::pin(async { Ok(Vec::new()) })
319 }
320 fn delete<'a>(
321 &'a self,
322 _task_id: &'a str,
323 _id: &'a str,
324 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
325 Box::pin(async { Ok(()) })
326 }
327 }
328 assert_eq!(RequiredOnly.count().await.expect("count"), None);
329 }
330
331 #[tokio::test]
334 async fn set_without_task_id_returns_invalid_params() {
335 let store = InMemoryPushConfigStore::new();
336 let config = TaskPushNotificationConfig {
337 tenant: None,
338 id: None,
339 task_id: None,
340 url: "https://example.com/hook".to_string(),
341 token: None,
342 authentication: None,
343 };
344 let err = store
345 .set(config)
346 .await
347 .expect_err("None task_id must be rejected");
348 assert!(err.to_string().contains("taskId"), "got: {err}");
349 }
350
351 #[tokio::test]
352 async fn set_assigns_id_when_none() {
353 let store = InMemoryPushConfigStore::new();
354 let config = make_config("task-1", None, "https://example.com/hook");
355 let result = store.set(config).await.expect("set should succeed");
356 assert!(
357 result.id.is_some(),
358 "set should assign an id when none is provided"
359 );
360 }
361
362 #[tokio::test]
363 async fn set_preserves_explicit_id() {
364 let store = InMemoryPushConfigStore::new();
365 let config = make_config("task-1", Some("my-id"), "https://example.com/hook");
366 let result = store.set(config).await.expect("set should succeed");
367 assert_eq!(
368 result.id.as_deref(),
369 Some("my-id"),
370 "set should preserve the explicitly provided id"
371 );
372 }
373
374 #[tokio::test]
375 async fn get_returns_none_for_missing_config() {
376 let store = InMemoryPushConfigStore::new();
377 let result = store
378 .get("no-task", "no-id")
379 .await
380 .expect("get should succeed");
381 assert!(
382 result.is_none(),
383 "get should return None for a non-existent config"
384 );
385 }
386
387 #[tokio::test]
388 async fn set_then_get_round_trip() {
389 let store = InMemoryPushConfigStore::new();
390 let config = make_config("task-1", Some("cfg-1"), "https://example.com/hook");
391 store.set(config).await.expect("set should succeed");
392
393 let retrieved = store
394 .get("task-1", "cfg-1")
395 .await
396 .expect("get should succeed")
397 .expect("config should exist after set");
398 assert_eq!(retrieved.task_id.as_deref(), Some("task-1"));
399 assert_eq!(retrieved.url, "https://example.com/hook");
400 }
401
402 #[tokio::test]
403 async fn overwrite_existing_config() {
404 let store = InMemoryPushConfigStore::new();
405 let config1 = make_config("task-1", Some("cfg-1"), "https://example.com/v1");
406 store.set(config1).await.expect("first set should succeed");
407
408 let config2 = make_config("task-1", Some("cfg-1"), "https://example.com/v2");
409 store
410 .set(config2)
411 .await
412 .expect("overwrite set should succeed");
413
414 let retrieved = store
415 .get("task-1", "cfg-1")
416 .await
417 .expect("get should succeed")
418 .expect("config should exist");
419 assert_eq!(
420 retrieved.url, "https://example.com/v2",
421 "overwrite should update the URL"
422 );
423 }
424
425 #[tokio::test]
426 async fn list_returns_empty_for_unknown_task() {
427 let store = InMemoryPushConfigStore::new();
428 let configs = store
429 .list("no-such-task")
430 .await
431 .expect("list should succeed");
432 assert!(
433 configs.is_empty(),
434 "list should return empty vec for unknown task"
435 );
436 }
437
438 #[tokio::test]
439 async fn list_returns_only_configs_for_given_task() {
440 let store = InMemoryPushConfigStore::new();
441 store
442 .set(make_config("task-a", Some("c1"), "https://a.com/1"))
443 .await
444 .unwrap();
445 store
446 .set(make_config("task-a", Some("c2"), "https://a.com/2"))
447 .await
448 .unwrap();
449 store
450 .set(make_config("task-b", Some("c3"), "https://b.com/1"))
451 .await
452 .unwrap();
453
454 let a_configs = store.list("task-a").await.expect("list should succeed");
455 assert_eq!(a_configs.len(), 2, "task-a should have exactly 2 configs");
456
457 let b_configs = store.list("task-b").await.expect("list should succeed");
458 assert_eq!(b_configs.len(), 1, "task-b should have exactly 1 config");
459 }
460
461 #[tokio::test]
462 async fn delete_removes_config() {
463 let store = InMemoryPushConfigStore::new();
464 store
465 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
466 .await
467 .unwrap();
468
469 store
470 .delete("task-1", "cfg-1")
471 .await
472 .expect("delete should succeed");
473
474 let result = store.get("task-1", "cfg-1").await.unwrap();
475 assert!(result.is_none(), "config should be gone after delete");
476 }
477
478 #[tokio::test]
479 async fn delete_nonexistent_is_ok() {
480 let store = InMemoryPushConfigStore::new();
481 let result = store.delete("no-task", "no-id").await;
482 assert!(
483 result.is_ok(),
484 "deleting a non-existent config should not error"
485 );
486 }
487
488 #[tokio::test]
489 async fn max_configs_per_task_limit_enforced() {
490 let store = InMemoryPushConfigStore::with_max_configs_per_task(2);
491 store
492 .set(make_config("task-1", Some("c1"), "https://a.com"))
493 .await
494 .unwrap();
495 store
496 .set(make_config("task-1", Some("c2"), "https://b.com"))
497 .await
498 .unwrap();
499
500 let err = store
501 .set(make_config("task-1", Some("c3"), "https://c.com"))
502 .await
503 .expect_err("third config should exceed per-task limit");
504 let msg = format!("{err}");
505 assert!(
506 msg.contains("limit exceeded"),
507 "error message should mention limit exceeded, got: {msg}"
508 );
509 }
510
511 #[tokio::test]
512 async fn per_task_limit_does_not_block_other_tasks() {
513 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
514 store
515 .set(make_config("task-1", Some("c1"), "https://a.com"))
516 .await
517 .unwrap();
518
519 let result = store
521 .set(make_config("task-2", Some("c1"), "https://b.com"))
522 .await;
523 assert!(
524 result.is_ok(),
525 "per-task limit should not block a different task"
526 );
527 }
528
529 #[tokio::test]
530 async fn overwrite_does_not_count_toward_per_task_limit() {
531 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
532 store
533 .set(make_config("task-1", Some("c1"), "https://a.com"))
534 .await
535 .unwrap();
536
537 let result = store
539 .set(make_config("task-1", Some("c1"), "https://b.com"))
540 .await;
541 assert!(
542 result.is_ok(),
543 "overwriting an existing config should not count toward the limit"
544 );
545 }
546
547 #[tokio::test]
548 async fn max_total_configs_limit_enforced() {
549 let store =
550 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(2);
551 store
552 .set(make_config("t1", Some("c1"), "https://a.com"))
553 .await
554 .unwrap();
555 store
556 .set(make_config("t2", Some("c2"), "https://b.com"))
557 .await
558 .unwrap();
559
560 let err = store
561 .set(make_config("t3", Some("c3"), "https://c.com"))
562 .await
563 .expect_err("third config should exceed global limit");
564 let msg = format!("{err}");
565 assert!(
566 msg.contains("global push config limit exceeded"),
567 "error should mention global limit, got: {msg}"
568 );
569 }
570
571 #[tokio::test]
572 async fn overwrite_does_not_count_toward_global_limit() {
573 let store =
574 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(1);
575 store
576 .set(make_config("t1", Some("c1"), "https://a.com"))
577 .await
578 .unwrap();
579
580 let result = store
582 .set(make_config("t1", Some("c1"), "https://b.com"))
583 .await;
584 assert!(
585 result.is_ok(),
586 "overwriting should not count toward global limit"
587 );
588 }
589
590 #[tokio::test]
603 async fn deleting_one_config_does_not_reset_the_per_task_limit() {
604 let store = InMemoryPushConfigStore::with_max_configs_per_task(2);
605
606 store
607 .set(make_config("task-1", Some("c1"), "https://a.com"))
608 .await
609 .expect("first config is within the cap");
610 store
611 .set(make_config("task-1", Some("c2"), "https://b.com"))
612 .await
613 .expect("second config is within the cap");
614
615 store.delete("task-1", "c1").await.expect("delete c1");
616
617 store
619 .set(make_config("task-1", Some("c3"), "https://c.com"))
620 .await
621 .expect("the delete freed exactly one slot");
622
623 let over = store
624 .set(make_config("task-1", Some("c4"), "https://d.com"))
625 .await;
626 assert!(
627 over.is_err(),
628 "the task is back at its cap of 2, so a further config must be \
629 refused; acceptance means the delete wiped the count entry and \
630 the per-task limit stopped being enforced"
631 );
632 }
633}