Skip to main content

a2a/
errordetails.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::collections::HashMap;
5
6use serde::{Deserialize, Serialize, de::Deserializer, ser::Serializer};
7use serde_json::Value;
8
9pub const ERROR_INFO_TYPE: &str = "type.googleapis.com/google.rpc.ErrorInfo";
10pub const BAD_REQUEST_TYPE: &str = "type.googleapis.com/google.rpc.BadRequest";
11pub const STRUCT_TYPE: &str = "type.googleapis.com/google.protobuf.Struct";
12pub const PROTOCOL_DOMAIN: &str = "a2a-protocol.org";
13
14/// A field-level validation error, matching `google.rpc.BadRequest.FieldViolation`.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct FieldViolation {
17    pub field: String,
18    pub description: String,
19}
20
21/// A typed error detail object using ProtoJSON `Any` representation.
22///
23/// Each detail object carries a `@type` URL identifying its schema and
24/// a map of additional fields. When serialized to JSON, `@type` is
25/// flattened into the object alongside the value fields.
26#[derive(Debug, Clone, PartialEq)]
27pub struct TypedDetail {
28    pub type_url: String,
29    pub value: HashMap<String, Value>,
30}
31
32impl TypedDetail {
33    pub fn new(type_url: impl Into<String>, value: HashMap<String, Value>) -> Self {
34        Self {
35            type_url: type_url.into(),
36            value,
37        }
38    }
39
40    /// Create a `google.rpc.ErrorInfo` detail.
41    pub fn error_info(
42        reason: impl Into<String>,
43        domain: impl Into<String>,
44        metadata: Option<HashMap<String, String>>,
45    ) -> Self {
46        let mut value = HashMap::new();
47        value.insert("reason".to_string(), Value::String(reason.into()));
48        value.insert("domain".to_string(), Value::String(domain.into()));
49        if let Some(meta) = metadata {
50            let meta_obj: serde_json::Map<String, Value> = meta
51                .into_iter()
52                .map(|(k, v)| (k, Value::String(v)))
53                .collect();
54            value.insert("metadata".to_string(), Value::Object(meta_obj));
55        }
56        Self {
57            type_url: ERROR_INFO_TYPE.to_string(),
58            value,
59        }
60    }
61
62    /// Create a `google.rpc.BadRequest` detail with field violations.
63    pub fn bad_request(field_violations: Vec<FieldViolation>) -> Self {
64        let violations: Vec<Value> = field_violations
65            .into_iter()
66            .map(|fv| serde_json::to_value(fv).unwrap_or_default())
67            .collect();
68        let mut value = HashMap::new();
69        value.insert("fieldViolations".to_string(), Value::Array(violations));
70        Self {
71            type_url: BAD_REQUEST_TYPE.to_string(),
72            value,
73        }
74    }
75
76    /// Create a typed detail from a struct (arbitrary map).
77    pub fn from_struct(fields: HashMap<String, Value>) -> Self {
78        Self {
79            type_url: STRUCT_TYPE.to_string(),
80            value: fields,
81        }
82    }
83}
84
85impl Serialize for TypedDetail {
86    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
87        use serde::ser::SerializeMap;
88        let mut map = serializer.serialize_map(Some(self.value.len() + 1))?;
89        map.serialize_entry("@type", &self.type_url)?;
90        for (k, v) in &self.value {
91            map.serialize_entry(k, v)?;
92        }
93        map.end()
94    }
95}
96
97impl<'de> Deserialize<'de> for TypedDetail {
98    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
99        let mut map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
100        let type_url = map
101            .remove("@type")
102            .and_then(|v| v.as_str().map(String::from))
103            .unwrap_or_else(|| STRUCT_TYPE.to_string());
104        Ok(Self {
105            type_url,
106            value: map,
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_serialize_typed_detail() {
117        let detail = TypedDetail::error_info("TASK_NOT_FOUND", PROTOCOL_DOMAIN, None);
118        let json = serde_json::to_value(&detail).unwrap();
119        assert_eq!(json["@type"], ERROR_INFO_TYPE);
120        assert_eq!(json["reason"], "TASK_NOT_FOUND");
121        assert_eq!(json["domain"], PROTOCOL_DOMAIN);
122    }
123
124    #[test]
125    fn test_deserialize_typed_detail() {
126        let json = serde_json::json!({
127            "@type": ERROR_INFO_TYPE,
128            "reason": "TASK_NOT_FOUND",
129            "domain": PROTOCOL_DOMAIN,
130            "metadata": {"taskId": "t1"}
131        });
132        let detail: TypedDetail = serde_json::from_value(json).unwrap();
133        assert_eq!(detail.type_url, ERROR_INFO_TYPE);
134        assert_eq!(detail.value["reason"], "TASK_NOT_FOUND");
135        assert_eq!(detail.value["domain"], PROTOCOL_DOMAIN);
136        assert_eq!(detail.value["metadata"]["taskId"], "t1");
137    }
138
139    #[test]
140    fn test_deserialize_without_type_defaults_to_struct() {
141        let json = serde_json::json!({"resource": "task"});
142        let detail: TypedDetail = serde_json::from_value(json).unwrap();
143        assert_eq!(detail.type_url, STRUCT_TYPE);
144        assert_eq!(detail.value["resource"], "task");
145    }
146
147    #[test]
148    fn test_round_trip() {
149        let meta = HashMap::from([("taskId".to_string(), "t1".to_string())]);
150        let detail = TypedDetail::error_info("TASK_NOT_FOUND", PROTOCOL_DOMAIN, Some(meta));
151        let serialized = serde_json::to_value(&detail).unwrap();
152        let deserialized: TypedDetail = serde_json::from_value(serialized).unwrap();
153        assert_eq!(detail, deserialized);
154    }
155
156    #[test]
157    fn test_bad_request() {
158        let violations = vec![
159            FieldViolation {
160                field: "message.parts".into(),
161                description: "At least one part is required".into(),
162            },
163            FieldViolation {
164                field: "message.role".into(),
165                description: "Role must be 'user' or 'agent'".into(),
166            },
167        ];
168        let detail = TypedDetail::bad_request(violations);
169        assert_eq!(detail.type_url, BAD_REQUEST_TYPE);
170
171        let json = serde_json::to_value(&detail).unwrap();
172        assert_eq!(json["@type"], BAD_REQUEST_TYPE);
173        let fv = json["fieldViolations"].as_array().unwrap();
174        assert_eq!(fv.len(), 2);
175        assert_eq!(fv[0]["field"], "message.parts");
176        assert_eq!(fv[0]["description"], "At least one part is required");
177        assert_eq!(fv[1]["field"], "message.role");
178    }
179
180    #[test]
181    fn test_bad_request_round_trip() {
182        let violations = vec![FieldViolation {
183            field: "task.id".into(),
184            description: "Must not be empty".into(),
185        }];
186        let detail = TypedDetail::bad_request(violations);
187        let serialized = serde_json::to_value(&detail).unwrap();
188        let deserialized: TypedDetail = serde_json::from_value(serialized).unwrap();
189        assert_eq!(detail, deserialized);
190    }
191
192    #[test]
193    fn test_from_struct() {
194        let mut fields = HashMap::new();
195        fields.insert("key".to_string(), Value::String("val".to_string()));
196        let detail = TypedDetail::from_struct(fields.clone());
197        assert_eq!(detail.type_url, STRUCT_TYPE);
198        assert_eq!(detail.value, fields);
199    }
200}