1use std::fmt;
5
6use a2a::{
7 AgentCard, CancelTaskRequest, DeleteTaskPushNotificationConfigRequest,
8 GetExtendedAgentCardRequest, GetTaskPushNotificationConfigRequest, GetTaskRequest,
9 ListTaskPushNotificationConfigsRequest, ListTaskPushNotificationConfigsResponse,
10 ListTasksRequest, ListTasksResponse, SendMessageRequest, SendMessageResponse, StreamResponse,
11 SubscribeToTaskRequest, Task, TaskPushNotificationConfig,
12};
13use prost::Message;
14use serde::{Serialize, de::DeserializeOwned};
15use serde_json::Value;
16
17#[derive(Debug)]
18pub enum ProtoJsonPayloadError {
19 Json(serde_json::Error),
20 Decode(prost::DecodeError),
21 MissingPayload(&'static str),
22}
23
24impl fmt::Display for ProtoJsonPayloadError {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::Json(error) => write!(f, "ProtoJSON error: {error}"),
28 Self::Decode(error) => write!(f, "protobuf transcode error: {error}"),
29 Self::MissingPayload(type_name) => {
30 write!(f, "ProtoJSON payload missing required data for {type_name}")
31 }
32 }
33 }
34}
35
36impl std::error::Error for ProtoJsonPayloadError {}
37
38pub trait ProtoJsonPayload: Sized {
39 type Proto: Message + Default;
40 type ProtoJson: Message + Default + Serialize + DeserializeOwned;
41
42 fn to_proto(value: &Self) -> Self::Proto;
43 fn try_from_proto(value: &Self::Proto) -> Result<Self, ProtoJsonPayloadError>;
44
45 fn normalize_json(_value: &mut Value) {}
50}
51
52pub fn to_value<T: ProtoJsonPayload>(value: &T) -> Result<Value, ProtoJsonPayloadError> {
53 let proto = T::to_proto(value);
54 let protojson: T::ProtoJson = transcode_message(&proto)?;
55 let mut json = serde_json::to_value(protojson).map_err(ProtoJsonPayloadError::Json)?;
56 normalize_timestamp_strings(&mut json);
57 T::normalize_json(&mut json);
58 Ok(json)
59}
60
61const TIMESTAMP_FIELD_NAMES: &[&str] = &["timestamp", "statusTimestampAfter"];
63
64fn normalize_timestamp_strings(value: &mut Value) {
73 match value {
74 Value::Object(map) => {
75 for (key, v) in map.iter_mut() {
76 if TIMESTAMP_FIELD_NAMES.contains(&key.as_str()) {
77 if let Value::String(s) = v {
78 if let Some(prefix) = s.strip_suffix("+00:00") {
79 *s = format!("{prefix}Z");
80 }
81 }
82 }
83 normalize_timestamp_strings(v);
84 }
85 }
86 Value::Array(items) => {
87 for item in items {
88 normalize_timestamp_strings(item);
89 }
90 }
91 _ => {}
92 }
93}
94
95pub fn from_value<T: ProtoJsonPayload>(value: Value) -> Result<T, ProtoJsonPayloadError> {
96 let protojson: T::ProtoJson =
97 serde_json::from_value(value).map_err(ProtoJsonPayloadError::Json)?;
98 let proto: T::Proto = transcode_message(&protojson)?;
99 T::try_from_proto(&proto)
100}
101
102pub fn from_str<T: ProtoJsonPayload>(value: &str) -> Result<T, ProtoJsonPayloadError> {
103 let protojson: T::ProtoJson =
104 serde_json::from_str(value).map_err(ProtoJsonPayloadError::Json)?;
105 let proto: T::Proto = transcode_message(&protojson)?;
106 T::try_from_proto(&proto)
107}
108
109fn transcode_message<Src, Dst>(source: &Src) -> Result<Dst, ProtoJsonPayloadError>
110where
111 Src: Message,
112 Dst: Message + Default,
113{
114 Dst::decode(source.encode_to_vec().as_slice()).map_err(ProtoJsonPayloadError::Decode)
115}
116
117macro_rules! impl_protojson_payload {
118 ($native:path, $proto:path, $protojson:path, $to_proto:path, $from_proto:path) => {
119 impl ProtoJsonPayload for $native {
120 type Proto = $proto;
121 type ProtoJson = $protojson;
122
123 fn to_proto(value: &Self) -> Self::Proto {
124 $to_proto(value)
125 }
126
127 fn try_from_proto(value: &Self::Proto) -> Result<Self, ProtoJsonPayloadError> {
128 Ok($from_proto(value))
129 }
130 }
131 };
132}
133
134macro_rules! impl_protojson_payload_optional {
135 ($native:path, $proto:path, $protojson:path, $to_proto:path, $from_proto:path) => {
136 impl ProtoJsonPayload for $native {
137 type Proto = $proto;
138 type ProtoJson = $protojson;
139
140 fn to_proto(value: &Self) -> Self::Proto {
141 $to_proto(value)
142 }
143
144 fn try_from_proto(value: &Self::Proto) -> Result<Self, ProtoJsonPayloadError> {
145 $from_proto(value).ok_or(ProtoJsonPayloadError::MissingPayload(stringify!($native)))
146 }
147 }
148 };
149}
150
151impl_protojson_payload!(
152 SendMessageRequest,
153 crate::proto::SendMessageRequest,
154 crate::protojson::SendMessageRequest,
155 crate::pbconv::to_proto_send_message_request,
156 crate::pbconv::from_proto_send_message_request
157);
158impl_protojson_payload!(
159 GetTaskRequest,
160 crate::proto::GetTaskRequest,
161 crate::protojson::GetTaskRequest,
162 crate::pbconv::to_proto_get_task_request,
163 crate::pbconv::from_proto_get_task_request
164);
165impl_protojson_payload!(
166 ListTasksRequest,
167 crate::proto::ListTasksRequest,
168 crate::protojson::ListTasksRequest,
169 crate::pbconv::to_proto_list_tasks_request,
170 crate::pbconv::from_proto_list_tasks_request
171);
172impl_protojson_payload!(
173 CancelTaskRequest,
174 crate::proto::CancelTaskRequest,
175 crate::protojson::CancelTaskRequest,
176 crate::pbconv::to_proto_cancel_task_request,
177 crate::pbconv::from_proto_cancel_task_request
178);
179impl_protojson_payload!(
180 SubscribeToTaskRequest,
181 crate::proto::SubscribeToTaskRequest,
182 crate::protojson::SubscribeToTaskRequest,
183 crate::pbconv::to_proto_subscribe_to_task_request,
184 crate::pbconv::from_proto_subscribe_to_task_request
185);
186impl_protojson_payload!(
187 GetExtendedAgentCardRequest,
188 crate::proto::GetExtendedAgentCardRequest,
189 crate::protojson::GetExtendedAgentCardRequest,
190 crate::pbconv::to_proto_get_extended_agent_card_request,
191 crate::pbconv::from_proto_get_extended_agent_card_request
192);
193impl_protojson_payload!(
194 GetTaskPushNotificationConfigRequest,
195 crate::proto::GetTaskPushNotificationConfigRequest,
196 crate::protojson::GetTaskPushNotificationConfigRequest,
197 crate::pbconv::to_proto_get_task_push_notification_config_request,
198 crate::pbconv::from_proto_get_task_push_notification_config_request
199);
200impl_protojson_payload!(
201 DeleteTaskPushNotificationConfigRequest,
202 crate::proto::DeleteTaskPushNotificationConfigRequest,
203 crate::protojson::DeleteTaskPushNotificationConfigRequest,
204 crate::pbconv::to_proto_delete_task_push_notification_config_request,
205 crate::pbconv::from_proto_delete_task_push_notification_config_request
206);
207impl_protojson_payload!(
208 ListTaskPushNotificationConfigsRequest,
209 crate::proto::ListTaskPushNotificationConfigsRequest,
210 crate::protojson::ListTaskPushNotificationConfigsRequest,
211 crate::pbconv::to_proto_list_task_push_notification_configs_request,
212 crate::pbconv::from_proto_list_task_push_notification_configs_request
213);
214impl_protojson_payload!(
215 TaskPushNotificationConfig,
216 crate::proto::TaskPushNotificationConfig,
217 crate::protojson::TaskPushNotificationConfig,
218 crate::pbconv::to_proto_task_push_notification_config,
219 crate::pbconv::from_proto_task_push_notification_config
220);
221impl_protojson_payload!(
222 Task,
223 crate::proto::Task,
224 crate::protojson::Task,
225 crate::pbconv::to_proto_task,
226 crate::pbconv::from_proto_task
227);
228impl ProtoJsonPayload for ListTasksResponse {
229 type Proto = crate::proto::ListTasksResponse;
230 type ProtoJson = crate::protojson::ListTasksResponse;
231
232 fn to_proto(value: &Self) -> Self::Proto {
233 crate::pbconv::to_proto_list_tasks_response(value)
234 }
235
236 fn try_from_proto(value: &Self::Proto) -> Result<Self, ProtoJsonPayloadError> {
237 Ok(crate::pbconv::from_proto_list_tasks_response(value))
238 }
239
240 fn normalize_json(value: &mut Value) {
244 if let Value::Object(map) = value {
245 map.entry("nextPageToken")
246 .or_insert_with(|| Value::String(String::new()));
247 }
248 }
249}
250impl_protojson_payload!(
251 ListTaskPushNotificationConfigsResponse,
252 crate::proto::ListTaskPushNotificationConfigsResponse,
253 crate::protojson::ListTaskPushNotificationConfigsResponse,
254 crate::pbconv::to_proto_list_task_push_notification_configs_response,
255 crate::pbconv::from_proto_list_task_push_notification_configs_response
256);
257impl_protojson_payload!(
258 AgentCard,
259 crate::proto::AgentCard,
260 crate::protojson::AgentCard,
261 crate::pbconv::to_proto_agent_card,
262 crate::pbconv::from_proto_agent_card
263);
264impl_protojson_payload_optional!(
265 SendMessageResponse,
266 crate::proto::SendMessageResponse,
267 crate::protojson::SendMessageResponse,
268 crate::pbconv::to_proto_send_message_response,
269 crate::pbconv::from_proto_send_message_response
270);
271impl_protojson_payload_optional!(
272 StreamResponse,
273 crate::proto::StreamResponse,
274 crate::protojson::StreamResponse,
275 crate::pbconv::to_proto_stream_response,
276 crate::pbconv::from_proto_stream_response
277);
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
286 fn test_list_tasks_response_always_includes_next_page_token() {
287 let response = ListTasksResponse {
288 tasks: vec![],
289 next_page_token: String::new(),
290 page_size: 0,
291 total_size: 0,
292 };
293 let json = to_value(&response).unwrap();
294 assert_eq!(
295 json.get("nextPageToken"),
296 Some(&Value::String(String::new()))
297 );
298 }
299
300 #[test]
301 fn test_list_tasks_response_keeps_a_non_empty_next_page_token() {
302 let response = ListTasksResponse {
303 tasks: vec![],
304 next_page_token: "page-2".to_string(),
305 page_size: 0,
306 total_size: 0,
307 };
308 let json = to_value(&response).unwrap();
309 assert_eq!(
310 json.get("nextPageToken"),
311 Some(&Value::String("page-2".to_string()))
312 );
313 }
314
315 #[test]
321 fn test_list_tasks_response_normalize_json_ignores_a_non_object_value() {
322 let mut not_an_object = Value::Null;
323 ListTasksResponse::normalize_json(&mut not_an_object);
324 assert_eq!(not_an_object, Value::Null);
325 }
326
327 fn sample_task_with_timestamp(timestamp: chrono::DateTime<chrono::Utc>) -> Task {
328 Task {
329 id: "task-1".to_string(),
330 context_id: "ctx-1".to_string(),
331 status: a2a::TaskStatus {
332 state: a2a::TaskState::Working,
333 message: None,
334 timestamp: Some(timestamp),
335 },
336 artifacts: None,
337 history: None,
338 metadata: None,
339 }
340 }
341
342 #[test]
346 fn test_task_status_timestamp_uses_z_suffix_not_offset() {
347 let timestamp = "2026-09-23T02:07:57.846477544Z"
348 .parse::<chrono::DateTime<chrono::Utc>>()
349 .unwrap();
350 let json = to_value(&sample_task_with_timestamp(timestamp)).unwrap();
351 let rendered = json["status"]["timestamp"].as_str().unwrap();
352 assert_eq!(rendered, "2026-09-23T02:07:57.846477544Z");
353 assert!(!rendered.ends_with("+00:00"));
354 }
355
356 #[test]
357 fn test_normalize_timestamp_strings_recurses_into_nested_tasks() {
358 let timestamp = "2026-09-23T02:07:57Z"
359 .parse::<chrono::DateTime<chrono::Utc>>()
360 .unwrap();
361 let response = ListTasksResponse {
362 tasks: vec![sample_task_with_timestamp(timestamp)],
363 next_page_token: String::new(),
364 page_size: 1,
365 total_size: 1,
366 };
367 let json = to_value(&response).unwrap();
368 let rendered = json["tasks"][0]["status"]["timestamp"].as_str().unwrap();
369 assert_eq!(rendered, "2026-09-23T02:07:57Z");
370 }
371
372 #[test]
376 fn test_normalize_timestamp_strings_ignores_unrelated_fields() {
377 let mut json = serde_json::json!({
378 "notATimestamp": "ends with +00:00",
379 "nested": { "alsoNotATimestamp": "also +00:00" }
380 });
381 normalize_timestamp_strings(&mut json);
382 assert_eq!(json["notATimestamp"], "ends with +00:00");
383 assert_eq!(json["nested"]["alsoNotATimestamp"], "also +00:00");
384 }
385}