Skip to main content

protocol_droid/domain/a2a/
push_notification_methods.rs

1//! A2A push-notification method envelopes —
2//! the four methods that read and write a
3//! task's push-notification configurations.
4//!
5//! # What these are
6//!
7//! [`CreateTaskPushNotificationConfigRequest`]
8//! and
9//! [`CreateTaskPushNotificationConfigResponse`]
10//! are the typed shapes for the
11//! `CreateTaskPushNotificationConfig` JSON-RPC
12//! method — register a new webhook for a task.
13//!
14//! [`GetTaskPushNotificationConfigRequest`]
15//! and
16//! [`GetTaskPushNotificationConfigResponse`]
17//! are the typed shapes for the
18//! `GetTaskPushNotificationConfig` JSON-RPC
19//! method — fetch a single config by id.
20//!
21//! [`ListTaskPushNotificationConfigsRequest`]
22//! and
23//! [`ListTaskPushNotificationConfigsResponse`]
24//! are the typed shapes for the
25//! `ListTaskPushNotificationConfigs`
26//! JSON-RPC method — list all configs for a
27//! task.
28//!
29//! [`DeleteTaskPushNotificationConfigRequest`]
30//! and
31//! [`DeleteTaskPushNotificationConfigResponse`]
32//! are the typed shapes for the
33//! `DeleteTaskPushNotificationConfig`
34//! JSON-RPC method — remove a config by id.
35//!
36//! # Why the response is `TaskPushNotificationConfig`
37//! directly, not a wrapper
38//!
39//! Each of the three read/write methods
40//! returns a [`TaskPushNotificationConfig`]
41//! value. The shape is the inner value
42//! directly — no wrapper object — so the
43//! wire form is the spec's
44//! `{"id": "...", "taskId": "...", "url": "..."}`.
45//! A newtype with `transparent` is the
46//! cheapest shape that keeps the type
47//! distinct from a raw
48//! `TaskPushNotificationConfig` (and lets
49//! the consumer pattern-match on the
50//! response type if they want to).
51//!
52//! # Why the delete response is `{}`
53//!
54//! The spec uses `google.protobuf.Empty` for
55//! the delete response, which serialises to
56//! `{}` on the wire. A handler that wants to
57//! signal success does not have to write a
58//! payload; the JSON-RPC layer's
59//! `result: null` or `result: {}` is the
60//! success signal.
61
62use std::collections::BTreeMap;
63
64use serde::{Deserialize, Serialize};
65
66use super::push_notification::TaskPushNotificationConfig;
67
68// --- CreateTaskPushNotificationConfig ---------------------------
69
70/// A typed `CreateTaskPushNotificationConfig`
71/// request. The wire form is the
72/// [`TaskPushNotificationConfig`] object
73/// directly.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(transparent)]
76pub struct CreateTaskPushNotificationConfigRequest(pub TaskPushNotificationConfig);
77
78impl From<TaskPushNotificationConfig> for CreateTaskPushNotificationConfigRequest {
79    fn from(config: TaskPushNotificationConfig) -> Self {
80        Self(config)
81    }
82}
83
84impl std::ops::Deref for CreateTaskPushNotificationConfigRequest {
85    type Target = TaskPushNotificationConfig;
86
87    fn deref(&self) -> &Self::Target {
88        &self.0
89    }
90}
91
92/// A typed `CreateTaskPushNotificationConfig`
93/// response. The wire form is the
94/// [`TaskPushNotificationConfig`] object
95/// directly.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct CreateTaskPushNotificationConfigResponse(pub TaskPushNotificationConfig);
99
100impl From<TaskPushNotificationConfig> for CreateTaskPushNotificationConfigResponse {
101    fn from(config: TaskPushNotificationConfig) -> Self {
102        Self(config)
103    }
104}
105
106impl std::ops::Deref for CreateTaskPushNotificationConfigResponse {
107    type Target = TaskPushNotificationConfig;
108
109    fn deref(&self) -> &Self::Target {
110        &self.0
111    }
112}
113
114// --- GetTaskPushNotificationConfig -------------------------------
115
116/// A typed `GetTaskPushNotificationConfig`
117/// request. Wire form:
118/// ```json
119/// { "id": "config-1", "taskId": "task-1" }
120/// ```
121///
122/// Both fields are required.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct GetTaskPushNotificationConfigRequest {
126    /// The config's id.
127    pub id: String,
128    /// The parent task's id.
129    pub task_id: String,
130}
131
132impl GetTaskPushNotificationConfigRequest {
133    /// Starts a new request with `id` and
134    /// `task_id`.
135    #[must_use]
136    pub fn new(id: impl Into<String>, task_id: impl Into<String>) -> Self {
137        Self {
138            id: id.into(),
139            task_id: task_id.into(),
140        }
141    }
142}
143
144/// A typed `GetTaskPushNotificationConfig`
145/// response. The wire form is the
146/// [`TaskPushNotificationConfig`] object
147/// directly.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(transparent)]
150pub struct GetTaskPushNotificationConfigResponse(pub TaskPushNotificationConfig);
151
152impl From<TaskPushNotificationConfig> for GetTaskPushNotificationConfigResponse {
153    fn from(config: TaskPushNotificationConfig) -> Self {
154        Self(config)
155    }
156}
157
158impl std::ops::Deref for GetTaskPushNotificationConfigResponse {
159    type Target = TaskPushNotificationConfig;
160
161    fn deref(&self) -> &Self::Target {
162        &self.0
163    }
164}
165
166// --- ListTaskPushNotificationConfigs -----------------------------
167
168/// A typed `ListTaskPushNotificationConfigs`
169/// request. Wire form:
170/// ```json
171/// {
172///   "taskId": "task-1",
173///   "pageSize": 50,
174///   "pageToken": "..."
175/// }
176/// ```
177///
178/// `taskId` is required; `pageSize` and
179/// `pageToken` are optional.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ListTaskPushNotificationConfigsRequest {
183    /// The parent task's id. Required.
184    pub task_id: String,
185    /// The maximum number of configurations
186    /// to return. A request with no
187    /// page size lets the agent choose.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub page_size: Option<u32>,
190    /// A page token received from a
191    /// previous
192    /// `ListTaskPushNotificationConfigsResponse`
193    /// call. The agent uses it to continue
194    /// from the last page.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub page_token: Option<String>,
197}
198
199impl ListTaskPushNotificationConfigsRequest {
200    /// Starts a new request with `task_id`.
201    /// Use [`with_page_size`](Self::with_page_size)
202    /// and
203    /// [`with_page_token`](Self::with_page_token)
204    /// to set the pagination fields.
205    #[must_use]
206    pub fn new(task_id: impl Into<String>) -> Self {
207        Self {
208            task_id: task_id.into(),
209            page_size: None,
210            page_token: None,
211        }
212    }
213
214    /// Sets the page size.
215    #[must_use]
216    pub const fn with_page_size(mut self, page_size: u32) -> Self {
217        self.page_size = Some(page_size);
218        self
219    }
220
221    /// Sets the page token.
222    #[must_use]
223    pub fn with_page_token(mut self, page_token: impl Into<String>) -> Self {
224        self.page_token = Some(page_token.into());
225        self
226    }
227}
228
229/// A typed `ListTaskPushNotificationConfigs`
230/// response. Wire form:
231/// ```json
232/// {
233///   "configs": [TaskPushNotificationConfig, ...],
234///   "nextPageToken": "..."
235/// }
236/// ```
237#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct ListTaskPushNotificationConfigsResponse {
240    /// The configurations. An empty list is
241    /// a task that has no configs.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub configs: Vec<TaskPushNotificationConfig>,
244    /// A token to retrieve the next page of
245    /// results, or empty if there are no
246    /// more results in the list. A response
247    /// with no `nextPageToken` is the
248    /// last page.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub next_page_token: Option<String>,
251}
252
253impl ListTaskPushNotificationConfigsResponse {
254    /// Starts a new response with `configs`.
255    /// Use
256    /// [`with_next_page_token`](Self::with_next_page_token)
257    /// to attach a token.
258    #[must_use]
259    pub fn new(configs: impl IntoIterator<Item = TaskPushNotificationConfig>) -> Self {
260        Self {
261            configs: configs.into_iter().collect(),
262            next_page_token: None,
263        }
264    }
265
266    /// Sets the next page token.
267    #[must_use]
268    pub fn with_next_page_token(mut self, next_page_token: impl Into<String>) -> Self {
269        self.next_page_token = Some(next_page_token.into());
270        self
271    }
272}
273
274// --- DeleteTaskPushNotificationConfig ----------------------------
275
276/// A typed `DeleteTaskPushNotificationConfig`
277/// request. Wire form:
278/// ```json
279/// { "id": "config-1", "taskId": "task-1" }
280/// ```
281///
282/// Both fields are required.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct DeleteTaskPushNotificationConfigRequest {
286    /// The config's id.
287    pub id: String,
288    /// The parent task's id.
289    pub task_id: String,
290}
291
292impl DeleteTaskPushNotificationConfigRequest {
293    /// Starts a new request with `id` and
294    /// `task_id`.
295    #[must_use]
296    pub fn new(id: impl Into<String>, task_id: impl Into<String>) -> Self {
297        Self {
298            id: id.into(),
299            task_id: task_id.into(),
300        }
301    }
302}
303
304/// A typed `DeleteTaskPushNotificationConfig`
305/// response. The wire form is `{}` (the
306/// spec's `google.protobuf.Empty`).
307#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
308pub struct DeleteTaskPushNotificationConfigResponse {
309    /// A catch-all for the metadata. The
310    /// spec does not name a field, but a
311    /// server that wants to include a
312    /// trailing note (e.g. `"deleted_at"`:
313    /// `"2025-01-01T00:00:00Z"`) can use
314    /// this map.
315    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
316    pub metadata: BTreeMap<String, serde_json::Value>,
317}
318
319#[cfg(test)]
320mod tests {
321    use super::{
322        CreateTaskPushNotificationConfigRequest, CreateTaskPushNotificationConfigResponse,
323        DeleteTaskPushNotificationConfigRequest, DeleteTaskPushNotificationConfigResponse,
324        GetTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigResponse,
325        ListTaskPushNotificationConfigsRequest, ListTaskPushNotificationConfigsResponse,
326    };
327    use crate::domain::a2a::push_notification::TaskPushNotificationConfig;
328
329    type TestError = Box<dyn std::error::Error>;
330
331    fn sample_config() -> TaskPushNotificationConfig {
332        TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh")
333    }
334
335    // --- Create -----------------------------------------------------
336
337    #[test]
338    fn create_request_serialises_as_config_object() -> Result<(), TestError> {
339        let request = CreateTaskPushNotificationConfigRequest::from(sample_config());
340        let value = serde_json::to_value(&request)?;
341        let obj = value
342            .as_object()
343            .ok_or("a CreateTaskPushNotificationConfigRequest must serialise to a JSON object")?;
344        assert_eq!(
345            obj.get("id").and_then(serde_json::Value::as_str),
346            Some("c-1")
347        );
348        assert_eq!(
349            obj.get("taskId").and_then(serde_json::Value::as_str),
350            Some("t-1")
351        );
352        // The wire form is the inner
353        // config — no wrapper.
354        assert!(!obj.contains_key("config"));
355        Ok(())
356    }
357
358    #[test]
359    fn create_response_from_config_via_into_works() {
360        let config = sample_config();
361        let response: CreateTaskPushNotificationConfigResponse = config.clone().into();
362        assert_eq!(response.0, config);
363    }
364
365    // --- Get --------------------------------------------------------
366
367    #[test]
368    fn get_request_serialises_with_id_and_task_id() -> Result<(), TestError> {
369        let request = GetTaskPushNotificationConfigRequest::new("c-1", "t-1");
370        let value = serde_json::to_value(&request)?;
371        let obj = value
372            .as_object()
373            .ok_or("a GetTaskPushNotificationConfigRequest must serialise to a JSON object")?;
374        assert_eq!(
375            obj.get("id").and_then(serde_json::Value::as_str),
376            Some("c-1")
377        );
378        assert_eq!(
379            obj.get("taskId").and_then(serde_json::Value::as_str),
380            Some("t-1")
381        );
382        Ok(())
383    }
384
385    #[test]
386    fn get_response_serialises_as_config_object() -> Result<(), TestError> {
387        let response = GetTaskPushNotificationConfigResponse::from(sample_config());
388        let value = serde_json::to_value(&response)?;
389        let obj = value
390            .as_object()
391            .ok_or("a GetTaskPushNotificationConfigResponse must serialise to a JSON object")?;
392        assert_eq!(
393            obj.get("id").and_then(serde_json::Value::as_str),
394            Some("c-1")
395        );
396        Ok(())
397    }
398
399    // --- List -------------------------------------------------------
400
401    #[test]
402    fn list_request_serialises_with_task_id_only() -> Result<(), TestError> {
403        let request = ListTaskPushNotificationConfigsRequest::new("t-1");
404        let value = serde_json::to_value(&request)?;
405        let obj = value
406            .as_object()
407            .ok_or("a ListTaskPushNotificationConfigsRequest must serialise to a JSON object")?;
408        assert_eq!(
409            obj.get("taskId").and_then(serde_json::Value::as_str),
410            Some("t-1")
411        );
412        assert!(!obj.contains_key("pageSize"));
413        assert!(!obj.contains_key("pageToken"));
414        Ok(())
415    }
416
417    #[test]
418    fn list_request_round_trips_with_pagination() -> Result<(), TestError> {
419        let request = ListTaskPushNotificationConfigsRequest::new("t-1")
420            .with_page_size(50)
421            .with_page_token("next-page");
422        let json = serde_json::to_string(&request)?;
423        let parsed: ListTaskPushNotificationConfigsRequest = serde_json::from_str(&json)?;
424        assert_eq!(parsed, request);
425        Ok(())
426    }
427
428    #[test]
429    fn list_response_with_no_next_page_token_omits_the_field() -> Result<(), TestError> {
430        // A response that is the last
431        // page has no `nextPageToken` —
432        // the absence is the signal.
433        let response = ListTaskPushNotificationConfigsResponse::new([sample_config()]);
434        let value = serde_json::to_value(&response)?;
435        let obj = value
436            .as_object()
437            .ok_or("a ListTaskPushNotificationConfigsResponse must serialise to a JSON object")?;
438        assert!(!obj.contains_key("nextPageToken"));
439        let configs = obj
440            .get("configs")
441            .and_then(serde_json::Value::as_array)
442            .ok_or("`configs` must be a JSON array")?;
443        assert_eq!(configs.len(), 1);
444        Ok(())
445    }
446
447    #[test]
448    fn list_response_round_trips_with_token_and_configs() -> Result<(), TestError> {
449        let response = ListTaskPushNotificationConfigsResponse::new([
450            sample_config(),
451            TaskPushNotificationConfig::new("c-2", "t-1", "https://example.com/wh2"),
452        ])
453        .with_next_page_token("next");
454        let json = serde_json::to_string(&response)?;
455        let parsed: ListTaskPushNotificationConfigsResponse = serde_json::from_str(&json)?;
456        assert_eq!(parsed, response);
457        Ok(())
458    }
459
460    // --- Delete -----------------------------------------------------
461
462    #[test]
463    fn delete_request_serialises_with_id_and_task_id() -> Result<(), TestError> {
464        let request = DeleteTaskPushNotificationConfigRequest::new("c-1", "t-1");
465        let value = serde_json::to_value(&request)?;
466        let obj = value
467            .as_object()
468            .ok_or("a DeleteTaskPushNotificationConfigRequest must serialise to a JSON object")?;
469        assert_eq!(
470            obj.get("id").and_then(serde_json::Value::as_str),
471            Some("c-1")
472        );
473        assert_eq!(
474            obj.get("taskId").and_then(serde_json::Value::as_str),
475            Some("t-1")
476        );
477        Ok(())
478    }
479
480    #[test]
481    fn delete_response_with_no_metadata_is_an_empty_object() -> Result<(), TestError> {
482        // The spec uses
483        // `google.protobuf.Empty` for the
484        // delete response, which
485        // serialises to `{}` on the
486        // wire. A response that adds no
487        // metadata is exactly `{}`.
488        let response = DeleteTaskPushNotificationConfigResponse::default();
489        let value = serde_json::to_value(&response)?;
490        let obj = value
491            .as_object()
492            .ok_or("a DeleteTaskPushNotificationConfigResponse must serialise to a JSON object")?;
493        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
494        Ok(())
495    }
496}