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(format!(
193 "push config limit exceeded: task {task_id} already has {count} configs (max {max})"
194 )));
195 }
196 }
197
198 store.insert(key, config.clone());
199 if is_new {
200 *counts.entry(task_id).or_insert(0) += 1;
201 }
202 drop(counts);
203 drop(store);
204 Ok(config)
205 })
206 }
207
208 fn get<'a>(
209 &'a self,
210 task_id: &'a str,
211 id: &'a str,
212 ) -> Pin<Box<dyn Future<Output = A2aResult<Option<TaskPushNotificationConfig>>> + Send + 'a>>
213 {
214 Box::pin(async move {
215 let store = self.configs.read().await;
216 let key = (task_id.to_owned(), id.to_owned());
217 let result = store.get(&key).cloned();
218 drop(store);
219 Ok(result)
220 })
221 }
222
223 fn list<'a>(
224 &'a self,
225 task_id: &'a str,
226 ) -> Pin<Box<dyn Future<Output = A2aResult<Vec<TaskPushNotificationConfig>>> + Send + 'a>> {
227 Box::pin(async move {
228 let store = self.configs.read().await;
229 let mut configs: Vec<_> = store
230 .iter()
231 .filter(|((tid, _), _)| tid == task_id)
232 .map(|(_, v)| v.clone())
233 .collect();
234 drop(store);
235 configs.sort_by(|a, b| a.task_id.cmp(&b.task_id).then_with(|| a.id.cmp(&b.id)));
237 Ok(configs)
238 })
239 }
240
241 fn delete<'a>(
242 &'a self,
243 task_id: &'a str,
244 id: &'a str,
245 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
246 Box::pin(async move {
247 let mut store = self.configs.write().await;
248 let mut counts = self.task_counts.write().await;
249 let key = (task_id.to_owned(), id.to_owned());
250 if store.remove(&key).is_some() {
251 if let Some(count) = counts.get_mut(task_id) {
253 *count = count.saturating_sub(1);
254 if *count == 0 {
255 counts.remove(task_id);
256 }
257 }
258 }
259 drop(counts);
260 drop(store);
261 Ok(())
262 })
263 }
264
265 fn count(&self) -> Pin<Box<dyn Future<Output = A2aResult<Option<usize>>> + Send + '_>> {
266 Box::pin(async move { Ok(Some(self.configs.read().await.len())) })
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use a2a_protocol_types::push::TaskPushNotificationConfig;
274
275 fn make_config(task_id: &str, id: Option<&str>, url: &str) -> TaskPushNotificationConfig {
276 TaskPushNotificationConfig {
277 tenant: None,
278 id: id.map(String::from),
279 task_id: Some(task_id.to_string()),
280 url: url.to_string(),
281 token: None,
282 authentication: None,
283 }
284 }
285
286 #[tokio::test]
289 async fn set_without_task_id_returns_invalid_params() {
290 let store = InMemoryPushConfigStore::new();
291 let config = TaskPushNotificationConfig {
292 tenant: None,
293 id: None,
294 task_id: None,
295 url: "https://example.com/hook".to_string(),
296 token: None,
297 authentication: None,
298 };
299 let err = store
300 .set(config)
301 .await
302 .expect_err("None task_id must be rejected");
303 assert!(err.to_string().contains("taskId"), "got: {err}");
304 }
305
306 #[tokio::test]
307 async fn set_assigns_id_when_none() {
308 let store = InMemoryPushConfigStore::new();
309 let config = make_config("task-1", None, "https://example.com/hook");
310 let result = store.set(config).await.expect("set should succeed");
311 assert!(
312 result.id.is_some(),
313 "set should assign an id when none is provided"
314 );
315 }
316
317 #[tokio::test]
318 async fn set_preserves_explicit_id() {
319 let store = InMemoryPushConfigStore::new();
320 let config = make_config("task-1", Some("my-id"), "https://example.com/hook");
321 let result = store.set(config).await.expect("set should succeed");
322 assert_eq!(
323 result.id.as_deref(),
324 Some("my-id"),
325 "set should preserve the explicitly provided id"
326 );
327 }
328
329 #[tokio::test]
330 async fn get_returns_none_for_missing_config() {
331 let store = InMemoryPushConfigStore::new();
332 let result = store
333 .get("no-task", "no-id")
334 .await
335 .expect("get should succeed");
336 assert!(
337 result.is_none(),
338 "get should return None for a non-existent config"
339 );
340 }
341
342 #[tokio::test]
343 async fn set_then_get_round_trip() {
344 let store = InMemoryPushConfigStore::new();
345 let config = make_config("task-1", Some("cfg-1"), "https://example.com/hook");
346 store.set(config).await.expect("set should succeed");
347
348 let retrieved = store
349 .get("task-1", "cfg-1")
350 .await
351 .expect("get should succeed")
352 .expect("config should exist after set");
353 assert_eq!(retrieved.task_id.as_deref(), Some("task-1"));
354 assert_eq!(retrieved.url, "https://example.com/hook");
355 }
356
357 #[tokio::test]
358 async fn overwrite_existing_config() {
359 let store = InMemoryPushConfigStore::new();
360 let config1 = make_config("task-1", Some("cfg-1"), "https://example.com/v1");
361 store.set(config1).await.expect("first set should succeed");
362
363 let config2 = make_config("task-1", Some("cfg-1"), "https://example.com/v2");
364 store
365 .set(config2)
366 .await
367 .expect("overwrite set should succeed");
368
369 let retrieved = store
370 .get("task-1", "cfg-1")
371 .await
372 .expect("get should succeed")
373 .expect("config should exist");
374 assert_eq!(
375 retrieved.url, "https://example.com/v2",
376 "overwrite should update the URL"
377 );
378 }
379
380 #[tokio::test]
381 async fn list_returns_empty_for_unknown_task() {
382 let store = InMemoryPushConfigStore::new();
383 let configs = store
384 .list("no-such-task")
385 .await
386 .expect("list should succeed");
387 assert!(
388 configs.is_empty(),
389 "list should return empty vec for unknown task"
390 );
391 }
392
393 #[tokio::test]
394 async fn list_returns_only_configs_for_given_task() {
395 let store = InMemoryPushConfigStore::new();
396 store
397 .set(make_config("task-a", Some("c1"), "https://a.com/1"))
398 .await
399 .unwrap();
400 store
401 .set(make_config("task-a", Some("c2"), "https://a.com/2"))
402 .await
403 .unwrap();
404 store
405 .set(make_config("task-b", Some("c3"), "https://b.com/1"))
406 .await
407 .unwrap();
408
409 let a_configs = store.list("task-a").await.expect("list should succeed");
410 assert_eq!(a_configs.len(), 2, "task-a should have exactly 2 configs");
411
412 let b_configs = store.list("task-b").await.expect("list should succeed");
413 assert_eq!(b_configs.len(), 1, "task-b should have exactly 1 config");
414 }
415
416 #[tokio::test]
417 async fn delete_removes_config() {
418 let store = InMemoryPushConfigStore::new();
419 store
420 .set(make_config("task-1", Some("cfg-1"), "https://example.com"))
421 .await
422 .unwrap();
423
424 store
425 .delete("task-1", "cfg-1")
426 .await
427 .expect("delete should succeed");
428
429 let result = store.get("task-1", "cfg-1").await.unwrap();
430 assert!(result.is_none(), "config should be gone after delete");
431 }
432
433 #[tokio::test]
434 async fn delete_nonexistent_is_ok() {
435 let store = InMemoryPushConfigStore::new();
436 let result = store.delete("no-task", "no-id").await;
437 assert!(
438 result.is_ok(),
439 "deleting a non-existent config should not error"
440 );
441 }
442
443 #[tokio::test]
444 async fn max_configs_per_task_limit_enforced() {
445 let store = InMemoryPushConfigStore::with_max_configs_per_task(2);
446 store
447 .set(make_config("task-1", Some("c1"), "https://a.com"))
448 .await
449 .unwrap();
450 store
451 .set(make_config("task-1", Some("c2"), "https://b.com"))
452 .await
453 .unwrap();
454
455 let err = store
456 .set(make_config("task-1", Some("c3"), "https://c.com"))
457 .await
458 .expect_err("third config should exceed per-task limit");
459 let msg = format!("{err}");
460 assert!(
461 msg.contains("limit exceeded"),
462 "error message should mention limit exceeded, got: {msg}"
463 );
464 }
465
466 #[tokio::test]
467 async fn per_task_limit_does_not_block_other_tasks() {
468 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
469 store
470 .set(make_config("task-1", Some("c1"), "https://a.com"))
471 .await
472 .unwrap();
473
474 let result = store
476 .set(make_config("task-2", Some("c1"), "https://b.com"))
477 .await;
478 assert!(
479 result.is_ok(),
480 "per-task limit should not block a different task"
481 );
482 }
483
484 #[tokio::test]
485 async fn overwrite_does_not_count_toward_per_task_limit() {
486 let store = InMemoryPushConfigStore::with_max_configs_per_task(1);
487 store
488 .set(make_config("task-1", Some("c1"), "https://a.com"))
489 .await
490 .unwrap();
491
492 let result = store
494 .set(make_config("task-1", Some("c1"), "https://b.com"))
495 .await;
496 assert!(
497 result.is_ok(),
498 "overwriting an existing config should not count toward the limit"
499 );
500 }
501
502 #[tokio::test]
503 async fn max_total_configs_limit_enforced() {
504 let store =
505 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(2);
506 store
507 .set(make_config("t1", Some("c1"), "https://a.com"))
508 .await
509 .unwrap();
510 store
511 .set(make_config("t2", Some("c2"), "https://b.com"))
512 .await
513 .unwrap();
514
515 let err = store
516 .set(make_config("t3", Some("c3"), "https://c.com"))
517 .await
518 .expect_err("third config should exceed global limit");
519 let msg = format!("{err}");
520 assert!(
521 msg.contains("global push config limit exceeded"),
522 "error should mention global limit, got: {msg}"
523 );
524 }
525
526 #[tokio::test]
527 async fn overwrite_does_not_count_toward_global_limit() {
528 let store =
529 InMemoryPushConfigStore::with_max_configs_per_task(100).with_max_total_configs(1);
530 store
531 .set(make_config("t1", Some("c1"), "https://a.com"))
532 .await
533 .unwrap();
534
535 let result = store
537 .set(make_config("t1", Some("c1"), "https://b.com"))
538 .await;
539 assert!(
540 result.is_ok(),
541 "overwriting should not count toward global limit"
542 );
543 }
544}