1use std::collections::HashMap;
7
8use serde::Serialize;
9use serde_json::{Map, Value};
10
11const BUNDLE: &str = include_str!("../schema/a2a.json");
15
16pub const SCHEMA_A2A_VERSION: &str = "v1.0.1-48-g2b93e318";
19
20const AGENT_CARD: &str = "Agent Card";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct Violation {
31 pub path: String,
33 pub message: String,
34}
35
36fn normalise(name: &str) -> String {
41 name.chars()
42 .filter(|c| c.is_ascii_alphanumeric())
43 .map(|c| c.to_ascii_lowercase())
44 .collect()
45}
46
47fn definition_key(reference: &str, index: &HashMap<String, String>) -> Option<String> {
58 let name = reference
59 .strip_prefix("#/definitions/")
60 .unwrap_or(reference);
61 let base = name.strip_suffix(".jsonschema.json").unwrap_or(name);
62 let type_name = base.rsplit('.').next()?;
63 let key = normalise(type_name);
64 index.contains_key(&key).then_some(key)
65}
66
67fn retarget_refs(node: &mut Value, index: &HashMap<String, String>, unresolved: &mut Vec<String>) {
71 match node {
72 Value::Object(map) => {
73 if let Some(Value::String(reference)) = map.get("$ref") {
74 match definition_key(reference, index) {
75 Some(key) => {
76 let pointer = format!("#/definitions/{key}");
77 map.insert("$ref".to_string(), Value::String(pointer));
78 }
79 None => unresolved.push(reference.clone()),
80 }
81 }
82 for value in map.values_mut() {
83 retarget_refs(value, index, unresolved);
84 }
85 }
86 Value::Array(items) => {
87 for item in items {
88 retarget_refs(item, index, unresolved);
89 }
90 }
91 _ => {}
92 }
93}
94
95fn agent_card_schema() -> Result<Value, String> {
101 let bundle: Value =
102 serde_json::from_str(BUNDLE).map_err(|e| format!("vendored schema is not JSON: {e}"))?;
103 build_schema(bundle)
104}
105
106fn build_schema(mut bundle: Value) -> Result<Value, String> {
112 let original = bundle
113 .get("definitions")
114 .and_then(Value::as_object)
115 .ok_or("vendored schema has no definitions")?
116 .clone();
117 let index: HashMap<String, String> = original
118 .keys()
119 .map(|name| (normalise(name), name.clone()))
120 .collect();
121
122 let card_key = normalise(AGENT_CARD);
123 if !index.contains_key(&card_key) {
124 return Err(format!("vendored schema has no {AGENT_CARD} definition"));
125 }
126
127 let mut unresolved = Vec::new();
128 retarget_refs(&mut bundle, &index, &mut unresolved);
129 if !unresolved.is_empty() {
130 unresolved.sort();
131 unresolved.dedup();
132 return Err(format!(
133 "vendored schema has unplaceable references: {}",
134 unresolved.join(", ")
135 ));
136 }
137
138 let rehoused: Map<String, Value> = bundle
139 .get("definitions")
140 .and_then(Value::as_object)
141 .ok_or("vendored schema has no definitions")?
142 .iter()
143 .map(|(name, schema)| (normalise(name), schema.clone()))
144 .collect();
145
146 let mut document = Map::new();
147 document.insert(
148 "$schema".to_string(),
149 Value::String("https://json-schema.org/draft/2020-12/schema".to_string()),
150 );
151 document.insert(
152 "$ref".to_string(),
153 Value::String(format!("#/definitions/{card_key}")),
154 );
155 document.insert("definitions".to_string(), Value::Object(rehoused));
156 Ok(Value::Object(document))
157}
158
159pub fn validate_agent_card(card: &Value) -> Result<Vec<Violation>, String> {
164 let schema = agent_card_schema()?;
165 let validator = jsonschema::validator_for(&schema)
166 .map_err(|e| format!("vendored schema does not compile: {e}"))?;
167
168 let mut violations: Vec<Violation> = validator
169 .iter_errors(card)
170 .map(|error| Violation {
171 path: error.instance_path().to_string(),
172 message: error.to_string(),
173 })
174 .collect();
175 violations.sort_by(|a, b| (&a.path, &a.message).cmp(&(&b.path, &b.message)));
176 violations.dedup();
177 Ok(violations)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use serde_json::json;
184
185 fn valid_card() -> Value {
186 json!({
187 "name": "Fixture Agent",
188 "description": "a card that satisfies the schema",
189 "version": "1.0.0",
190 "supportedInterfaces": [
191 {"url": "http://localhost:3000/jsonrpc", "protocolBinding": "JSONRPC"}
192 ],
193 "capabilities": {"streaming": true},
194 "defaultInputModes": ["text/plain"],
195 "defaultOutputModes": ["text/plain"],
196 "skills": []
197 })
198 }
199
200 #[test]
204 fn every_reference_in_the_bundle_resolves() {
205 let schema = agent_card_schema().expect("bundle should load");
206 let rendered = serde_json::to_string(&schema).unwrap();
207 assert!(
208 !rendered.contains(".jsonschema.json"),
209 "an external reference survived retargeting"
210 );
211 assert!(jsonschema::validator_for(&schema).is_ok());
212 }
213
214 #[test]
217 fn a_bundle_missing_the_agent_card_definition_is_rejected() {
218 let bundle = json!({
219 "definitions": {
220 "Something Else": {"type": "object"}
221 }
222 });
223 let error = build_schema(bundle).unwrap_err();
224 assert!(error.contains("Agent Card"), "{error}");
225 }
226
227 #[test]
230 fn a_bundle_with_an_unplaceable_reference_is_rejected() {
231 let bundle = json!({
232 "definitions": {
233 "Agent Card": {"$ref": "#/definitions/NoSuchType"}
234 }
235 });
236 let error = build_schema(bundle).unwrap_err();
237 assert!(error.contains("NoSuchType"), "{error}");
238 }
239
240 #[test]
241 fn a_valid_card_has_no_violations() {
242 assert_eq!(validate_agent_card(&valid_card()).unwrap(), vec![]);
243 }
244
245 #[test]
246 fn an_unknown_property_is_reported_with_its_path() {
247 let mut card = valid_card();
248 card["speling"] = json!("mistake");
249
250 let violations = validate_agent_card(&card).unwrap();
251 assert_eq!(violations.len(), 1, "{violations:?}");
252 assert!(
253 violations[0].message.contains("speling"),
254 "{:?}",
255 violations[0]
256 );
257 }
258
259 #[test]
260 fn a_wrong_type_is_reported_with_its_path() {
261 let mut card = valid_card();
262 card["name"] = json!(42);
263
264 let violations = validate_agent_card(&card).unwrap();
265 assert_eq!(violations.len(), 1, "{violations:?}");
266 assert_eq!(violations[0].path, "/name");
267 }
268
269 #[test]
271 fn several_violations_are_all_reported_each_with_its_own_path() {
272 let mut card = valid_card();
273 card["name"] = json!(42);
274 card["defaultInputModes"] = json!("text/plain");
275 card["nonsense"] = json!(true);
276
277 let violations = validate_agent_card(&card).unwrap();
278 let paths: Vec<&str> = violations.iter().map(|v| v.path.as_str()).collect();
279 assert!(paths.contains(&"/name"), "{violations:?}");
280 assert!(paths.contains(&"/defaultInputModes"), "{violations:?}");
281 assert!(violations.len() >= 3, "{violations:?}");
282 }
283
284 #[test]
287 fn an_unrecognised_enum_member_is_reported() {
288 let mut card = valid_card();
289 card["skills"] = json!([{
290 "id": "s1",
291 "name": "skill",
292 "description": "d",
293 "tags": [],
294 "security": [{"schemes": {"oauth": {"oauth2": {"flows": {"unknownFlow": {}}}}}}]
295 }]);
296
297 let violations = validate_agent_card(&card).unwrap();
298 assert!(!violations.is_empty(), "an invalid nested shape passed");
299 assert!(
300 violations.iter().all(|v| v.path.starts_with("/skills")),
301 "{violations:?}"
302 );
303 }
304
305 #[test]
306 fn the_schema_records_which_a2a_it_came_from() {
307 assert!(SCHEMA_A2A_VERSION.starts_with("v1."));
308 }
309}