Skip to content

Commit ead2ee1

Browse files
committed
feat(a2a): Add enhanced Agent Card validation (#6996)
Implements comprehensive validation for A2A Agent Card artifacts following the A2A protocol specification. Changes: - Add A2A Agent Card JSON Schema (a2a-agent-card-schema.json) for reference - Enhance AgentCardContentValidator with full structure validation: - Required field validation (name) - Type checking for all fields - Skill validation (id and name required, tags/examples arrays) - Provider field validation - Capabilities field validation (boolean types) - Authentication field validation - Input/output modes validation - Add 6 new test resources for validation scenarios - Add 6 new unit tests (16 total) Validation levels: - NONE: No validation - SYNTAX_ONLY: Valid JSON object check - FULL: Complete schema-like validation Part of #6996
1 parent e548a4f commit ead2ee1

File tree

8 files changed

+470
-28
lines changed

8 files changed

+470
-28
lines changed

schema-util/common/src/main/java/io/apicurio/registry/rules/validity/AgentCardContentValidator.java

Lines changed: 204 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
*
2020
* Validates that the content is a valid Agent Card JSON document following the A2A protocol specification.
2121
*
22+
* Validation levels:
23+
* - NONE: No validation
24+
* - SYNTAX_ONLY: Validates that the content is valid JSON and is an object
25+
* - FULL: Full schema validation including required fields, type checking, and structure validation
26+
*
2227
* @see <a href="https://a2a-protocol.org/">A2A Protocol</a>
2328
*/
2429
public class AgentCardContentValidator implements ContentValidator {
@@ -42,60 +47,231 @@ public void validate(ValidityLevel level, TypedContent content,
4247
Collections.singleton(new RuleViolation("Agent Card must be a JSON object", "")));
4348
}
4449

45-
// SYNTAX_ONLY level: just check it's valid JSON (already done by parsing)
50+
// SYNTAX_ONLY level: just check it's valid JSON object (already done)
4651
if (level == ValidityLevel.SYNTAX_ONLY) {
4752
return;
4853
}
4954

50-
// FULL level: validate required fields
51-
if (!tree.has("name") || tree.get("name").asText().trim().isEmpty()) {
52-
violations.add(new RuleViolation("Agent Card must have a non-empty 'name' field", "/name"));
55+
// FULL level: comprehensive validation
56+
validateRequiredFields(tree, violations);
57+
validateStringFields(tree, violations);
58+
validateProviderField(tree, violations);
59+
validateCapabilitiesField(tree, violations);
60+
validateSkillsField(tree, violations);
61+
validateArrayFields(tree, violations);
62+
validateAuthenticationField(tree, violations);
63+
64+
if (!violations.isEmpty()) {
65+
throw new RuleViolationException("Invalid Agent Card", RuleType.VALIDITY, level.name(),
66+
violations);
5367
}
5468

55-
// Validate optional but typed fields if present
56-
if (tree.has("version") && !tree.get("version").isTextual()) {
57-
violations.add(new RuleViolation("'version' field must be a string", "/version"));
69+
} catch (RuleViolationException e) {
70+
throw e;
71+
} catch (Exception e) {
72+
throw new RuleViolationException("Invalid Agent Card JSON: " + e.getMessage(),
73+
RuleType.VALIDITY, level.name(), e);
74+
}
75+
}
76+
77+
private void validateRequiredFields(JsonNode tree, Set<RuleViolation> violations) {
78+
// 'name' is required and must be non-empty
79+
if (!tree.has("name")) {
80+
violations.add(new RuleViolation("Agent Card must have a 'name' field", "/name"));
81+
} else if (!tree.get("name").isTextual()) {
82+
violations.add(new RuleViolation("'name' field must be a string", "/name"));
83+
} else if (tree.get("name").asText().trim().isEmpty()) {
84+
violations.add(new RuleViolation("'name' field must not be empty", "/name"));
85+
}
86+
}
87+
88+
private void validateStringFields(JsonNode tree, Set<RuleViolation> violations) {
89+
// Validate optional string fields
90+
validateOptionalString(tree, "description", violations);
91+
validateOptionalString(tree, "version", violations);
92+
validateOptionalString(tree, "url", violations);
93+
}
94+
95+
private void validateOptionalString(JsonNode tree, String fieldName, Set<RuleViolation> violations) {
96+
if (tree.has(fieldName) && !tree.get(fieldName).isTextual()) {
97+
violations.add(new RuleViolation("'" + fieldName + "' field must be a string", "/" + fieldName));
98+
}
99+
}
100+
101+
private void validateProviderField(JsonNode tree, Set<RuleViolation> violations) {
102+
if (!tree.has("provider")) {
103+
return;
104+
}
105+
106+
JsonNode provider = tree.get("provider");
107+
if (!provider.isObject()) {
108+
violations.add(new RuleViolation("'provider' field must be an object", "/provider"));
109+
return;
110+
}
111+
112+
if (provider.has("organization") && !provider.get("organization").isTextual()) {
113+
violations.add(new RuleViolation("'provider.organization' must be a string", "/provider/organization"));
114+
}
115+
116+
if (provider.has("url") && !provider.get("url").isTextual()) {
117+
violations.add(new RuleViolation("'provider.url' must be a string", "/provider/url"));
118+
}
119+
}
120+
121+
private void validateCapabilitiesField(JsonNode tree, Set<RuleViolation> violations) {
122+
if (!tree.has("capabilities")) {
123+
return;
124+
}
125+
126+
JsonNode capabilities = tree.get("capabilities");
127+
if (!capabilities.isObject()) {
128+
violations.add(new RuleViolation("'capabilities' field must be an object", "/capabilities"));
129+
return;
130+
}
131+
132+
// Validate known capability fields are booleans
133+
validateCapabilityBoolean(capabilities, "streaming", violations);
134+
validateCapabilityBoolean(capabilities, "pushNotifications", violations);
135+
}
136+
137+
private void validateCapabilityBoolean(JsonNode capabilities, String fieldName, Set<RuleViolation> violations) {
138+
if (capabilities.has(fieldName) && !capabilities.get(fieldName).isBoolean()) {
139+
violations.add(new RuleViolation("'capabilities." + fieldName + "' must be a boolean",
140+
"/capabilities/" + fieldName));
141+
}
142+
}
143+
144+
private void validateSkillsField(JsonNode tree, Set<RuleViolation> violations) {
145+
if (!tree.has("skills")) {
146+
return;
147+
}
148+
149+
JsonNode skills = tree.get("skills");
150+
if (!skills.isArray()) {
151+
violations.add(new RuleViolation("'skills' field must be an array", "/skills"));
152+
return;
153+
}
154+
155+
int index = 0;
156+
for (JsonNode skill : skills) {
157+
String basePath = "/skills/" + index;
158+
159+
if (!skill.isObject()) {
160+
violations.add(new RuleViolation("Skill at index " + index + " must be an object", basePath));
161+
index++;
162+
continue;
58163
}
59164

60-
if (tree.has("url") && !tree.get("url").isTextual()) {
61-
violations.add(new RuleViolation("'url' field must be a string", "/url"));
165+
// 'id' is required for skills
166+
if (!skill.has("id")) {
167+
violations.add(new RuleViolation("Skill at index " + index + " must have an 'id' field",
168+
basePath + "/id"));
169+
} else if (!skill.get("id").isTextual()) {
170+
violations.add(new RuleViolation("Skill 'id' must be a string", basePath + "/id"));
171+
} else if (skill.get("id").asText().trim().isEmpty()) {
172+
violations.add(new RuleViolation("Skill 'id' must not be empty", basePath + "/id"));
62173
}
63174

64-
if (tree.has("capabilities") && !tree.get("capabilities").isObject()) {
65-
violations.add(new RuleViolation("'capabilities' field must be an object", "/capabilities"));
175+
// 'name' is required for skills
176+
if (!skill.has("name")) {
177+
violations.add(new RuleViolation("Skill at index " + index + " must have a 'name' field",
178+
basePath + "/name"));
179+
} else if (!skill.get("name").isTextual()) {
180+
violations.add(new RuleViolation("Skill 'name' must be a string", basePath + "/name"));
66181
}
67182

68-
if (tree.has("skills") && !tree.get("skills").isArray()) {
69-
violations.add(new RuleViolation("'skills' field must be an array", "/skills"));
183+
// Optional fields validation
184+
if (skill.has("description") && !skill.get("description").isTextual()) {
185+
violations.add(new RuleViolation("Skill 'description' must be a string", basePath + "/description"));
70186
}
71187

72-
if (tree.has("defaultInputModes") && !tree.get("defaultInputModes").isArray()) {
73-
violations.add(
74-
new RuleViolation("'defaultInputModes' field must be an array", "/defaultInputModes"));
188+
if (skill.has("tags") && !skill.get("tags").isArray()) {
189+
violations.add(new RuleViolation("Skill 'tags' must be an array", basePath + "/tags"));
190+
} else if (skill.has("tags")) {
191+
validateStringArray(skill.get("tags"), basePath + "/tags", "tag", violations);
75192
}
76193

77-
if (tree.has("defaultOutputModes") && !tree.get("defaultOutputModes").isArray()) {
78-
violations.add(new RuleViolation("'defaultOutputModes' field must be an array",
79-
"/defaultOutputModes"));
194+
if (skill.has("examples") && !skill.get("examples").isArray()) {
195+
violations.add(new RuleViolation("Skill 'examples' must be an array", basePath + "/examples"));
196+
} else if (skill.has("examples")) {
197+
validateStringArray(skill.get("examples"), basePath + "/examples", "example", violations);
80198
}
81199

82-
if (!violations.isEmpty()) {
83-
throw new RuleViolationException("Invalid Agent Card", RuleType.VALIDITY, level.name(),
84-
violations);
200+
index++;
201+
}
202+
}
203+
204+
private void validateArrayFields(JsonNode tree, Set<RuleViolation> violations) {
205+
validateStringArrayField(tree, "defaultInputModes", violations);
206+
validateStringArrayField(tree, "defaultOutputModes", violations);
207+
}
208+
209+
private void validateStringArrayField(JsonNode tree, String fieldName, Set<RuleViolation> violations) {
210+
if (!tree.has(fieldName)) {
211+
return;
212+
}
213+
214+
JsonNode array = tree.get(fieldName);
215+
if (!array.isArray()) {
216+
violations.add(new RuleViolation("'" + fieldName + "' field must be an array", "/" + fieldName));
217+
return;
218+
}
219+
220+
validateStringArray(array, "/" + fieldName, "item", violations);
221+
}
222+
223+
private void validateStringArray(JsonNode array, String basePath, String itemName, Set<RuleViolation> violations) {
224+
int index = 0;
225+
for (JsonNode item : array) {
226+
if (!item.isTextual()) {
227+
violations.add(new RuleViolation("Each " + itemName + " must be a string",
228+
basePath + "/" + index));
85229
}
230+
index++;
231+
}
232+
}
86233

87-
} catch (RuleViolationException e) {
88-
throw e;
89-
} catch (Exception e) {
90-
throw new RuleViolationException("Invalid Agent Card JSON: " + e.getMessage(),
91-
RuleType.VALIDITY, level.name(), e);
234+
private void validateAuthenticationField(JsonNode tree, Set<RuleViolation> violations) {
235+
if (!tree.has("authentication")) {
236+
return;
237+
}
238+
239+
JsonNode auth = tree.get("authentication");
240+
if (!auth.isObject()) {
241+
violations.add(new RuleViolation("'authentication' field must be an object", "/authentication"));
242+
return;
243+
}
244+
245+
if (auth.has("schemes")) {
246+
if (!auth.get("schemes").isArray()) {
247+
violations.add(new RuleViolation("'authentication.schemes' must be an array",
248+
"/authentication/schemes"));
249+
} else {
250+
validateStringArray(auth.get("schemes"), "/authentication/schemes", "scheme", violations);
251+
}
252+
}
253+
254+
if (auth.has("credentials")) {
255+
if (!auth.get("credentials").isArray()) {
256+
violations.add(new RuleViolation("'authentication.credentials' must be an array",
257+
"/authentication/credentials"));
258+
} else {
259+
int index = 0;
260+
for (JsonNode cred : auth.get("credentials")) {
261+
if (!cred.isObject()) {
262+
violations.add(new RuleViolation("Credential at index " + index + " must be an object",
263+
"/authentication/credentials/" + index));
264+
}
265+
index++;
266+
}
267+
}
92268
}
93269
}
94270

95271
@Override
96272
public void validateReferences(TypedContent content, List<ArtifactReference> references)
97273
throws RuleViolationException {
98-
// Agent Cards don't support references in MVP
274+
// Agent Cards don't support references
99275
if (references != null && !references.isEmpty()) {
100276
throw new RuleViolationException("Agent Cards do not support references",
101277
RuleType.INTEGRITY, "NONE",

0 commit comments

Comments
 (0)