Skip to main content

a2acli/
card_schema.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// Copyright A2A Contributors (https://github.com/a2aproject)
3// SPDX-License-Identifier: Apache-2.0
4//! Agent Card validation against the vendored A2A JSON Schema (ยง10.1).
5
6use std::collections::HashMap;
7
8use serde::Serialize;
9use serde_json::{Map, Value};
10
11/// The canonical A2A schema bundle, vendored byte-identical to upstream so a
12/// reviewer can diff it. Its `$ref`s are retargeted at load time rather than
13/// in the file, keeping the copy honest.
14const BUNDLE: &str = include_str!("../schema/a2a.json");
15
16/// Which A2A the vendored bundle came from: "valid" says nothing without
17/// naming what it was valid against.
18pub const SCHEMA_A2A_VERSION: &str = "v1.0.1-48-g2b93e318";
19
20/// The bundle's name for an Agent Card. Its definitions are spaced words
21/// rather than type names.
22const AGENT_CARD: &str = "Agent Card";
23
24/// One schema violation, located by JSON pointer into the card.
25///
26/// `Serialize` derives directly, rather than through a second CLI-side type:
27/// this is exactly the shape `-o json` reports each violation as under
28/// `card get --validate`'s error envelope.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct Violation {
31    /// JSON pointer to the offending value; empty for the card itself.
32    pub path: String,
33    pub message: String,
34}
35
36/// Normalised key for matching a `$ref` filename to a definition name:
37/// upstream spells the same type `APIKeySecurityScheme` in a ref and
38/// `API Key Security Scheme` in a definition, and its own splitting is not
39/// consistent (`Authorization CodeO Auth Flow`), so compare on letters alone.
40fn 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
47/// The normalised key a `$ref` names, if the bundle has a definition for it.
48///
49/// A ref is one of two shapes: an internal `#/definitions/Agent Card`
50/// (the bundle's own definitions cross-reference each other this way), or an
51/// external sibling file the bundle does not ship
52/// (`a2a.v1.AgentSkill.jsonschema.json`, `google.protobuf.Struct.jsonschema.json`).
53/// Both name a definition by a spelling that differs from its key, so both
54/// are redirected to the same normalised key the definitions are rehoused
55/// under -- which also sidesteps the definitions' names containing spaces,
56/// not legal in a URI fragment (`#/definitions/Agent Card` does not parse).
57fn 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
67/// Rewrites every `$ref` -- internal or external -- to the matching
68/// normalised key. Returns the refs it could not place, so a bundle update
69/// cannot silently leave part of the card unvalidated.
70fn 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
95/// The Agent Card schema, as a self-contained document.
96///
97/// The bundle's definitions are rehoused under normalised, space-free keys
98/// before anything references them, since `Agent Card` and its siblings
99/// cannot appear in a `$ref` as spelled.
100fn 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
106/// The guts of [`agent_card_schema`], taking the bundle as a `Value` rather
107/// than reading the vendored file, so its defensive paths -- a bundle update
108/// that drops the `Agent Card` definition, or adds a `$ref` this index can't
109/// place -- are exercised against a deliberately broken fixture instead of
110/// staying dead code no real (i.e. currently valid) bundle would reach.
111fn 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
159/// Every violation in `card`, ordered by position so the report is stable.
160///
161/// All of them, not just the first: a card with three problems should take
162/// one run to diagnose.
163pub 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    /// The bundle ships refs to sibling files it does not contain, so this is
201    /// the load-bearing step: if a future update adds a type the index cannot
202    /// place, it must fail loudly rather than skip validating that subtree.
203    #[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    /// The real vendored bundle always has an `Agent Card` definition, so
215    /// this defensive check needs a fixture that deliberately lacks one.
216    #[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    /// Likewise, the real bundle's refs always resolve; this needs one that
228    /// deliberately doesn't.
229    #[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    /// The whole point of collecting rather than short-circuiting.
270    #[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    /// Enums reach the bundle as an `anyOf` of a pattern, an enum and an
285    /// integer range, so an unrecognised member still fails every branch.
286    #[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}