Skip to main content

a2a_pb/
protojson_conv.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// Copyright A2A Contributors (https://github.com/a2aproject)
3// SPDX-License-Identifier: Apache-2.0
4use 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    /// Adjusts the emitted JSON after the generic proto3-JSON conversion. A
46    /// no-op for every type except where the wire mapping needs a field
47    /// always present that proto3's own JSON convention omits when it's
48    /// left at its default.
49    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
61/// JSON field names in `a2a.proto` whose value is a `google.protobuf.Timestamp`.
62const TIMESTAMP_FIELD_NAMES: &[&str] = &["timestamp", "statusTimestampAfter"];
63
64/// `pbjson_types::Timestamp`'s own `Serialize` emits an RFC 3339 string with
65/// a numeric `+00:00` offset (e.g. `"...846477544+00:00"`); the protobuf
66/// JSON canonical mapping requires the `Z` UTC designator instead
67/// (<https://protobuf.dev/programming-guides/proto3/#json>), and DM-SERIAL-001
68/// enforces exactly that. Recurses through the whole tree, keyed by field
69/// name rather than a blind string match, so an unrelated string value that
70/// happens to end the same way (e.g. user-supplied message text) is never
71/// touched.
72fn 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    /// REQ-TASK-LIST-002: `nextPageToken` must always be present, even on
241    /// the last page, but the generated serializer follows proto3-JSON's
242    /// own convention of omitting a string left at its default ("").
243    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    /// REQ-TASK-LIST-002: the field must survive even when it's the
284    /// proto3 default, which the generated serializer would otherwise omit.
285    #[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    /// `serde_json::to_value` on a struct always yields `Value::Object`, so
316    /// `to_value` never reaches the non-object case in practice -- but
317    /// `normalize_json` is a public trait method, not an internal that gets
318    /// to lean on that invariant, so its behavior on the rest of `Value` is
319    /// still part of its contract and worth pinning directly.
320    #[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    /// DM-SERIAL-001: the protobuf JSON canonical mapping requires a
343    /// `Z`-suffixed RFC 3339 timestamp, but `pbjson_types::Timestamp`'s own
344    /// `Serialize` emits the numeric `+00:00` offset form instead.
345    #[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    /// A key-based fix rather than a blind string replace: an unrelated
373    /// field whose value happens to end the same way as the buggy timestamp
374    /// format (e.g. user-supplied text) must never be rewritten.
375    #[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}