Skip to main content

protocol_droid/domain/a2a/
push_notification.rs

1//! A2A push-notification configuration value
2//! objects — the wire form.
3//!
4//! # What these are
5//!
6//! [`TaskPushNotificationConfig`] is the wire
7//! shape a client sends to register a webhook
8//! for task updates. It is the value the four
9//! push-notification JSON-RPC methods
10//! (`CreateTaskPushNotificationConfig`,
11//! `GetTaskPushNotificationConfig`,
12//! `ListTaskPushNotificationConfigs`,
13//! `DeleteTaskPushNotificationConfig`) read and
14//! write.
15//!
16//! [`PushNotificationAuthenticationInfo`] is
17//! the authentication block inside a
18//! `TaskPushNotificationConfig`. The spec
19//! names it `AuthenticationInfo`; the
20//! `PushNotification` prefix disambiguates it
21//! from the agent card's `SecurityScheme` and
22//! `SecurityRequirement` types.
23//!
24//! # Why `String` for the URL, not `url::Url`
25//!
26//! The wire form is a string. The `url::Url`
27//! type would be a more typed alternative but
28//! adds a dependency and a `Result` on every
29//! read. A consumer who wants a parsed URL
30//! does the `Url::parse` on demand; the wire
31//! form stays the spec's string.
32//!
33//! # Why the URL is a separate `String`
34//!
35//! The URL is the SSRF surface the A2A SSRF
36//! denylist filters on the server side. A
37//! client that sends a URL is trusting the
38//! server to either fetch it (after
39//! validation) or refuse. The wire form is a
40//! raw string; the
41//! [`NotificationConfig`](crate::application::a2a::NotificationConfig)
42//! is the post-validation form the delivery
43//! port sees.
44
45use std::collections::BTreeMap;
46
47use serde::{Deserialize, Serialize};
48
49/// A registered push-notification configuration
50/// for a task.
51///
52/// Wire form:
53/// ```json
54/// {
55///   "id": "config-1",
56///   "taskId": "task-1",
57///   "url": "https://example.com/webhook",
58///   "token": "auth-token",
59///   "authentication": { "scheme": "Bearer", "credentials": "..." }
60/// }
61/// ```
62///
63/// All fields are required except `token` and
64/// `authentication`. The spec lets a client
65/// register a config with no token (a webhook
66/// the agent POSTs to without authentication)
67/// and no authentication block (a webhook
68/// that uses a header the agent does not need
69/// to know about, or an unauthenticated
70/// webhook).
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct TaskPushNotificationConfig {
74    /// The configuration's id. Required —
75    /// the spec uses it to address a config
76    /// within a task.
77    pub id: String,
78    /// The task's id. Required — a config is
79    /// scoped to one task.
80    pub task_id: String,
81    /// The webhook the server POSTs to. The
82    /// URL is the SSRF surface the SSRF
83    /// denylist filters on the server side.
84    /// Required.
85    pub url: String,
86    /// An optional token the server includes
87    /// in the `X-A2A-Notification-Token`
88    /// header. A receiver without a token is
89    /// a receiver that does not need to
90    /// authenticate the delivery.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub token: Option<String>,
93    /// Optional authentication info. A
94    /// webhook that needs a Bearer token
95    /// (or Basic, or Digest) sets this; a
96    /// webhook that does not leaves it off
97    /// the wire.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub authentication: Option<PushNotificationAuthenticationInfo>,
100    /// Per-config metadata.
101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102    pub metadata: BTreeMap<String, serde_json::Value>,
103}
104
105impl TaskPushNotificationConfig {
106    /// Starts a new config with `id`, `task_id`,
107    /// and `url`. Use [`with_token`](Self::with_token)
108    /// and [`with_authentication`](Self::with_authentication)
109    /// to set the optional fields.
110    #[must_use]
111    pub fn new(id: impl Into<String>, task_id: impl Into<String>, url: impl Into<String>) -> Self {
112        Self {
113            id: id.into(),
114            task_id: task_id.into(),
115            url: url.into(),
116            token: None,
117            authentication: None,
118            metadata: BTreeMap::new(),
119        }
120    }
121
122    /// Sets the token.
123    #[must_use]
124    pub fn with_token(mut self, token: impl Into<String>) -> Self {
125        self.token = Some(token.into());
126        self
127    }
128
129    /// Sets the authentication info.
130    #[must_use]
131    pub fn with_authentication(
132        mut self,
133        authentication: PushNotificationAuthenticationInfo,
134    ) -> Self {
135        self.authentication = Some(authentication);
136        self
137    }
138}
139
140/// The authentication block inside a
141/// [`TaskPushNotificationConfig`].
142///
143/// Wire form:
144/// ```json
145/// { "scheme": "Bearer", "credentials": "..." }
146/// ```
147///
148/// `scheme` is required; `credentials` is
149/// optional. The spec lets a config carry
150/// just a scheme name (e.g. `"Bearer"`) and
151/// rely on a side channel for the
152/// credentials; the agent reads the
153/// `credentials` field when present and
154/// falls back to the side channel when it
155/// is not.
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct PushNotificationAuthenticationInfo {
158    /// The HTTP authentication scheme. Per
159    /// [IANA registry](https://www.iana.org/assignments/http-authschemes/),
160    /// examples are `Bearer`, `Basic`,
161    /// `Digest`. Scheme names are
162    /// case-insensitive per RFC 9110
163    /// §11.1; the wire form is the
164    /// canonical casing the client sends,
165    /// and the agent normalises on read.
166    pub scheme: String,
167    /// The credentials. The format depends
168    /// on the scheme (a token for `Bearer`,
169    /// a base64-encoded `user:pass` for
170    /// `Basic`, etc.). Optional — a config
171    /// that names a scheme without
172    /// credentials is valid per the spec
173    /// and relies on a side channel for
174    /// the value.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub credentials: Option<String>,
177}
178
179impl PushNotificationAuthenticationInfo {
180    /// Starts a new authentication block with
181    /// `scheme`. Use [`with_credentials`](Self::with_credentials)
182    /// to set the credentials.
183    #[must_use]
184    pub fn new(scheme: impl Into<String>) -> Self {
185        Self {
186            scheme: scheme.into(),
187            credentials: None,
188        }
189    }
190
191    /// Sets the credentials.
192    #[must_use]
193    pub fn with_credentials(mut self, credentials: impl Into<String>) -> Self {
194        self.credentials = Some(credentials.into());
195        self
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{PushNotificationAuthenticationInfo, TaskPushNotificationConfig};
202
203    type TestError = Box<dyn std::error::Error>;
204
205    // --- TaskPushNotificationConfig wire form -----------------------
206
207    #[test]
208    fn config_serialises_with_required_fields_only() -> Result<(), TestError> {
209        let config = TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh");
210        let value = serde_json::to_value(&config)?;
211        let obj = value
212            .as_object()
213            .ok_or("a TaskPushNotificationConfig must serialise to a JSON object")?;
214        assert_eq!(
215            obj.get("id").and_then(serde_json::Value::as_str),
216            Some("c-1")
217        );
218        assert_eq!(
219            obj.get("taskId").and_then(serde_json::Value::as_str),
220            Some("t-1")
221        );
222        assert_eq!(
223            obj.get("url").and_then(serde_json::Value::as_str),
224            Some("https://example.com/wh")
225        );
226        // The optional fields are absent
227        // when they are `None` / empty.
228        assert!(!obj.contains_key("token"));
229        assert!(!obj.contains_key("authentication"));
230        assert!(!obj.contains_key("metadata"));
231        Ok(())
232    }
233
234    #[test]
235    fn config_round_trips_with_token_and_authentication() -> Result<(), TestError> {
236        let config = TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh")
237            .with_token("auth-token")
238            .with_authentication(
239                PushNotificationAuthenticationInfo::new("Bearer").with_credentials("xyz"),
240            );
241        let json = serde_json::to_string(&config)?;
242        let parsed: TaskPushNotificationConfig = serde_json::from_str(&json)?;
243        assert_eq!(parsed, config);
244        Ok(())
245    }
246
247    // --- PushNotificationAuthenticationInfo wire form ---------------
248
249    #[test]
250    fn auth_info_serialises_with_scheme_only() -> Result<(), TestError> {
251        let info = PushNotificationAuthenticationInfo::new("Bearer");
252        let value = serde_json::to_value(&info)?;
253        let obj = value
254            .as_object()
255            .ok_or("a PushNotificationAuthenticationInfo must serialise to a JSON object")?;
256        assert_eq!(
257            obj.get("scheme").and_then(serde_json::Value::as_str),
258            Some("Bearer")
259        );
260        // `credentials` is absent when `None`.
261        assert!(!obj.contains_key("credentials"));
262        Ok(())
263    }
264
265    #[test]
266    fn auth_info_round_trips_with_credentials() -> Result<(), TestError> {
267        let info =
268            PushNotificationAuthenticationInfo::new("Basic").with_credentials("dXNlcjpwYXNz");
269        let json = serde_json::to_string(&info)?;
270        let parsed: PushNotificationAuthenticationInfo = serde_json::from_str(&json)?;
271        assert_eq!(parsed, info);
272        Ok(())
273    }
274}