a2a_protocol_types/
push.rs1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct AuthenticationInfo {
24 pub scheme: String,
26
27 pub credentials: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct TaskPushNotificationConfig {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub tenant: Option<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
48 pub id: Option<String>,
49
50 pub task_id: String,
52
53 pub url: String,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub token: Option<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub authentication: Option<AuthenticationInfo>,
63}
64
65impl TaskPushNotificationConfig {
66 #[must_use]
68 pub fn new(task_id: impl Into<String>, url: impl Into<String>) -> Self {
69 Self {
70 tenant: None,
71 id: None,
72 task_id: task_id.into(),
73 url: url.into(),
74 token: None,
75 authentication: None,
76 }
77 }
78
79 pub fn validate(&self) -> Result<(), String> {
90 if self.url.is_empty() {
91 return Err("push notification URL must not be empty".into());
92 }
93 if !self.url.starts_with("https://") && !self.url.starts_with("http://") {
94 return Err(format!(
95 "push notification URL must use http:// or https:// scheme: {}",
96 self.url
97 ));
98 }
99 if self.task_id.is_empty() {
100 return Err("push notification task_id must not be empty".into());
101 }
102 Ok(())
103 }
104}
105
106#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn push_config_minimal_roundtrip() {
114 let cfg = TaskPushNotificationConfig::new("task-1", "https://example.com/webhook");
115 let json = serde_json::to_string(&cfg).expect("serialize");
116 assert!(json.contains("\"url\""));
117 assert!(json.contains("\"taskId\""));
118 assert!(!json.contains("\"id\""), "id should be omitted when None");
119
120 let back: TaskPushNotificationConfig = serde_json::from_str(&json).expect("deserialize");
121 assert_eq!(back.url, "https://example.com/webhook");
122 assert_eq!(back.task_id, "task-1");
123 }
124
125 #[test]
126 fn push_config_full_roundtrip() {
127 let cfg = TaskPushNotificationConfig {
128 tenant: Some("tenant-1".into()),
129 id: Some("cfg-1".into()),
130 task_id: "task-1".into(),
131 url: "https://example.com/webhook".into(),
132 token: Some("secret".into()),
133 authentication: Some(AuthenticationInfo {
134 scheme: "bearer".into(),
135 credentials: "my-token".into(),
136 }),
137 };
138 let json = serde_json::to_string(&cfg).expect("serialize");
139 let back: TaskPushNotificationConfig = serde_json::from_str(&json).expect("deserialize");
140 assert_eq!(back.task_id, "task-1");
141 assert_eq!(back.url, "https://example.com/webhook");
142 let auth = back.authentication.expect("authentication should be Some");
143 assert_eq!(auth.scheme, "bearer");
144 assert_eq!(auth.credentials, "my-token");
145 assert_eq!(back.tenant.as_deref(), Some("tenant-1"));
146 assert_eq!(back.id.as_deref(), Some("cfg-1"));
147 assert_eq!(back.token.as_deref(), Some("secret"));
148 }
149
150 #[test]
153 fn push_config_new_optional_fields_are_none() {
154 let cfg = TaskPushNotificationConfig::new("t1", "https://hook.test");
155 assert_eq!(cfg.task_id, "t1");
156 assert_eq!(cfg.url, "https://hook.test");
157 assert!(cfg.tenant.is_none(), "tenant should be None");
158 assert!(cfg.id.is_none(), "id should be None");
159 assert!(cfg.token.is_none(), "token should be None");
160 assert!(
161 cfg.authentication.is_none(),
162 "authentication should be None"
163 );
164 }
165
166 #[test]
167 fn push_config_optional_fields_omitted_in_json() {
168 let cfg = TaskPushNotificationConfig::new("t1", "https://hook.test");
169 let json = serde_json::to_string(&cfg).expect("serialize");
170 assert!(!json.contains("\"tenant\""), "tenant should be omitted");
171 assert!(!json.contains("\"id\""), "id should be omitted");
172 assert!(!json.contains("\"token\""), "token should be omitted");
173 assert!(
174 !json.contains("\"authentication\""),
175 "authentication should be omitted"
176 );
177 }
178
179 #[test]
182 fn validate_accepts_https_url() {
183 let cfg = TaskPushNotificationConfig::new("task-1", "https://example.com/webhook");
184 assert!(cfg.validate().is_ok());
185 }
186
187 #[test]
188 fn validate_accepts_http_url() {
189 let cfg = TaskPushNotificationConfig::new("task-1", "http://localhost:8080/webhook");
190 assert!(cfg.validate().is_ok());
191 }
192
193 #[test]
194 fn validate_rejects_empty_url() {
195 let cfg = TaskPushNotificationConfig::new("task-1", "");
196 let err = cfg.validate().unwrap_err();
197 assert!(err.contains("must not be empty"), "got: {err}");
198 }
199
200 #[test]
201 fn validate_rejects_non_http_scheme() {
202 let cfg = TaskPushNotificationConfig::new("task-1", "ftp://example.com/webhook");
203 let err = cfg.validate().unwrap_err();
204 assert!(err.contains("http:// or https://"), "got: {err}");
205 }
206
207 #[test]
208 fn validate_rejects_bare_string() {
209 let cfg = TaskPushNotificationConfig::new("task-1", "example.com/webhook");
210 let err = cfg.validate().unwrap_err();
211 assert!(err.contains("http:// or https://"), "got: {err}");
212 }
213
214 #[test]
215 fn validate_rejects_empty_task_id() {
216 let cfg = TaskPushNotificationConfig::new("", "https://example.com/webhook");
217 let err = cfg.validate().unwrap_err();
218 assert!(err.contains("task_id must not be empty"), "got: {err}");
219 }
220
221 #[test]
222 fn authentication_info_roundtrip() {
223 let auth = AuthenticationInfo {
224 scheme: "api-key".into(),
225 credentials: "secret-123".into(),
226 };
227 let json = serde_json::to_string(&auth).expect("serialize");
228 let back: AuthenticationInfo = serde_json::from_str(&json).expect("deserialize");
229 assert_eq!(back.scheme, "api-key");
230 assert_eq!(back.credentials, "secret-123");
231 }
232}