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}