Skip to content

Commit 8946760

Browse files
authored
feat!: Add SecurityRequirement to the domain API (#670)
This replaces the `List<Map<String, List<String>>>` that was not properly serialized to JSON. Rename spec-grpc SecurityMapper to SecurityRequirementMapper.java This fixes #667 Signed-off-by: Jeff Mesnil <jmesnil@ibm.com>
1 parent 4808aff commit 8946760

13 files changed

Lines changed: 465 additions & 132 deletions

File tree

client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import io.a2a.spec.OpenIdConnectSecurityScheme;
5858
import io.a2a.spec.Part;
5959
import io.a2a.spec.PushNotificationConfig;
60+
import io.a2a.spec.SecurityRequirement;
6061
import io.a2a.spec.SecurityScheme;
6162
import io.a2a.spec.Task;
6263
import io.a2a.spec.TaskIdParams;
@@ -376,9 +377,9 @@ public void testA2AClientGetExtendedAgentCard() throws Exception {
376377
assertNotNull(securitySchemes);
377378
OpenIdConnectSecurityScheme google = (OpenIdConnectSecurityScheme) securitySchemes.get("google");
378379
assertEquals("https://accounts.google.com/.well-known/openid-configuration", google.openIdConnectUrl());
379-
List<Map<String, List<String>>> security = agentCard.securityRequirements();
380+
List<SecurityRequirement> security = agentCard.securityRequirements();
380381
assertEquals(1, security.size());
381-
Map<String, List<String>> securityMap = security.get(0);
382+
Map<String, List<String>> securityMap = security.get(0).schemes();
382383
List<String> scopes = securityMap.get("google");
383384
List<String> expectedScopes = List.of("openid", "profile", "email");
384385
assertEquals(expectedScopes, scopes);

client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptor.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.a2a.spec.HTTPAuthSecurityScheme;
1414
import io.a2a.spec.OAuth2SecurityScheme;
1515
import io.a2a.spec.OpenIdConnectSecurityScheme;
16+
import io.a2a.spec.SecurityRequirement;
1617
import io.a2a.spec.SecurityScheme;
1718
import org.jspecify.annotations.Nullable;
1819

@@ -38,8 +39,11 @@ public PayloadAndHeaders intercept(String methodName, @Nullable Object payload,
3839
if (agentCard == null || agentCard.securityRequirements()== null || agentCard.securitySchemes() == null) {
3940
return new PayloadAndHeaders(payload, updatedHeaders);
4041
}
41-
for (Map<String, List<String>> requirement : agentCard.securityRequirements()) {
42-
for (String securitySchemeName : requirement.keySet()) {
42+
for (SecurityRequirement requirement : agentCard.securityRequirements()) {
43+
if (requirement == null) {
44+
continue;
45+
}
46+
for (String securitySchemeName : requirement.schemes().keySet()) {
4347
String credential = credentialService.getCredential(securitySchemeName, clientCallContext);
4448
if (credential != null && agentCard.securitySchemes().containsKey(securitySchemeName)) {
4549
SecurityScheme securityScheme = agentCard.securitySchemes().get(securitySchemeName);

client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.a2a.spec.OAuth2SecurityScheme;
1919
import io.a2a.spec.OAuthFlows;
2020
import io.a2a.spec.OpenIdConnectSecurityScheme;
21+
import io.a2a.spec.SecurityRequirement;
2122
import io.a2a.spec.SecurityScheme;
2223
import org.junit.jupiter.api.BeforeEach;
2324
import org.junit.jupiter.api.Test;
@@ -235,7 +236,7 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() {
235236
.defaultInputModes(List.of("text"))
236237
.defaultOutputModes(List.of("text"))
237238
.skills(List.of())
238-
.securityRequirements(List.of(Map.of(schemeName, List.of())))
239+
.securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build()))
239240
.securitySchemes(Map.of()) // no security schemes
240241
.build();
241242

@@ -321,7 +322,7 @@ private AgentCard createAgentCard(String schemeName, SecurityScheme securitySche
321322
.defaultInputModes(List.of("text"))
322323
.defaultOutputModes(List.of("text"))
323324
.skills(List.of())
324-
.securityRequirements(List.of(Map.of(schemeName, List.of())))
325+
.securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build()))
325326
.securitySchemes(Map.of(schemeName, securityScheme))
326327
.build();
327328
}

jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616
import static io.a2a.spec.FilePart.FILE;
1717
import static io.a2a.spec.TextPart.TEXT;
1818
import static java.lang.String.format;
19+
import static java.util.Collections.emptyMap;
1920

2021
import java.io.StringReader;
2122
import java.lang.reflect.Type;
2223
import java.time.OffsetDateTime;
2324
import java.time.format.DateTimeFormatter;
2425
import java.time.format.DateTimeParseException;
26+
import java.util.ArrayList;
27+
import java.util.Collections;
28+
import java.util.LinkedHashMap;
29+
import java.util.List;
2530
import java.util.Map;
2631
import java.util.Set;
2732

@@ -55,6 +60,7 @@
5560
import io.a2a.spec.OpenIdConnectSecurityScheme;
5661
import io.a2a.spec.Part;
5762
import io.a2a.spec.PushNotificationNotSupportedError;
63+
import io.a2a.spec.SecurityRequirement;
5864
import io.a2a.spec.SecurityScheme;
5965
import io.a2a.spec.StreamingEventKind;
6066
import io.a2a.spec.Task;
@@ -76,8 +82,10 @@ private static GsonBuilder createBaseGsonBuilder() {
7682
return new GsonBuilder()
7783
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
7884
.registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
85+
.registerTypeAdapter(SecurityRequirement.class, new SecurityRequirementTypeAdapter())
7986
.registerTypeHierarchyAdapter(A2AError.class, new A2AErrorTypeAdapter())
8087
.registerTypeHierarchyAdapter(FileContent.class, new FileContentTypeAdapter());
88+
8189
}
8290

8391
/**
@@ -841,4 +849,123 @@ SecurityScheme read(JsonReader in) throws java.io.IOException {
841849
};
842850
}
843851
}
852+
853+
/**
854+
* Gson TypeAdapter for serializing and deserializing {@link SecurityRequirement}.
855+
* <p>
856+
* This adapter handles the JSON structure where a SecurityRequirement is represented
857+
* as an object with a "schemes" field containing a map of security scheme names to
858+
* StringList objects (matching the protobuf representation).
859+
* <p>
860+
* Serialization format:
861+
* <pre>{@code
862+
* {
863+
* "schemes": {
864+
* "oauth2": { "list": ["read", "write"] },
865+
* "apiKey": { "list": [] }
866+
* }
867+
* }
868+
* }</pre>
869+
*
870+
* @see SecurityRequirement
871+
*/
872+
static class SecurityRequirementTypeAdapter extends TypeAdapter<SecurityRequirement> {
873+
874+
private static final String SCHEMES_FIELD = "schemes";
875+
private static final String LIST_FIELD = "list";
876+
877+
@Override
878+
public void write(JsonWriter out, SecurityRequirement value) throws java.io.IOException {
879+
if (value == null) {
880+
out.nullValue();
881+
return;
882+
}
883+
884+
out.beginObject();
885+
out.name(SCHEMES_FIELD);
886+
887+
Map<String, List<String>> schemes = value.schemes();
888+
if (schemes == null || schemes.isEmpty()) {
889+
out.beginObject();
890+
out.endObject();
891+
} else {
892+
out.beginObject();
893+
for (Map.Entry<String, List<String>> entry : schemes.entrySet()) {
894+
out.name(entry.getKey());
895+
out.beginObject();
896+
out.name(LIST_FIELD);
897+
out.beginArray();
898+
List<String> scopes = entry.getValue();
899+
if (scopes != null) {
900+
for (String scope : scopes) {
901+
out.value(scope);
902+
}
903+
}
904+
out.endArray();
905+
out.endObject();
906+
}
907+
out.endObject();
908+
}
909+
910+
out.endObject();
911+
}
912+
913+
@Override
914+
public @Nullable SecurityRequirement read(JsonReader in) throws java.io.IOException {
915+
if (in.peek() == JsonToken.NULL) {
916+
in.nextNull();
917+
return null;
918+
}
919+
920+
Map<String, List<String>> schemes = emptyMap();
921+
922+
in.beginObject();
923+
while (in.hasNext()) {
924+
String fieldName = in.nextName();
925+
if (SCHEMES_FIELD.equals(fieldName)) {
926+
schemes = readSchemesMap(in);
927+
} else {
928+
in.skipValue();
929+
}
930+
}
931+
in.endObject();
932+
933+
return new SecurityRequirement(schemes);
934+
}
935+
936+
private Map<String, List<String>> readSchemesMap(JsonReader in) throws java.io.IOException {
937+
Map<String, List<String>> schemes = new LinkedHashMap<>();
938+
939+
in.beginObject();
940+
while (in.hasNext()) {
941+
String schemeName = in.nextName();
942+
List<String> scopes = readStringList(in);
943+
schemes.put(schemeName, scopes);
944+
}
945+
in.endObject();
946+
947+
return schemes;
948+
}
949+
950+
private List<String> readStringList(JsonReader in) throws java.io.IOException {
951+
List<String> scopes = new ArrayList<>();
952+
953+
in.beginObject();
954+
while (in.hasNext()) {
955+
String fieldName = in.nextName();
956+
if (LIST_FIELD.equals(fieldName)) {
957+
in.beginArray();
958+
while (in.hasNext()) {
959+
scopes.add(in.nextString());
960+
}
961+
in.endArray();
962+
} else {
963+
in.skipValue();
964+
}
965+
}
966+
in.endObject();
967+
968+
return scopes;
969+
}
970+
}
844971
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package io.a2a.jsonrpc.common.json;
2+
3+
import static java.util.Collections.emptyMap;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertNotNull;
6+
7+
import java.util.List;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
import io.a2a.spec.SecurityRequirement;
12+
13+
/**
14+
* Tests for SecurityRequirement serialization and deserialization with JSON.
15+
*/
16+
class SecurityRequirementSerializationTest {
17+
18+
@Test
19+
void testSecurityRequirementSerializationWithSingleScheme() throws JsonProcessingException {
20+
SecurityRequirement requirement = SecurityRequirement.builder()
21+
.scheme("oauth2", List.of("read", "write"))
22+
.build();
23+
24+
String json = JsonUtil.toJson(requirement);
25+
assertNotNull(json);
26+
27+
String expected = """
28+
{"schemes":{"oauth2":{"list":["read","write"]}}}""";
29+
assertEquals(expected, json);
30+
31+
SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
32+
assertEquals(requirement, deserialized);
33+
}
34+
35+
@Test
36+
void testSecurityRequirementSerializationWithMultipleSchemes() throws JsonProcessingException {
37+
SecurityRequirement requirement = SecurityRequirement.builder()
38+
.scheme("oauth2", List.of("profile"))
39+
.scheme("apiKey", List.of())
40+
.build();
41+
42+
String json = JsonUtil.toJson(requirement);
43+
assertNotNull(json);
44+
45+
String expected = """
46+
{"schemes":{"oauth2":{"list":["profile"]},"apiKey":{"list":[]}}}""";
47+
assertEquals(expected, json);
48+
49+
SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
50+
assertEquals(requirement, deserialized);
51+
}
52+
53+
@Test
54+
void testSecurityRequirementSerializationWithEmptyScopes() throws JsonProcessingException {
55+
SecurityRequirement requirement = SecurityRequirement.builder()
56+
.scheme("apiKey", List.of())
57+
.build();
58+
59+
String json = JsonUtil.toJson(requirement);
60+
assertNotNull(json);
61+
62+
String expected = """
63+
{"schemes":{"apiKey":{"list":[]}}}""";
64+
assertEquals(expected, json);
65+
66+
SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
67+
assertEquals(requirement, deserialized);
68+
}
69+
70+
@Test
71+
void testSecurityRequirementSerializationWithNullSchemes() throws JsonProcessingException {
72+
SecurityRequirement requirement = new SecurityRequirement(emptyMap());
73+
74+
String json = JsonUtil.toJson(requirement);
75+
76+
assertNotNull(json);
77+
String expected = """
78+
{"schemes":{}}""";
79+
assertEquals(expected, json);
80+
81+
SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
82+
assertNotNull(deserialized);
83+
assertEquals(requirement, deserialized);
84+
}
85+
}

spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentCardMapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
AgentCapabilitiesMapper.class,
1515
AgentSkillMapper.class,
1616
SecuritySchemeMapper.class,
17-
SecurityMapper.class,
17+
SecurityRequirementMapper.class,
1818
AgentInterfaceMapper.class,
1919
AgentCardSignatureMapper.class
2020
})

spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentSkillMapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99
@Mapper(config = A2AProtoMapperConfig.class,
1010
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
11-
uses = SecurityMapper.class)
11+
uses = SecurityRequirementMapper.class)
1212
public interface AgentSkillMapper {
1313

1414
AgentSkillMapper INSTANCE = A2AMappers.getMapper(AgentSkillMapper.class);

0 commit comments

Comments
 (0)