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]
291 async fn set_without_task_id_returns_invalid_params() {
292 let store = InMemoryPushConfigStore::new();
293 let config = TaskPushNotificationConfig {
294 tenant: None,
295 id: None,
296 task_id: None,
297 url: "https://example.com/hook".to_string(),
298 token: None,
299 authentication: None,
300 };
301 let err = store
302 .set(config)
303 .await
304 .expect_err("None task_id must be rejected");
305 assert!(err.to_string().contains("taskId"), "got: {err}");
306 }
307
308 #[tokio::test]
309 async fn set_assigns_id_when_none() {
310 let store = InMemoryPushConfigStore::new();
311 let config = make_config("task-1", None, "https://example.com/hook");
312 let result = store.set(config).await.expect("set should succeed");
313 assert!(
314 result.id.is_some(),
315 "set should assign an id when none is provided"
316 );
317 }
318
319 #[tokio::test]
320 async fn set_preserves_explicit_id() {
321 let store = InMemoryPushConfigStore::new();
322 let config = make_config("task-1", Some("my-id"), "https://example.com/hook");
323 let result = store.set(config).await.expect("set should succeed");
324 assert_eq!(
325 result.id.as_deref(),
326 Some("my-id"),
327 "set should preserve the explicitly provided id"
328 );
329 }
330
331 #[tokio::test]
332 async fn get_returns_none_for_missing_config() {
333 let store = InMemoryPushConfigStore::new();
334 let result = store
335 .get("no-task", "no-id")
336 .await
337 .expect("get should succeed");
338 assert!(
339 result.is_none(),
340 "get should return None for a non-existent config"
341 );
342 }
343
344 #[tokio::test]
345 async fn set_then_get_round_trip() {
346 let store = InMemoryPushConfigStore::new();
347 let config = make_config("task-1", Some("cfg-1"), "https://example.com/hook");
348 store.set(config).await.expect("set should succeed");
349
350 let retrieved = store
351 .get("task-1", "cfg-1")
352 .await
353 .expect("get should succeed")
354 .expect("config should exist after set");
355 assert_eq!(retrieved.task_id.as_deref(), Some("task-1"));
356 assert_eq!(retrieved.url, "https://example.com/hook");
357 }
358
359 #[tokio::test]
360 async fn overwrite_existing_config() {
361 let store = InMemoryPushConfigStore::new();
362 let config1 = make_config("task-1", Some("cfg-1"), "https://example.com/v1");
363 store.set(config1).await.expect("first set should succeed");
364
365 let config2 = make_config("task-1", Some("cfg-1"), "https://example.com/v2");
366 store
367 .set(config2)
368 .await
369 .expect("overwrite set should succeed");
370
371 let retrieved = store
372 .get("task-1", "cfg-1")
373 .await
374 .expect("get should succeed")
375 .expect("config should exist");
376 assert_eq!(
377 retrieved.url, "https://example.com/v2",
378 "overwrite should update the URL"
379 );
380 }
381
382 #[tokio::test]
383 async fn list_returns_empty_for_unknown_task() {
384 let store = InMemoryPushConfigStore::new();
385 let configs = store
386 .list("no-such-task")
387 .await
388 .expect("list should succeed");
389 assert!(
390 configs.is_empty(),
391 "list should return empty vec for unknown task"
392 );
393 }
394
395 #[tokio::test]
396 async fn list_returns_only_configs_for_given_task() {
397 let store = InMemoryPushConfigStore::new();
398 store
399 .set(make_config("task-a", Some("c1"), "https://a.com/1"))
400 .await
401 .unwrap();
402 store
403 .set(make_config("task-a", Some("c2"), "https://a.com/2"))
404 .await
405 .unwrap();
406 store
407 .set(make_config("task-b", Some("c3"), "https://b.com/1"))
408 .await
409 .unwrap();
410
411 let a_configs = store.list("task-a").await.expect("list should succeed");
412 assert_eq!(a_configs.len(), 2, "task-a should have exactly 2 configs");
413
414 let b_configs = store.list("task-b").await.expect("list should succeed");
415 assert_eq!(b_configs.len(), 1, "task-b should have exactly 1 config");
416 }
417
418 #[tokio::test]
419 async fn delete_removes_config() {
420 let store = InMemoryPushConfigStore::new();
421 store
422 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
423 .await
424 .unwrap();
425
426 store
427 .delete("task-1", "cfg-1")
428 .await
429 .expect("delete should succeed");
430
431 let result = store.get("task-1", "cfg-1").await.unwrap();
432 assert!(result.is_none(), "config should be gone after delete");
433 }
434
435 #[tokio::test]
436 async fn delete_nonexistent_is_ok() {
437 let store = InMemoryPushConfigStore::new();
438 let result = store.delete("no-task", "no-id").await;
439 assert!(
440 result.is_ok(),
441 "deleting a non-existent config should not error"
442 );
443 }
444
445 #[tokio::test]
446 async fn max_configs_per_task_limit_enforced() {
447 let store = InMemoryPushConfigStore::with_max_configs_per_task(2);
448 store
449 .set(make_config("task-1", Some("c1"), "https://a.com"))
450 .await
451 .unwrap();
452 store
453 .set(make_config("task-1", Some("c2"), "https://b.com"))
454 .await
455 .unwrap();
456
457 let err = store
458 .set(make_config("task-1", Some("c3"), "https://c.com"))
459 .await
460 .expect_err("third config should exceed per-task limit");
461 let msg = format!("{err}");
462 assert!(
463 msg.contains("limit exceeded"),
464 "error message should mention limit exceeded, got: {msg}"
465 );
466 }
467
468 #[tokio::test]
469 async fn per_task_limit_does_not_block_other_tasks() {
470 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
471 store
472 .set(make_config("task-1", Some("c1"), "https://a.com"))
473 .await
474 .unwrap();
475
476 let result = store
478 .set(make_config("task-2", Some("c1"), "https://b.com"))
479 .await;
480 assert!(
481 result.is_ok(),
482 "per-task limit should not block a different task"
483 );
484 }
485
486 #[tokio::test]
487 async fn overwrite_does_not_count_toward_per_task_limit() {
488 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
489 store
490 .set(make_config("task-1", Some("c1"), "https://a.com"))
491 .await
492 .unwrap();
493
494 let result = store
496 .set(make_config("task-1", Some("c1"), "https://b.com"))
497 .await;
498 assert!(
499 result.is_ok(),
500 "overwriting an existing config should not count toward the limit"
501 );
502 }
503
504 #[tokio::test]
505 async fn max_total_configs_limit_enforced() {
506 let store =
507 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(2);
508 store
509 .set(make_config("t1", Some("c1"), "https://a.com"))
510 .await
511 .unwrap();
512 store
513 .set(make_config("t2", Some("c2"), "https://b.com"))
514 .await
515 .unwrap();
516
517 let err = store
518 .set(make_config("t3", Some("c3"), "https://c.com"))
519 .await
520 .expect_err("third config should exceed global limit");
521 let msg = format!("{err}");
522 assert!(
523 msg.contains("global push config limit exceeded"),
524 "error should mention global limit, got: {msg}"
525 );
526 }
527
528 #[tokio::test]
529 async fn overwrite_does_not_count_toward_global_limit() {
530 let store =
531 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(1);
532 store
533 .set(make_config("t1", Some("c1"), "https://a.com"))
534 .await
535 .unwrap();
536
537 let result = store
539 .set(make_config("t1", Some("c1"), "https://b.com"))
540 .await;
541 assert!(
542 result.is_ok(),
543 "overwriting should not count toward global limit"
544 );
545 }
546
547 #[tokio::test]
560 async fn deleting_one_config_does_not_reset_the_per_task_limit() {
561 let store = InMemoryPushConfigStore::with_max_configs_per_task(2);
562
563 store
564 .set(make_config("task-1", Some("c1"), "https://a.com"))
565 .await
566 .expect("first config is within the cap");
567 store
568 .set(make_config("task-1", Some("c2"), "https://b.com"))
569 .await
570 .expect("second config is within the cap");
571
572 store.delete("task-1", "c1").await.expect("delete c1");
573
574 store
576 .set(make_config("task-1", Some("c3"), "https://c.com"))
577 .await
578 .expect("the delete freed exactly one slot");
579
580 let over = store
581 .set(make_config("task-1", Some("c4"), "https://d.com"))
582 .await;
583 assert!(
584 over.is_err(),
585 "the task is back at its cap of 2, so a further config must be \
586 refused; acceptance means the delete wiped the count entry and \
587 the per-task limit stopped being enforced"
588 );
589 }
590}