use std::collections::HashMap;
use serde::Serialize;
use serde_json::{Map, Value};
const BUNDLE: &str = include_str!("../schema/a2a.json");
pub const SCHEMA_A2A_VERSION: &str = "v1.0.1-48-g2b93e318";
const AGENT_CARD: &str = "Agent Card";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Violation {
pub path: String,
pub message: String,
}
fn normalise(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn definition_key(reference: &str, index: &HashMap<String, String>) -> Option<String> {
let name = reference
.strip_prefix("#/definitions/")
.unwrap_or(reference);
let base = name.strip_suffix(".jsonschema.json").unwrap_or(name);
let type_name = base.rsplit('.').next()?;
let key = normalise(type_name);
index.contains_key(&key).then_some(key)
}
fn retarget_refs(node: &mut Value, index: &HashMap<String, String>, unresolved: &mut Vec<String>) {
match node {
Value::Object(map) => {
if let Some(Value::String(reference)) = map.get("$ref") {
match definition_key(reference, index) {
Some(key) => {
let pointer = format!("#/definitions/{key}");
map.insert("$ref".to_string(), Value::String(pointer));
}
None => unresolved.push(reference.clone()),
}
}
for value in map.values_mut() {
retarget_refs(value, index, unresolved);
}
}
Value::Array(items) => {
for item in items {
retarget_refs(item, index, unresolved);
}
}
_ => {}
}
}
fn agent_card_schema() -> Result<Value, String> {
let bundle: Value =
serde_json::from_str(BUNDLE).map_err(|e| format!("vendored schema is not JSON: {e}"))?;
build_schema(bundle)
}
fn build_schema(mut bundle: Value) -> Result<Value, String> {
let original = bundle
.get("definitions")
.and_then(Value::as_object)
.ok_or("vendored schema has no definitions")?
.clone();
let index: HashMap<String, String> = original
.keys()
.map(|name| (normalise(name), name.clone()))
.collect();
let card_key = normalise(AGENT_CARD);
if !index.contains_key(&card_key) {
return Err(format!("vendored schema has no {AGENT_CARD} definition"));
}
let mut unresolved = Vec::new();
retarget_refs(&mut bundle, &index, &mut unresolved);
if !unresolved.is_empty() {
unresolved.sort();
unresolved.dedup();
return Err(format!(
"vendored schema has unplaceable references: {}",
unresolved.join(", ")
));
}
let rehoused: Map<String, Value> = bundle
.get("definitions")
.and_then(Value::as_object)
.ok_or("vendored schema has no definitions")?
.iter()
.map(|(name, schema)| (normalise(name), schema.clone()))
.collect();
let mut document = Map::new();
document.insert(
"$schema".to_string(),
Value::String("https://json-schema.org/draft/2020-12/schema".to_string()),
);
document.insert(
"$ref".to_string(),
Value::String(format!("#/definitions/{card_key}")),
);
document.insert("definitions".to_string(), Value::Object(rehoused));
Ok(Value::Object(document))
}
pub fn validate_agent_card(card: &Value) -> Result<Vec<Violation>, String> {
let schema = agent_card_schema()?;
let validator = jsonschema::validator_for(&schema)
.map_err(|e| format!("vendored schema does not compile: {e}"))?;
let mut violations: Vec<Violation> = validator
.iter_errors(card)
.map(|error| Violation {
path: error.instance_path().to_string(),
message: error.to_string(),
})
.collect();
violations.sort_by(|a, b| (&a.path, &a.message).cmp(&(&b.path, &b.message)));
violations.dedup();
Ok(violations)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn valid_card() -> Value {
json!({
"name": "Fixture Agent",
"description": "a card that satisfies the schema",
"version": "1.0.0",
"supportedInterfaces": [
{"url": "http://localhost:3000/jsonrpc", "protocolBinding": "JSONRPC"}
],
"capabilities": {"streaming": true},
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": []
})
}
#[test]
fn every_reference_in_the_bundle_resolves() {
let schema = agent_card_schema().expect("bundle should load");
let rendered = serde_json::to_string(&schema).unwrap();
assert!(
!rendered.contains(".jsonschema.json"),
"an external reference survived retargeting"
);
assert!(jsonschema::validator_for(&schema).is_ok());
}
#[test]
fn a_bundle_missing_the_agent_card_definition_is_rejected() {
let bundle = json!({
"definitions": {
"Something Else": {"type": "object"}
}
});
let error = build_schema(bundle).unwrap_err();
assert!(error.contains("Agent Card"), "{error}");
}
#[test]
fn a_bundle_with_an_unplaceable_reference_is_rejected() {
let bundle = json!({
"definitions": {
"Agent Card": {"$ref": "#/definitions/NoSuchType"}
}
});
let error = build_schema(bundle).unwrap_err();
assert!(error.contains("NoSuchType"), "{error}");
}
#[test]
fn a_valid_card_has_no_violations() {
assert_eq!(validate_agent_card(&valid_card()).unwrap(), vec![]);
}
#[test]
fn an_unknown_property_is_reported_with_its_path() {
let mut card = valid_card();
card["speling"] = json!("mistake");
let violations = validate_agent_card(&card).unwrap();
assert_eq!(violations.len(), 1, "{violations:?}");
assert!(
violations[0].message.contains("speling"),
"{:?}",
violations[0]
);
}
#[test]
fn a_wrong_type_is_reported_with_its_path() {
let mut card = valid_card();
card["name"] = json!(42);
let violations = validate_agent_card(&card).unwrap();
assert_eq!(violations.len(), 1, "{violations:?}");
assert_eq!(violations[0].path, "/name");
}
#[test]
fn several_violations_are_all_reported_each_with_its_own_path() {
let mut card = valid_card();
card["name"] = json!(42);
card["defaultInputModes"] = json!("text/plain");
card["nonsense"] = json!(true);
let violations = validate_agent_card(&card).unwrap();
let paths: Vec<&str> = violations.iter().map(|v| v.path.as_str()).collect();
assert!(paths.contains(&"/name"), "{violations:?}");
assert!(paths.contains(&"/defaultInputModes"), "{violations:?}");
assert!(violations.len() >= 3, "{violations:?}");
}
#[test]
fn an_unrecognised_enum_member_is_reported() {
let mut card = valid_card();
card["skills"] = json!([{
"id": "s1",
"name": "skill",
"description": "d",
"tags": [],
"security": [{"schemes": {"oauth": {"oauth2": {"flows": {"unknownFlow": {}}}}}}]
}]);
let violations = validate_agent_card(&card).unwrap();
assert!(!violations.is_empty(), "an invalid nested shape passed");
assert!(
violations.iter().all(|v| v.path.starts_with("/skills")),
"{violations:?}"
);
}
#[test]
fn the_schema_records_which_a2a_it_came_from() {
assert!(SCHEMA_A2A_VERSION.starts_with("v1."));
}
}