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}