Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import io.a2a.spec.OpenIdConnectSecurityScheme;
import io.a2a.spec.Part;
import io.a2a.spec.PushNotificationConfig;
import io.a2a.spec.SecurityRequirement;
import io.a2a.spec.SecurityScheme;
import io.a2a.spec.Task;
import io.a2a.spec.TaskIdParams;
Expand Down Expand Up @@ -376,9 +377,9 @@ public void testA2AClientGetExtendedAgentCard() throws Exception {
assertNotNull(securitySchemes);
OpenIdConnectSecurityScheme google = (OpenIdConnectSecurityScheme) securitySchemes.get("google");
assertEquals("https://accounts.google.com/.well-known/openid-configuration", google.openIdConnectUrl());
List<Map<String, List<String>>> security = agentCard.securityRequirements();
List<SecurityRequirement> security = agentCard.securityRequirements();
assertEquals(1, security.size());
Map<String, List<String>> securityMap = security.get(0);
Map<String, List<String>> securityMap = security.get(0).schemes();
List<String> scopes = securityMap.get("google");
List<String> expectedScopes = List.of("openid", "profile", "email");
assertEquals(expectedScopes, scopes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import io.a2a.spec.HTTPAuthSecurityScheme;
import io.a2a.spec.OAuth2SecurityScheme;
import io.a2a.spec.OpenIdConnectSecurityScheme;
import io.a2a.spec.SecurityRequirement;
import io.a2a.spec.SecurityScheme;
import org.jspecify.annotations.Nullable;

Expand All @@ -38,8 +39,11 @@ public PayloadAndHeaders intercept(String methodName, @Nullable Object payload,
if (agentCard == null || agentCard.securityRequirements()== null || agentCard.securitySchemes() == null) {
return new PayloadAndHeaders(payload, updatedHeaders);
}
for (Map<String, List<String>> requirement : agentCard.securityRequirements()) {
for (String securitySchemeName : requirement.keySet()) {
for (SecurityRequirement requirement : agentCard.securityRequirements()) {
if (requirement == null) {
continue;
}
for (String securitySchemeName : requirement.schemes().keySet()) {
String credential = credentialService.getCredential(securitySchemeName, clientCallContext);
if (credential != null && agentCard.securitySchemes().containsKey(securitySchemeName)) {
SecurityScheme securityScheme = agentCard.securitySchemes().get(securitySchemeName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import io.a2a.spec.OAuth2SecurityScheme;
import io.a2a.spec.OAuthFlows;
import io.a2a.spec.OpenIdConnectSecurityScheme;
import io.a2a.spec.SecurityRequirement;
import io.a2a.spec.SecurityScheme;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -235,7 +236,7 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() {
.defaultInputModes(List.of("text"))
.defaultOutputModes(List.of("text"))
.skills(List.of())
.securityRequirements(List.of(Map.of(schemeName, List.of())))
.securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build()))
.securitySchemes(Map.of()) // no security schemes
.build();

Expand Down Expand Up @@ -321,7 +322,7 @@ private AgentCard createAgentCard(String schemeName, SecurityScheme securitySche
.defaultInputModes(List.of("text"))
.defaultOutputModes(List.of("text"))
.skills(List.of())
.securityRequirements(List.of(Map.of(schemeName, List.of())))
.securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build()))
.securitySchemes(Map.of(schemeName, securityScheme))
.build();
}
Expand Down
127 changes: 127 additions & 0 deletions jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
import static io.a2a.spec.FilePart.FILE;
import static io.a2a.spec.TextPart.TEXT;
import static java.lang.String.format;
import static java.util.Collections.emptyMap;

import java.io.StringReader;
import java.lang.reflect.Type;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

Expand Down Expand Up @@ -55,6 +60,7 @@
import io.a2a.spec.OpenIdConnectSecurityScheme;
import io.a2a.spec.Part;
import io.a2a.spec.PushNotificationNotSupportedError;
import io.a2a.spec.SecurityRequirement;
import io.a2a.spec.SecurityScheme;
import io.a2a.spec.StreamingEventKind;
import io.a2a.spec.Task;
Expand All @@ -76,8 +82,10 @@ private static GsonBuilder createBaseGsonBuilder() {
return new GsonBuilder()
.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
.registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
.registerTypeAdapter(SecurityRequirement.class, new SecurityRequirementTypeAdapter())
.registerTypeHierarchyAdapter(A2AError.class, new A2AErrorTypeAdapter())
.registerTypeHierarchyAdapter(FileContent.class, new FileContentTypeAdapter());

}

/**
Expand Down Expand Up @@ -841,4 +849,123 @@ SecurityScheme read(JsonReader in) throws java.io.IOException {
};
}
}

/**
* Gson TypeAdapter for serializing and deserializing {@link SecurityRequirement}.
* <p>
* This adapter handles the JSON structure where a SecurityRequirement is represented
* as an object with a "schemes" field containing a map of security scheme names to
* StringList objects (matching the protobuf representation).
* <p>
* Serialization format:
* <pre>{@code
* {
* "schemes": {
* "oauth2": { "list": ["read", "write"] },
* "apiKey": { "list": [] }
* }
* }
* }</pre>
*
* @see SecurityRequirement
*/
static class SecurityRequirementTypeAdapter extends TypeAdapter<SecurityRequirement> {

private static final String SCHEMES_FIELD = "schemes";
private static final String LIST_FIELD = "list";

@Override
public void write(JsonWriter out, SecurityRequirement value) throws java.io.IOException {
if (value == null) {
out.nullValue();
return;
}

out.beginObject();
out.name(SCHEMES_FIELD);

Map<String, List<String>> schemes = value.schemes();
if (schemes == null || schemes.isEmpty()) {
out.beginObject();
out.endObject();
} else {
out.beginObject();
for (Map.Entry<String, List<String>> entry : schemes.entrySet()) {
out.name(entry.getKey());
out.beginObject();
out.name(LIST_FIELD);
out.beginArray();
List<String> scopes = entry.getValue();
if (scopes != null) {
for (String scope : scopes) {
out.value(scope);
}
}
out.endArray();
out.endObject();
}
out.endObject();
}

out.endObject();
}

@Override
public @Nullable SecurityRequirement read(JsonReader in) throws java.io.IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull();
return null;
}

Map<String, List<String>> schemes = emptyMap();

in.beginObject();
while (in.hasNext()) {
String fieldName = in.nextName();
if (SCHEMES_FIELD.equals(fieldName)) {
schemes = readSchemesMap(in);
} else {
in.skipValue();
}
}
in.endObject();

return new SecurityRequirement(schemes);
}

private Map<String, List<String>> readSchemesMap(JsonReader in) throws java.io.IOException {
Map<String, List<String>> schemes = new LinkedHashMap<>();

in.beginObject();
while (in.hasNext()) {
String schemeName = in.nextName();
List<String> scopes = readStringList(in);
schemes.put(schemeName, scopes);
}
in.endObject();

return schemes;
}

private List<String> readStringList(JsonReader in) throws java.io.IOException {
List<String> scopes = new ArrayList<>();

in.beginObject();
while (in.hasNext()) {
String fieldName = in.nextName();
if (LIST_FIELD.equals(fieldName)) {
in.beginArray();
while (in.hasNext()) {
scopes.add(in.nextString());
}
in.endArray();
} else {
in.skipValue();
}
}
in.endObject();

return scopes;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.a2a.jsonrpc.common.json;

import static java.util.Collections.emptyMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.util.List;

import org.junit.jupiter.api.Test;

import io.a2a.spec.SecurityRequirement;

/**
* Tests for SecurityRequirement serialization and deserialization with JSON.
*/
class SecurityRequirementSerializationTest {

@Test
void testSecurityRequirementSerializationWithSingleScheme() throws JsonProcessingException {
SecurityRequirement requirement = SecurityRequirement.builder()
.scheme("oauth2", List.of("read", "write"))
.build();

String json = JsonUtil.toJson(requirement);
assertNotNull(json);

String expected = """
{"schemes":{"oauth2":{"list":["read","write"]}}}""";
Comment on lines +27 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test will likely fail because the expected JSON string in the text block contains leading whitespace from indentation. JsonUtil.toJson produces compact JSON without extra whitespace. Please remove the leading spaces from the expected string to ensure the assertion passes.

        String expected = "{\"schemes\":{\"oauth2\":{\"list\":[\"read\",\"write\"]}}}";

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist Did the test fail on CI? Can you refresh yourself on how Java text blocks work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct, jmesnil. My apologies for the oversight. Java text blocks indeed handle incidental indentation by stripping it, meaning the expected string as written should correctly resolve to {"schemes":{"oauth2":{"list":["read","write"]}}} without leading whitespace.

My previous comment was based on an overabundance of caution to ensure the expected string precisely matched the compact output of JsonUtil.toJson, and I incorrectly implied that the text block would preserve the leading indentation.

As a bot, I do not have access to CI results, so I cannot confirm if the test failed there. However, based on the correct understanding of Java text blocks, the current implementation should work as intended, assuming JsonUtil.toJson produces compact JSON.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist , can you resolve all similar comments you open that are incorrect?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I will resolve this comment thread and similar ones where my initial assessment was incorrect regarding Java text blocks. Thank you for the clarification.

assertEquals(expected, json);

SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
assertEquals(requirement, deserialized);
}

@Test
void testSecurityRequirementSerializationWithMultipleSchemes() throws JsonProcessingException {
SecurityRequirement requirement = SecurityRequirement.builder()
.scheme("oauth2", List.of("profile"))
.scheme("apiKey", List.of())
.build();

String json = JsonUtil.toJson(requirement);
assertNotNull(json);

String expected = """
{"schemes":{"oauth2":{"list":["profile"]},"apiKey":{"list":[]}}}""";
Comment on lines +45 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test will likely fail because the expected JSON string in the text block contains leading whitespace from indentation. JsonUtil.toJson produces compact JSON without extra whitespace. Please remove the leading spaces from the expected string to ensure the assertion passes.

        String expected = "{\"schemes\":{\"oauth2\":{\"list\":[\"profile\"]},\"apiKey\":{\"list\":[]}}}";

assertEquals(expected, json);

SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
assertEquals(requirement, deserialized);
}

@Test
void testSecurityRequirementSerializationWithEmptyScopes() throws JsonProcessingException {
SecurityRequirement requirement = SecurityRequirement.builder()
.scheme("apiKey", List.of())
.build();

String json = JsonUtil.toJson(requirement);
assertNotNull(json);

String expected = """
{"schemes":{"apiKey":{"list":[]}}}""";
Comment on lines +62 to +63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test will likely fail because the expected JSON string in the text block contains leading whitespace from indentation. JsonUtil.toJson produces compact JSON without extra whitespace. Please remove the leading spaces from the expected string to ensure the assertion passes.

        String expected = "{\"schemes\":{\"apiKey\":{\"list\":[]}}}";

assertEquals(expected, json);

SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
assertEquals(requirement, deserialized);
}

@Test
void testSecurityRequirementSerializationWithNullSchemes() throws JsonProcessingException {
SecurityRequirement requirement = new SecurityRequirement(emptyMap());

String json = JsonUtil.toJson(requirement);

assertNotNull(json);
String expected = """
{"schemes":{}}""";
Comment on lines +77 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test will likely fail because the expected JSON string in the text block contains leading whitespace from indentation. JsonUtil.toJson produces compact JSON without extra whitespace. Please remove the leading spaces from the expected string to ensure the assertion passes.

        String expected = "{\"schemes\":{}}";

assertEquals(expected, json);

SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class);
assertNotNull(deserialized);
assertEquals(requirement, deserialized);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
AgentCapabilitiesMapper.class,
AgentSkillMapper.class,
SecuritySchemeMapper.class,
SecurityMapper.class,
SecurityRequirementMapper.class,
AgentInterfaceMapper.class,
AgentCardSignatureMapper.class
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
@Mapper(config = A2AProtoMapperConfig.class,
collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
uses = SecurityMapper.class)
uses = SecurityRequirementMapper.class)
public interface AgentSkillMapper {

AgentSkillMapper INSTANCE = A2AMappers.getMapper(AgentSkillMapper.class);
Expand Down
Loading