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 `pageSize`.
215    ///
216    /// Absent from the request unless this method is called.
217    #[must_use]
218    pub const fn with_page_size(mut self, page_size: u32) -> Self {
219        self.page_size = Some(page_size);
220        self
221    }
222
223    /// Sets `pageToken`.
224    ///
225    /// Absent from the request unless this method is called, which is the
226    /// first page.
227    #[must_use]
228    pub fn with_page_token(mut self, page_token: impl Into<String>) -> Self {
229        self.page_token = Some(page_token.into());
230        self
231    }
232}
233
234/// A typed `ListTaskPushNotificationConfigs`
235/// response. Wire form:
236/// ```json
237/// {
238///   "configs": [TaskPushNotificationConfig, ...],
239///   "nextPageToken": "..."
240/// }
241/// ```
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct ListTaskPushNotificationConfigsResponse {
245    /// The configurations. An empty list is
246    /// a task that has no configs.
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub configs: Vec<TaskPushNotificationConfig>,
249    /// A token to retrieve the next page of
250    /// results, or empty if there are no
251    /// more results in the list. A response
252    /// with no `nextPageToken` is the
253    /// last page.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub next_page_token: Option<String>,
256}
257
258impl ListTaskPushNotificationConfigsResponse {
259    /// Starts a new response with `configs`.
260    /// Use
261    /// [`with_next_page_token`](Self::with_next_page_token)
262    /// to attach a token.
263    #[must_use]
264    pub fn new(configs: impl IntoIterator<Item = TaskPushNotificationConfig>) -> Self {
265        Self {
266            configs: configs.into_iter().collect(),
267            next_page_token: None,
268        }
269    }
270
271    /// Sets the next page token.
272    #[must_use]
273    pub fn with_next_page_token(mut self, next_page_token: impl Into<String>) -> Self {
274        self.next_page_token = Some(next_page_token.into());
275        self
276    }
277}
278
279// --- DeleteTaskPushNotificationConfig ----------------------------
280
281/// A typed `DeleteTaskPushNotificationConfig`
282/// request. Wire form:
283/// ```json
284/// { "id": "config-1", "taskId": "task-1" }
285/// ```
286///
287/// Both fields are required.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct DeleteTaskPushNotificationConfigRequest {
291    /// The config's id.
292    pub id: String,
293    /// The parent task's id.
294    pub task_id: String,
295}
296
297impl DeleteTaskPushNotificationConfigRequest {
298    /// Starts a new request with `id` and
299    /// `task_id`.
300    #[must_use]
301    pub fn new(id: impl Into<String>, task_id: impl Into<String>) -> Self {
302        Self {
303            id: id.into(),
304            task_id: task_id.into(),
305        }
306    }
307}
308
309/// A typed `DeleteTaskPushNotificationConfig`
310/// response. The wire form is `{}` (the
311/// spec's `google.protobuf.Empty`).
312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313pub struct DeleteTaskPushNotificationConfigResponse {
314    /// A catch-all for the metadata. The
315    /// spec does not name a field, but a
316    /// server that wants to include a
317    /// trailing note (e.g. `"deleted_at"`:
318    /// `"2025-01-01T00:00:00Z"`) can use
319    /// this map.
320    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
321    pub metadata: BTreeMap<String, serde_json::Value>,
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{
327        CreateTaskPushNotificationConfigRequest, CreateTaskPushNotificationConfigResponse,
328        DeleteTaskPushNotificationConfigRequest, DeleteTaskPushNotificationConfigResponse,
329        GetTaskPushNotificationConfigRequest, GetTaskPushNotificationConfigResponse,
330        ListTaskPushNotificationConfigsRequest, ListTaskPushNotificationConfigsResponse,
331    };
332    use crate::domain::a2a::push_notification::TaskPushNotificationConfig;
333
334    type TestError = Box<dyn std::error::Error>;
335
336    fn sample_config() -> TaskPushNotificationConfig {
337        TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh")
338    }
339
340    // --- Create -----------------------------------------------------
341
342    #[test]
343    fn create_request_serialises_as_config_object() -> Result<(), TestError> {
344        let request = CreateTaskPushNotificationConfigRequest::from(sample_config());
345        let value = serde_json::to_value(&request)?;
346        let obj = value
347            .as_object()
348            .ok_or("a CreateTaskPushNotificationConfigRequest must serialise to a JSON object")?;
349        assert_eq!(
350            obj.get("id").and_then(serde_json::Value::as_str),
351            Some("c-1")
352        );
353        assert_eq!(
354            obj.get("taskId").and_then(serde_json::Value::as_str),
355            Some("t-1")
356        );
357        // The wire form is the inner
358        // config — no wrapper.
359        assert!(!obj.contains_key("config"));
360        Ok(())
361    }
362
363    #[test]
364    fn create_response_from_config_via_into_works() {
365        let config = sample_config();
366        let response: CreateTaskPushNotificationConfigResponse = config.clone().into();
367        assert_eq!(response.0, config);
368    }
369
370    // --- Get --------------------------------------------------------
371
372    #[test]
373    fn get_request_serialises_with_id_and_task_id() -> Result<(), TestError> {
374        let request = GetTaskPushNotificationConfigRequest::new("c-1", "t-1");
375        let value = serde_json::to_value(&request)?;
376        let obj = value
377            .as_object()
378            .ok_or("a GetTaskPushNotificationConfigRequest must serialise to a JSON object")?;
379        assert_eq!(
380            obj.get("id").and_then(serde_json::Value::as_str),
381            Some("c-1")
382        );
383        assert_eq!(
384            obj.get("taskId").and_then(serde_json::Value::as_str),
385            Some("t-1")
386        );
387        Ok(())
388    }
389
390    #[test]
391    fn get_response_serialises_as_config_object() -> Result<(), TestError> {
392        let response = GetTaskPushNotificationConfigResponse::from(sample_config());
393        let value = serde_json::to_value(&response)?;
394        let obj = value
395            .as_object()
396            .ok_or("a GetTaskPushNotificationConfigResponse must serialise to a JSON object")?;
397        assert_eq!(
398            obj.get("id").and_then(serde_json::Value::as_str),
399            Some("c-1")
400        );
401        Ok(())
402    }
403
404    // --- List -------------------------------------------------------
405
406    #[test]
407    fn list_request_serialises_with_task_id_only() -> Result<(), TestError> {
408        let request = ListTaskPushNotificationConfigsRequest::new("t-1");
409        let value = serde_json::to_value(&request)?;
410        let obj = value
411            .as_object()
412            .ok_or("a ListTaskPushNotificationConfigsRequest must serialise to a JSON object")?;
413        assert_eq!(
414            obj.get("taskId").and_then(serde_json::Value::as_str),
415            Some("t-1")
416        );
417        assert!(!obj.contains_key("pageSize"));
418        assert!(!obj.contains_key("pageToken"));
419        Ok(())
420    }
421
422    #[test]
423    fn list_request_round_trips_with_pagination() -> Result<(), TestError> {
424        let request = ListTaskPushNotificationConfigsRequest::new("t-1")
425            .with_page_size(50)
426            .with_page_token("next-page");
427        let json = serde_json::to_string(&request)?;
428        let parsed: ListTaskPushNotificationConfigsRequest = serde_json::from_str(&json)?;
429        assert_eq!(parsed, request);
430        Ok(())
431    }
432
433    #[test]
434    fn list_response_with_no_next_page_token_omits_the_field() -> Result<(), TestError> {
435        // A response that is the last
436        // page has no `nextPageToken` —
437        // the absence is the signal.
438        let response = ListTaskPushNotificationConfigsResponse::new([sample_config()]);
439        let value = serde_json::to_value(&response)?;
440        let obj = value
441            .as_object()
442            .ok_or("a ListTaskPushNotificationConfigsResponse must serialise to a JSON object")?;
443        assert!(!obj.contains_key("nextPageToken"));
444        let configs = obj
445            .get("configs")
446            .and_then(serde_json::Value::as_array)
447            .ok_or("`configs` must be a JSON array")?;
448        assert_eq!(configs.len(), 1);
449        Ok(())
450    }
451
452    #[test]
453    fn list_response_round_trips_with_token_and_configs() -> Result<(), TestError> {
454        let response = ListTaskPushNotificationConfigsResponse::new([
455            sample_config(),
456            TaskPushNotificationConfig::new("c-2", "t-1", "https://example.com/wh2"),
457        ])
458        .with_next_page_token("next");
459        let json = serde_json::to_string(&response)?;
460        let parsed: ListTaskPushNotificationConfigsResponse = serde_json::from_str(&json)?;
461        assert_eq!(parsed, response);
462        Ok(())
463    }
464
465    // --- Delete -----------------------------------------------------
466
467    #[test]
468    fn delete_request_serialises_with_id_and_task_id() -> Result<(), TestError> {
469        let request = DeleteTaskPushNotificationConfigRequest::new("c-1", "t-1");
470        let value = serde_json::to_value(&request)?;
471        let obj = value
472            .as_object()
473            .ok_or("a DeleteTaskPushNotificationConfigRequest must serialise to a JSON object")?;
474        assert_eq!(
475            obj.get("id").and_then(serde_json::Value::as_str),
476            Some("c-1")
477        );
478        assert_eq!(
479            obj.get("taskId").and_then(serde_json::Value::as_str),
480            Some("t-1")
481        );
482        Ok(())
483    }
484
485    #[test]
486    fn delete_response_with_no_metadata_is_an_empty_object() -> Result<(), TestError> {
487        // The spec uses
488        // `google.protobuf.Empty` for the
489        // delete response, which
490        // serialises to `{}` on the
491        // wire. A response that adds no
492        // metadata is exactly `{}`.
493        let response = DeleteTaskPushNotificationConfigResponse::default();
494        let value = serde_json::to_value(&response)?;
495        let obj = value
496            .as_object()
497            .ok_or("a DeleteTaskPushNotificationConfigResponse must serialise to a JSON object")?;
498        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
499        Ok(())
500    }
501}