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    /// The token to send.
123    ///
124    /// Absent on the wire unless this method is called.
125    #[must_use]
126    pub fn with_token(mut self, token: impl Into<String>) -> Self {
127        self.token = Some(token.into());
128        self
129    }
130
131    /// The authentication info to send.
132    ///
133    /// Absent on the wire unless this method is called.
134    #[must_use]
135    pub fn with_authentication(
136        mut self,
137        authentication: PushNotificationAuthenticationInfo,
138    ) -> Self {
139        self.authentication = Some(authentication);
140        self
141    }
142}
143
144/// The authentication block inside a
145/// [`TaskPushNotificationConfig`].
146///
147/// Wire form:
148/// ```json
149/// { "scheme": "Bearer", "credentials": "..." }
150/// ```
151///
152/// `scheme` is required; `credentials` is
153/// optional. The spec lets a config carry
154/// just a scheme name (e.g. `"Bearer"`) and
155/// rely on a side channel for the
156/// credentials; the agent reads the
157/// `credentials` field when present and
158/// falls back to the side channel when it
159/// is not.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct PushNotificationAuthenticationInfo {
162    /// The HTTP authentication scheme. Per
163    /// [IANA registry](https://www.iana.org/assignments/http-authschemes/),
164    /// examples are `Bearer`, `Basic`,
165    /// `Digest`. Scheme names are
166    /// case-insensitive per RFC 9110
167    /// §11.1; the wire form is the
168    /// canonical casing the client sends,
169    /// and the agent normalises on read.
170    pub scheme: String,
171    /// The credentials. The format depends
172    /// on the scheme (a token for `Bearer`,
173    /// a base64-encoded `user:pass` for
174    /// `Basic`, etc.). Optional — a config
175    /// that names a scheme without
176    /// credentials is valid per the spec
177    /// and relies on a side channel for
178    /// the value.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub credentials: Option<String>,
181}
182
183impl PushNotificationAuthenticationInfo {
184    /// Starts a new authentication block with
185    /// `scheme`. Use [`with_credentials`](Self::with_credentials)
186    /// to set the credentials.
187    #[must_use]
188    pub fn new(scheme: impl Into<String>) -> Self {
189        Self {
190            scheme: scheme.into(),
191            credentials: None,
192        }
193    }
194
195    /// The credentials to send.
196    ///
197    /// Absent on the wire unless this method is called.
198    #[must_use]
199    pub fn with_credentials(mut self, credentials: impl Into<String>) -> Self {
200        self.credentials = Some(credentials.into());
201        self
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::{PushNotificationAuthenticationInfo, TaskPushNotificationConfig};
208
209    type TestError = Box<dyn std::error::Error>;
210
211    // --- TaskPushNotificationConfig wire form -----------------------
212
213    #[test]
214    fn config_serialises_with_required_fields_only() -> Result<(), TestError> {
215        let config = TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh");
216        let value = serde_json::to_value(&config)?;
217        let obj = value
218            .as_object()
219            .ok_or("a TaskPushNotificationConfig must serialise to a JSON object")?;
220        assert_eq!(
221            obj.get("id").and_then(serde_json::Value::as_str),
222            Some("c-1")
223        );
224        assert_eq!(
225            obj.get("taskId").and_then(serde_json::Value::as_str),
226            Some("t-1")
227        );
228        assert_eq!(
229            obj.get("url").and_then(serde_json::Value::as_str),
230            Some("https://example.com/wh")
231        );
232        // The optional fields are absent
233        // when they are `None` / empty.
234        assert!(!obj.contains_key("token"));
235        assert!(!obj.contains_key("authentication"));
236        assert!(!obj.contains_key("metadata"));
237        Ok(())
238    }
239
240    #[test]
241    fn config_round_trips_with_token_and_authentication() -> Result<(), TestError> {
242        let config = TaskPushNotificationConfig::new("c-1", "t-1", "https://example.com/wh")
243            .with_token("auth-token")
244            .with_authentication(
245                PushNotificationAuthenticationInfo::new("Bearer").with_credentials("xyz"),
246            );
247        let json = serde_json::to_string(&config)?;
248        let parsed: TaskPushNotificationConfig = serde_json::from_str(&json)?;
249        assert_eq!(parsed, config);
250        Ok(())
251    }
252
253    // --- PushNotificationAuthenticationInfo wire form ---------------
254
255    #[test]
256    fn auth_info_serialises_with_scheme_only() -> Result<(), TestError> {
257        let info = PushNotificationAuthenticationInfo::new("Bearer");
258        let value = serde_json::to_value(&info)?;
259        let obj = value
260            .as_object()
261            .ok_or("a PushNotificationAuthenticationInfo must serialise to a JSON object")?;
262        assert_eq!(
263            obj.get("scheme").and_then(serde_json::Value::as_str),
264            Some("Bearer")
265        );
266        // `credentials` is absent when `None`.
267        assert!(!obj.contains_key("credentials"));
268        Ok(())
269    }
270
271    #[test]
272    fn auth_info_round_trips_with_credentials() -> Result<(), TestError> {
273        let info =
274            PushNotificationAuthenticationInfo::new("Basic").with_credentials("dXNlcjpwYXNz");
275        let json = serde_json::to_string(&info)?;
276        let parsed: PushNotificationAuthenticationInfo = serde_json::from_str(&json)?;
277        assert_eq!(parsed, info);
278        Ok(())
279    }
280}