diff --git a/common/src/main/java/org/a2aproject/sdk/util/Assert.java b/common/src/main/java/org/a2aproject/sdk/util/Assert.java index 64d85c156..128a89b98 100644 --- a/common/src/main/java/org/a2aproject/sdk/util/Assert.java +++ b/common/src/main/java/org/a2aproject/sdk/util/Assert.java @@ -8,6 +8,16 @@ public final class Assert { private Assert() { } + /** + * Checks whether the value is {@code null}, empty, or blank. + * + * @param value the value to check + * @return {@code true} if the value is {@code null}, empty, or blank + */ + public static boolean isNullOrEmpty(@Nullable String value) { + return value == null || value.isBlank(); + } + /** * Check that the named parameter is not {@code null}. Use a standard exception message if it is. * diff --git a/common/src/main/java/org/a2aproject/sdk/util/NotNull.java b/common/src/main/java/org/a2aproject/sdk/util/NotNull.java index c7719f0ff..92dd8f43b 100644 --- a/common/src/main/java/org/a2aproject/sdk/util/NotNull.java +++ b/common/src/main/java/org/a2aproject/sdk/util/NotNull.java @@ -7,8 +7,8 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.CLASS) -@Target({ ElementType.FIELD, ElementType.LOCAL_VARIABLE, ElementType.METHOD, ElementType.PARAMETER }) +@Target({ ElementType.FIELD, ElementType.LOCAL_VARIABLE, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE_USE }) @Documented /** Marks an element as non-null for static analysis. */ public @interface NotNull { -} \ No newline at end of file +} diff --git a/common/src/test/java/org/a2aproject/sdk/util/AssertTest.java b/common/src/test/java/org/a2aproject/sdk/util/AssertTest.java new file mode 100644 index 000000000..9e1af5762 --- /dev/null +++ b/common/src/test/java/org/a2aproject/sdk/util/AssertTest.java @@ -0,0 +1,29 @@ +package org.a2aproject.sdk.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class AssertTest { + + @Test + public void testIsNullOrEmptyForNull() { + assertTrue(Assert.isNullOrEmpty(null)); + } + + @Test + public void testIsNullOrEmptyForEmptyString() { + assertTrue(Assert.isNullOrEmpty("")); + } + + @Test + public void testIsNullOrEmptyForOrdinaryString() { + assertFalse(Assert.isNullOrEmpty("value")); + } + + @Test + public void testIsNullOrEmptyForWhitespaceString() { + assertTrue(Assert.isNullOrEmpty(" ")); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3.java b/compat-0.3/reference/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3.java index cb77a7b83..bf2d1d66c 100644 --- a/compat-0.3/reference/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3.java +++ b/compat-0.3/reference/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3.java @@ -17,7 +17,6 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; -import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import io.quarkus.security.Authenticated; import io.quarkus.security.ForbiddenException; @@ -30,23 +29,18 @@ import org.a2aproject.sdk.compat03.conversion.A2AProtocol_v0_3; import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetAuthenticatedExtendedCardRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCErrorResponse_v0_3; -import org.a2aproject.sdk.compat03.spec.JSONRPCMessage_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCRequest_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCResponse_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.NonStreamingJSONRPCRequest_v0_3; import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; @@ -55,6 +49,7 @@ import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; import org.a2aproject.sdk.compat03.transport.jsonrpc.handler.JSONRPCHandler_v0_3; +import org.a2aproject.sdk.compat03.transport.jsonrpc.handler.JSONRPCRequestDecoder_v0_3; import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.ServerCallContext; @@ -131,7 +126,7 @@ private static boolean hasNonDefaultV10AgentCard() { } @Authenticated - public void invokeJSONRPCHandler(String body, RoutingContext rc) { + public void invokeJSONRPCHandler(@Nullable String body, RoutingContext rc) { boolean streaming = false; ServerCallContext context = createCallContext(rc); JSONRPCResponse_v0_3 nonStreamingResponse = null; @@ -139,48 +134,23 @@ public void invokeJSONRPCHandler(String body, RoutingContext rc) { JSONRPCErrorResponse_v0_3 error = null; Object requestId = null; try { - com.google.gson.JsonObject node; - try { - node = JsonParser.parseString(body).getAsJsonObject(); - } catch (Exception e) { - throw new JSONParseError_v0_3(e.getMessage()); - } - - // Extract id field early so error responses can include it - com.google.gson.JsonElement idElement = node.get("id"); - if (idElement != null && !idElement.isJsonNull() && !idElement.isJsonPrimitive()) { - throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: 'id' must be a string, number, or null"); - } - if (idElement != null && !idElement.isJsonNull() && idElement.isJsonPrimitive()) { - com.google.gson.JsonPrimitive idPrimitive = idElement.getAsJsonPrimitive(); - requestId = idPrimitive.isNumber() ? idPrimitive.getAsLong() : idPrimitive.getAsString(); - } - - // Validate jsonrpc field - com.google.gson.JsonElement jsonrpcElement = node.get("jsonrpc"); - if (jsonrpcElement == null || !jsonrpcElement.isJsonPrimitive() - || !JSONRPCMessage_v0_3.JSONRPC_VERSION.equals(jsonrpcElement.getAsString())) { - throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); - } - - // Validate method field - com.google.gson.JsonElement methodElement = node.get("method"); - if (methodElement == null || !methodElement.isJsonPrimitive()) { - throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: missing or invalid 'method' field"); + JSONRPCRequestDecoder_v0_3.DecodeResult result = JSONRPCRequestDecoder_v0_3.decode(body); + if (result instanceof JSONRPCRequestDecoder_v0_3.DecodeFailure failure) { + requestId = failure.requestId(); + if (failure.methodName() != null) { + context.getState().put(METHOD_NAME_KEY, failure.methodName()); + } + throw failure.error(); } - - String methodName = methodElement.getAsString(); - context.getState().put(METHOD_NAME_KEY, methodName); - - streaming = SendStreamingMessageRequest_v0_3.METHOD.equals(methodName) - || TaskResubscriptionRequest_v0_3.METHOD.equals(methodName); - - if (streaming) { - StreamingJSONRPCRequest_v0_3 request = deserializeStreamingRequest(body, methodName); + JSONRPCRequestDecoder_v0_3.DecodedRequest decoded = (JSONRPCRequestDecoder_v0_3.DecodedRequest) result; + requestId = decoded.requestId(); + context.getState().put(METHOD_NAME_KEY, decoded.methodName()); + if (decoded.request() instanceof StreamingJSONRPCRequest_v0_3 request) { + streaming = true; streamingResponse = processStreamingRequest(request, context); } else { - NonStreamingJSONRPCRequest_v0_3 request = deserializeNonStreamingRequest(body, methodName); - nonStreamingResponse = processNonStreamingRequest(request, context); + nonStreamingResponse = processNonStreamingRequest( + (NonStreamingJSONRPCRequest_v0_3) decoded.request(), context); } } catch (JSONRPCError_v0_3 e) { error = new JSONRPCErrorResponse_v0_3(requestId, e); @@ -212,40 +182,6 @@ private static String formatSseEvent(Object data, long id) { return "data: " + Utils_v0_3.toJsonString(data) + "\nid: " + id + "\n\n"; } - private NonStreamingJSONRPCRequest_v0_3 deserializeNonStreamingRequest(String body, String methodName) { - try { - return switch (methodName) { - case GetTaskRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetTaskRequest_v0_3.class); - case CancelTaskRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, CancelTaskRequest_v0_3.class); - case SendMessageRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SendMessageRequest_v0_3.class); - case SetTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SetTaskPushNotificationConfigRequest_v0_3.class); - case GetTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetTaskPushNotificationConfigRequest_v0_3.class); - case ListTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, ListTaskPushNotificationConfigRequest_v0_3.class); - case DeleteTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, DeleteTaskPushNotificationConfigRequest_v0_3.class); - case GetAuthenticatedExtendedCardRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetAuthenticatedExtendedCardRequest_v0_3.class); - default -> throw new MethodNotFoundError_v0_3(); - }; - } catch (JSONRPCError_v0_3 e) { - throw e; - } catch (Exception e) { - throw new InvalidParamsError_v0_3(e.getMessage()); - } - } - - private StreamingJSONRPCRequest_v0_3 deserializeStreamingRequest(String body, String methodName) { - try { - return switch (methodName) { - case SendStreamingMessageRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SendStreamingMessageRequest_v0_3.class); - case TaskResubscriptionRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, TaskResubscriptionRequest_v0_3.class); - default -> throw new MethodNotFoundError_v0_3(); - }; - } catch (JSONRPCError_v0_3 e) { - throw e; - } catch (Exception e) { - throw new InvalidParamsError_v0_3(e.getMessage()); - } - } - private JSONRPCResponse_v0_3 processNonStreamingRequest( NonStreamingJSONRPCRequest_v0_3 request, ServerCallContext context) { if (request instanceof GetTaskRequest_v0_3 req) { diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3_Test.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3_Test.java new file mode 100644 index 000000000..993bc99a9 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2AServerRoutes_v0_3_Test.java @@ -0,0 +1,424 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; +import static org.a2aproject.sdk.compat03.transport.jsonrpc.context.JSONRPCContextKeys_v0_3.METHOD_NAME_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import jakarta.enterprise.inject.Instance; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.smallrye.mutiny.Multi; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import org.jspecify.annotations.Nullable; +import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; +import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendStreamingMessageResponse_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; +import org.a2aproject.sdk.compat03.transport.jsonrpc.handler.JSONRPCHandler_v0_3; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.auth.UnauthenticatedUser; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; + +/** Characterizes the existing route decoder, including the separately recovered ID. */ +public class A2AServerRoutes_v0_3_Test { + private A2AServerRoutes_v0_3 routes; + private JSONRPCHandler_v0_3 transport; + private RoutingContext rc; + private HttpServerResponse response; + private MultiMap responseHeaders; + + @BeforeEach + public void setUp() { + routes = new A2AServerRoutes_v0_3(); + transport = mock(JSONRPCHandler_v0_3.class); + routes.jsonRpcHandler = transport; + routes.callContextFactory = mock(Instance.class); + when(routes.callContextFactory.isUnsatisfied()).thenReturn(true); + rc = mock(RoutingContext.class); + HttpServerRequest request = mock(HttpServerRequest.class); + response = mock(HttpServerResponse.class); + responseHeaders = MultiMap.caseInsensitiveMultiMap(); + when(rc.request()).thenReturn(request); + when(rc.response()).thenReturn(response); + when(request.headers()).thenReturn(MultiMap.caseInsensitiveMultiMap()); + when(response.headers()).thenReturn(responseHeaders); + when(response.setStatusCode(anyInt())).thenReturn(response); + when(response.putHeader(any(CharSequence.class), any(CharSequence.class))).thenReturn(response); + when(response.end(anyString())).thenReturn(Future.succeededFuture()); + when(response.end()).thenReturn(Future.succeededFuture()); + } + + private JsonObject invoke(String body) { + routes.invokeJSONRPCHandler(body, rc); + return responseJson(); + } + + private JsonObject invokeNullBody() { + routes.invokeJSONRPCHandler(null, rc); + return responseJson(); + } + + private JsonObject responseJson() { + verify(response).setStatusCode(200); + verify(response).putHeader(CONTENT_TYPE, "application/json"); + ArgumentCaptor output = ArgumentCaptor.forClass(String.class); + verify(response).end(output.capture()); + verifyNoMoreInteractions(response); + return JsonParser.parseString(output.getValue()).getAsJsonObject(); + } + + private void assertError(JsonObject actual, String id, int code, @Nullable String message) { + JsonObject expected = new JsonObject(); + expected.addProperty("jsonrpc", "2.0"); + if (id != null) { + expected.add("id", JsonParser.parseString(id)); + } + JsonObject error = new JsonObject(); + error.addProperty("code", code); + if (message != null) { + error.addProperty("message", message); + } + if (message == null) { + assertEquals("2.0", actual.get("jsonrpc").getAsString()); + if (id == null) { + assertNull(actual.get("id")); + } else { + assertEquals(JsonParser.parseString(id), actual.get("id")); + } + JsonObject actualError = actual.getAsJsonObject("error"); + assertEquals(error.get("code"), actualError.get("code")); + assertNotNull(actualError.get("message")); + } else { + expected.add("error", error); + assertEquals(expected, actual); + } + } + + @ParameterizedTest(name = "body={0}") + @MethodSource("invalidJsonBodies") + public void testInvalidJsonBeforeEnvelopeValidation(String body, String message) { + assertError(invoke(body), null, -32700, message); + verifyNoInteractions(transport); + } + + private static Stream invalidJsonBodies() { + return Stream.of( + Arguments.of("{", "java.io.EOFException: End of input at line 1 column 2 path $."), + Arguments.of("[]", "Not a JSON Object: []"), + Arguments.of("null", "Not a JSON Object: null"), + Arguments.of("42", "Not a JSON Object: 42"), + Arguments.of("\"text\"", "Not a JSON Object: \"text\""), + Arguments.of("", "Not a JSON Object: null")); + } + + @Test + public void testNullBodyIsJsonParseError() { + assertError(invokeNullBody(), null, -32700, null); + verifyNoInteractions(transport); + } + + @ParameterizedTest(name = "id={0}") + @org.junit.jupiter.params.provider.ValueSource(strings = {"{}", "[]"}) + public void testInvalidIdBeforeVersionAndMethod(String id) { + assertError(invoke("{\"id\":" + id + ",\"jsonrpc\":false,\"method\":null}"), null, -32600, + "Invalid JSON-RPC request: 'id' must be a string, number, or null"); + verifyNoInteractions(transport); + } + + @ParameterizedTest(name = "id={0}") + @MethodSource("idCoercionCases") + public void testExistingIdCoercion(@Nullable String rawId, @Nullable String responseId) { + String id = rawId == null ? "" : "\"id\":" + rawId + ","; + assertError(invoke("{" + id + "\"jsonrpc\":\"wrong\"}"), responseId, -32600, + "Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); + verifyNoInteractions(transport); + } + + private static Stream idCoercionCases() { + return Stream.of( + Arguments.of("\"request\"", "\"request\""), + Arguments.of("7", "7"), + Arguments.of("7.9", "7"), + Arguments.of("true", "\"true\""), + Arguments.of("null", null), + Arguments.of((Object) null, null)); + } + + @ParameterizedTest(name = "jsonrpc={0}") + @MethodSource("invalidVersions") + public void testVersionFieldErrorRetainsId(@Nullable String version) { + String field = version == null ? "" : ",\"jsonrpc\":" + version; + assertError(invoke("{\"id\":\"original\",\"method\":{}" + field + "}"), "\"original\"", -32600, + "Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); + verifyNoInteractions(transport); + } + + private static Stream invalidVersions() { + return Stream.of((String) null, "null", "{}", "[]", "true", "1", "\"1.0\"").map(Arguments::of); + } + + @ParameterizedTest(name = "method={0}") + @MethodSource("invalidMethods") + public void testMethodFieldErrorRetainsId(@Nullable String method) { + String field = method == null ? "" : ",\"method\":" + method; + assertError(invoke("{\"jsonrpc\":\"2.0\",\"id\":17" + field + "}"), "17", -32600, + "Invalid JSON-RPC request: missing or invalid 'method' field"); + verifyNoInteractions(transport); + } + + private static Stream invalidMethods() { + return Stream.of((String) null, "null", "{}", "[]").map(Arguments::of); + } + + @ParameterizedTest(name = "method={0}") + @org.junit.jupiter.params.provider.ValueSource(strings = {"\"unknown\"", "\"\""}) + public void testUnknownMethodBeforeParams(String method) { + assertError(invoke("{\"jsonrpc\":\"2.0\",\"id\":\"unknown\",\"method\":" + method + + ",\"params\":[]}"), "\"unknown\"", -32601, "Method not found"); + verifyNoInteractions(transport); + } + + @ParameterizedTest(name = "method={0}") + @org.junit.jupiter.params.provider.ValueSource(strings = {"true", "17"}) + public void testNonStringMethodBeforeParams(String method) { + assertError(invoke("{\"jsonrpc\":\"2.0\",\"id\":\"unknown\",\"method\":" + method + + ",\"params\":[]}"), "\"unknown\"", -32600, + "Invalid JSON-RPC request: missing or invalid 'method' field"); + verifyNoInteractions(transport); + } + + @ParameterizedTest(name = "params={0}") + @MethodSource("invalidGetParams") + public void testParamsErrorRetainsId(@Nullable String params) { + JsonObject actual = invoke(request("tasks/get", params)); + assertError(actual, "\"original\"", -32602, + "{\"historyLength\":-1}".equals(params) ? null : "Failed to parse JSON"); + verifyNoInteractions(transport); + } + + private static Stream invalidGetParams() { + return Stream.of(Arguments.of("[]"), Arguments.of("\"bad\""), + Arguments.of("{\"historyLength\":-1}"), + Arguments.of("{\"id\":\"task\",\"historyLength\":\"bad\"}")); + } + + @ParameterizedTest(name = "params={0}") + @MethodSource("emptyParams") + public void testMissingMessageParamsIsInvalidParams(@Nullable String params) { + useRealTransport(); + assertError(invoke(request("message/send", params)), "\"original\"", -32602, + "Parameter 'params' may not be null"); + } + + @ParameterizedTest(name = "params={0}") + @MethodSource("emptyParams") + public void testMissingGetParamsReachTransport(@Nullable String params) { + when(transport.onGetTask(any(), any())).thenThrow(new IllegalStateException("dispatch reached")); + assertError(invoke(request("tasks/get", params)), "\"original\"", -32603, "dispatch reached"); + ArgumentCaptor decoded = + ArgumentCaptor.forClass(org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3.class); + verify(transport).onGetTask(decoded.capture(), any()); + org.junit.jupiter.api.Assertions.assertNull(decoded.getValue().getParams()); + } + + private static Stream emptyParams() { + return Stream.of(Arguments.of((Object) null), Arguments.of("null")); + } + + @ParameterizedTest(name = "params={0}") + @MethodSource("invalidMessageParams") + public void testInvalidMessageParams(@Nullable String params) { + assertError(invoke(request("message/send", params)), "\"original\"", -32602, "Failed to parse JSON"); + verifyNoInteractions(transport); + } + + private static Stream invalidMessageParams() { + return Stream.of( + Arguments.of("{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"invalid\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}]}}"), + Arguments.of("{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{}]}}"), + Arguments.of("{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{\"kind\":\"file\",\"file\":{}}]}}")); + } + + @ParameterizedTest(name = "method={0}") + @org.junit.jupiter.params.provider.ValueSource(strings = {"message/stream", "tasks/resubscribe"}) + public void testStreamingDecodeFailureIsHttpJson(String method) { + assertError(invoke(request(method, "[]")), "\"original\"", -32602, "Failed to parse JSON"); + verifyNoInteractions(transport); + } + + @Test + public void testStreamingDecodeFailureWithNullParamsIsHttpJson() { + useRealTransport(); + routes.invokeJSONRPCHandler(request("message/stream", "null"), rc); + assertEquals("text/event-stream", responseHeaders.get(CONTENT_TYPE)); + ArgumentCaptor event = ArgumentCaptor.forClass(io.vertx.core.buffer.Buffer.class); + verify(response).write(event.capture(), any()); + String eventText = event.getValue().toString(); + assertTrue(eventText.startsWith(": SSE stream started\n\ndata: ")); + assertTrue(eventText.endsWith("\nid: 0\n\n")); + assertError(JsonParser.parseString(eventText.substring(eventText.indexOf("data: ") + 6, + eventText.indexOf("\nid: "))).getAsJsonObject(), "\"original\"", -32603, + "Parameter 'params' may not be null"); + } + + @ParameterizedTest(name = "method={0}") + @MethodSource("supportedMethods") + public void testSupportedMethodsDecode(String method, @Nullable String params, String dispatchMethod) { + // A transport failure after decoding makes dispatch observable without implementing its dependencies. + transport = mock(JSONRPCHandler_v0_3.class, invocation -> { + throw new IllegalStateException("dispatch reached"); + }); + routes.jsonRpcHandler = transport; + assertError(invoke(request(method, params)), "\"original\"", -32603, "dispatch reached"); + var invocation = mockingDetails(transport).getInvocations().iterator().next(); + assertEquals(dispatchMethod, invocation.getMethod().getName()); + assertEquals(1, mockingDetails(transport).getInvocations().size()); + JSONRPCRequest_v0_3 decoded = invocation.getArgument(0); + assertEquals(method, decoded.getMethod()); + assertEquals("original", decoded.getId()); + ServerCallContext context = invocation.getArgument(1); + assertEquals(method, context.getState().get(METHOD_NAME_KEY)); + if (params != null) { + assertNotNull(decoded.getParams()); + } + } + + private static Stream supportedMethods() { + return Stream.of( + Arguments.of("tasks/get", "{\"id\":\"task\"}", "onGetTask"), + Arguments.of("tasks/cancel", "{\"id\":\"task\"}", "onCancelTask"), + Arguments.of("message/send", "{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}]}}", "onMessageSend"), + Arguments.of("tasks/pushNotificationConfig/set", "{\"taskId\":\"task\",\"pushNotificationConfig\":{\"url\":\"http://localhost/callback\"}}", "setPushNotificationConfig"), + Arguments.of("tasks/pushNotificationConfig/get", "{\"id\":\"task\"}", "getPushNotificationConfig"), + Arguments.of("tasks/pushNotificationConfig/list", "{\"id\":\"task\"}", "listPushNotificationConfig"), + Arguments.of("tasks/pushNotificationConfig/delete", "{\"id\":\"task\",\"pushNotificationConfigId\":\"config\"}", "deletePushNotificationConfig"), + Arguments.of("agent/getAuthenticatedExtendedCard", null, "onGetAuthenticatedExtendedCardRequest")); + } + + @ParameterizedTest(name = "method={0}") + @MethodSource("streamingMethods") + public void testSupportedStreamingMethodsDecode(String method, String params) { + var publisher = Multi.createFrom().item(new SendStreamingMessageResponse_v0_3("original", new TaskNotFoundError_v0_3())); + when(transport.onMessageSendStream(any(), any())).thenReturn(publisher); + when(transport.onResubscribeToTask(any(), any())).thenReturn(publisher); + routes.invokeJSONRPCHandler(request(method, params), rc); + if (method.equals("message/stream")) { + verify(transport).onMessageSendStream(any(), any()); + } else { + verify(transport).onResubscribeToTask(any(), any()); + } + verifyNoMoreInteractions(transport); + var invocation = mockingDetails(transport).getInvocations().iterator().next(); + JSONRPCRequest_v0_3 decoded = invocation.getArgument(0); + assertEquals(method, decoded.getMethod()); + assertEquals("original", decoded.getId()); + assertNotNull(decoded.getParams()); + ServerCallContext context = invocation.getArgument(1); + assertEquals(method, context.getState().get(METHOD_NAME_KEY)); + assertEquals("text/event-stream", responseHeaders.get(CONTENT_TYPE)); + assertEquals("no-cache", responseHeaders.get("Cache-Control")); + assertEquals("no", responseHeaders.get("X-Accel-Buffering")); + ArgumentCaptor event = ArgumentCaptor.forClass(io.vertx.core.buffer.Buffer.class); + verify(response).write(event.capture(), any()); + assertEquals(": SSE stream started\n\ndata: {\"jsonrpc\":\"2.0\",\"id\":\"original\",\"error\":{\"code\":-32001,\"message\":\"Task not found\"}}\nid: 0\n\n", event.getValue().toString()); + verify(response).end(); + } + + private static Stream streamingMethods() { + return Stream.of( + Arguments.of("message/stream", "{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}]}}"), + Arguments.of("tasks/resubscribe", "{\"id\":\"task\"}")); + } + + @ParameterizedTest(name = "body={0}, recoveredMethod={1}") + @MethodSource("contextRecoveryCases") + public void testMethodContextRecoveryOnDecodeFailure(String body, @Nullable String expectedMethod) { + ServerCallContext context = new ServerCallContext(UnauthenticatedUser.INSTANCE, new HashMap<>(), Set.of()); + CallContextFactory_v0_3 factory = mock(CallContextFactory_v0_3.class); + when(routes.callContextFactory.isUnsatisfied()).thenReturn(false); + when(routes.callContextFactory.get()).thenReturn(factory); + when(factory.build(rc)).thenReturn(context); + invoke(body); + if (expectedMethod == null) { + assertNull(context.getState().get(METHOD_NAME_KEY)); + } else { + assertEquals(expectedMethod, context.getState().get(METHOD_NAME_KEY)); + } + verifyNoInteractions(transport); + } + + private static Stream contextRecoveryCases() { + return Stream.of( + Arguments.of("{", null), + Arguments.of("{\"id\":{},\"method\":\"tasks/get\"}", null), + Arguments.of("{\"id\":17,\"jsonrpc\":\"wrong\",\"method\":\"tasks/get\"}", null), + Arguments.of("{\"id\":17,\"jsonrpc\":\"2.0\",\"method\":{}}", null), + Arguments.of(request("unknown", "[]"), "unknown"), + Arguments.of(request("tasks/get", "[]"), "tasks/get"), + Arguments.of(request("message/stream", "[]"), "message/stream")); + } + + @ParameterizedTest(name = "id={0}") + @MethodSource("dispatchIds") + public void testDispatchFailureRetainsRecoveredId(String rawId, String responseId, Object decodedId) { + when(transport.onGetTask(any(), any())).thenThrow(new IllegalStateException("dispatch failed")); + JsonObject body = JsonParser.parseString(request("tasks/get", "{\"id\":\"task\"}")).getAsJsonObject(); + body.add("id", JsonParser.parseString(rawId)); + assertError(invoke(body.toString()), responseId, -32603, "dispatch failed"); + ArgumentCaptor decoded = + ArgumentCaptor.forClass(org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3.class); + verify(transport).onGetTask(decoded.capture(), any()); + assertEquals(decodedId, decoded.getValue().getId()); + } + + private static Stream dispatchIds() { + return Stream.of( + Arguments.of("\"original\"", "\"original\"", "original"), + Arguments.of("7.9", "7", Double.valueOf(7.9)), + Arguments.of("true", "\"true\"", Boolean.TRUE)); + } + + private static String request(String method, String params) { + return "{\"jsonrpc\":\"2.0\",\"id\":\"original\",\"method\":\"" + method + "\"" + + (params == null ? "" : ",\"params\":" + params) + "}"; + } + + private void useRealTransport() { + AgentCard_v0_3 card = new AgentCard_v0_3.Builder().name("validation").description("Validation baseline") + .url("http://localhost:9999").version("1").protocolVersion("0.3") + .capabilities(new AgentCapabilities_v0_3(true, true, false, null)) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()).build(); + routes.jsonRpcHandler = new JSONRPCHandler_v0_3(card, Runnable::run, + new Convert_v0_3_To10RequestHandler(mock(RequestHandler.class))); + } +} diff --git a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java index a22b0de93..62261ca47 100644 --- a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java +++ b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java @@ -33,7 +33,6 @@ import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; @@ -182,8 +181,8 @@ public void sendMessageStreaming(String body, RoutingContext rc) { HTTPRestResponse error = null; try { HTTPRestResponse response = jsonRestHandler.sendStreamingMessage(body, context); - if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { - streamingResponse = hTTPRestStreamingResponse; + if (response instanceof HTTPRestStreamingResponse httpRestStreamingResponse) { + streamingResponse = httpRestStreamingResponse; } else { error = response; } @@ -205,28 +204,8 @@ public void getTask(RoutingContext rc) { ServerCallContext context = createCallContext(rc, GetTaskRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else { - int historyLength = 0; - boolean hasHistoryLength = rc.request().params().contains("history_length"); - boolean hasHistoryLengthCamel = rc.request().params().contains("historyLength"); - - if (hasHistoryLength && hasHistoryLengthCamel) { - response = jsonRestHandler.createErrorResponse( - new InvalidParamsError_v0_3("Only one of 'history_length' or 'historyLength' may be specified")); - } else if (hasHistoryLength) { - historyLength = Integer.parseInt(rc.request().params().get("history_length")); - } else if (hasHistoryLengthCamel) { - historyLength = Integer.parseInt(rc.request().params().get("historyLength")); - } - - if (response == null) { - response = jsonRestHandler.getTask(taskId, historyLength, context); - } - } - } catch (NumberFormatException e) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad history_length or historyLength")); + response = jsonRestHandler.getTask(taskId, rc.request().params().get("history_length"), + rc.request().params().get("historyLength"), context); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); } finally { @@ -240,11 +219,7 @@ public void cancelTask(RoutingContext rc) { ServerCallContext context = createCallContext(rc, CancelTaskRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else { - response = jsonRestHandler.cancelTask(taskId, context); - } + response = jsonRestHandler.cancelTask(taskId, context); } catch (Throwable t) { if (t instanceof JSONRPCError_v0_3 error) { response = jsonRestHandler.createErrorResponse(error); @@ -277,15 +252,11 @@ public void resubscribeTask(RoutingContext rc) { HTTPRestStreamingResponse streamingResponse = null; HTTPRestResponse error = null; try { - if (taskId == null || taskId.isEmpty()) { - error = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); + HTTPRestResponse response = jsonRestHandler.resubscribeTask(taskId, context); + if (response instanceof HTTPRestStreamingResponse httpRestStreamingResponse) { + streamingResponse = httpRestStreamingResponse; } else { - HTTPRestResponse response = jsonRestHandler.resubscribeTask(taskId, context); - if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { - streamingResponse = hTTPRestStreamingResponse; - } else { - error = response; - } + error = response; } } finally { if (error != null) { @@ -305,11 +276,7 @@ public void setTaskPushNotificationConfiguration(String body, RoutingContext rc) ServerCallContext context = createCallContext(rc, SetTaskPushNotificationConfigRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else { - response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, context); - } + response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, context); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); } finally { @@ -324,11 +291,7 @@ public void getTaskPushNotificationConfiguration(RoutingContext rc) { ServerCallContext context = createCallContext(rc, GetTaskPushNotificationConfigRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else { - response = jsonRestHandler.getTaskPushNotificationConfiguration(taskId, configId, context); - } + response = jsonRestHandler.getTaskPushNotificationConfiguration(taskId, configId, context); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); } finally { @@ -342,11 +305,7 @@ public void listTaskPushNotificationConfigurations(RoutingContext rc) { ServerCallContext context = createCallContext(rc, ListTaskPushNotificationConfigRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else { - response = jsonRestHandler.listTaskPushNotificationConfigurations(taskId, context); - } + response = jsonRestHandler.listTaskPushNotificationConfigurations(taskId, context); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); } finally { @@ -361,13 +320,7 @@ public void deleteTaskPushNotificationConfiguration(RoutingContext rc) { ServerCallContext context = createCallContext(rc, DeleteTaskPushNotificationConfigRequest_v0_3.METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); - } else if (configId == null || configId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad config id")); - } else { - response = jsonRestHandler.deleteTaskPushNotificationConfiguration(taskId, configId, context); - } + response = jsonRestHandler.deleteTaskPushNotificationConfiguration(taskId, configId, context); } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); } finally { diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3_Test.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3_Test.java new file mode 100644 index 000000000..e9f18801b --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3_Test.java @@ -0,0 +1,354 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import jakarta.enterprise.inject.Instance; + +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RoutingContext; +import org.jspecify.annotations.Nullable; +import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; +import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; + +/** Wire parity and raw delegation at the compat REST route boundary. */ +public class A2AServerRoutes_v0_3_Test { + private A2AServerRoutes_v0_3 routes; + private RequestHandler handler; + private RoutingContext rc; + private HttpServerResponse response; + private MultiMap params; + + private void fixture(boolean capabilities) { + routes = new A2AServerRoutes_v0_3(); + handler = mock(RequestHandler.class); + AgentCard_v0_3 card = new AgentCard_v0_3.Builder().name("validation").description("Validation baseline") + .url("http://localhost:9999").version("1").protocolVersion("0.3") + .capabilities(new AgentCapabilities_v0_3(capabilities, capabilities, false, null)) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()).build(); + routes.jsonRestHandler = new RestHandler_v0_3(card, Runnable::run, new Convert_v0_3_To10RequestHandler(handler)); + routes.callContextFactory = mock(Instance.class); + when(routes.callContextFactory.isUnsatisfied()).thenReturn(true); + rc = mock(RoutingContext.class); + HttpServerRequest request = mock(HttpServerRequest.class); + response = mock(HttpServerResponse.class); + params = MultiMap.caseInsensitiveMultiMap(); + when(rc.request()).thenReturn(request); + when(rc.response()).thenReturn(response); + when(rc.pathParam("id")).thenReturn("task"); + when(rc.pathParam("param0")).thenReturn("task"); + when(rc.pathParam("configId")).thenReturn("config"); + when(request.params()).thenReturn(params); + when(request.headers()).thenReturn(MultiMap.caseInsensitiveMultiMap()); + when(response.setStatusCode(anyInt())).thenReturn(response); + when(response.putHeader(any(CharSequence.class), any(CharSequence.class))).thenReturn(response); + when(response.end(anyString())).thenReturn(Future.succeededFuture()); + } + + private void assertError(int status, String type, String message) { + verify(response).setStatusCode(status); + verify(response).putHeader(CONTENT_TYPE, "application/json"); + verify(response).end("{\"error\": \"org.a2aproject.sdk.compat03.spec." + type + + "_v0_3\", \"message\": \"" + message + "\"}"); + verifyNoMoreInteractions(response); + } + + private void assertBadPath(Consumer operation) { + for (String id : new String[] {null, ""}) { + for (boolean capabilities : new boolean[] {true, false}) { + fixture(capabilities); + when(rc.pathParam("id")).thenReturn(id); + when(rc.pathParam("param0")).thenReturn(id); + when(rc.pathParam("configId")).thenReturn(""); + params.set("history_length", "bad").set("historyLength", "bad"); + operation.accept(rc); + assertError(422, "InvalidParamsError", "bad task id"); + verifyNoInteractions(handler); + } + } + } + + @Test + public void testGetTaskRawAliasValidationOrder() { + assertBadPath(context -> routes.getTask(context)); + for (String id : new String[] {null, ""}) { + fixture(true); + when(rc.pathParam("id")).thenReturn(id); + params.set("historyLength", "bad"); + routes.getTask(rc); + assertError(422, "InvalidParamsError", "bad task id"); + verifyNoInteractions(handler); + } + for (String snake : List.of("", "bad", "1")) { + fixture(true); + params.set("history_length", snake).set("historyLength", "bad"); + routes.getTask(rc); + assertError(422, "InvalidParamsError", "Only one of 'history_length' or 'historyLength' may be specified"); + verifyNoInteractions(handler); + } + for (String alias : List.of("history_length", "historyLength")) { + for (String value : List.of("bad", "", "2147483648", "-1")) { + fixture(true); + params.set(alias, value); + routes.getTask(rc); + assertError(422, "InvalidParamsError", "bad history_length or historyLength"); + verifyNoInteractions(handler); + } + } + for (String alias : new String[] {null, "history_length", "historyLength"}) { + fixture(true); + if (alias != null) { + params.set(alias, "2"); + } + routes.getTask(rc); + ArgumentCaptor query = ArgumentCaptor.forClass(TaskQueryParams.class); + verify(handler).onGetTask(query.capture(), any()); + assertEquals(alias == null ? null : Integer.valueOf(2), query.getValue().historyLength()); + assertError(404, "TaskNotFoundError", "Task not found"); + } + } + + @Test + public void testCancelTaskPathValidationOrder() { + assertBadPath(context -> routes.cancelTask(context)); + } + + @Test + public void testResubscribeTaskPathValidationOrder() { + assertBadPath(context -> routes.resubscribeTask(context)); + } + + @Test + public void testSetPushConfigPathValidationOrder() { + assertBadPath(context -> routes.setTaskPushNotificationConfiguration("{broken", context)); + fixture(true); + routes.setTaskPushNotificationConfiguration("{\"config\":{\"name\":\"short\"}}", rc); + assertError(422, "InvalidParamsError", "bad config name"); + verifyNoInteractions(handler); + } + + @Test + public void testGetPushConfigPathValidationOrder() { + assertBadPath(context -> routes.getTaskPushNotificationConfiguration(context)); + for (String configId : new String[] {null, ""}) { + fixture(true); + when(rc.pathParam("configId")).thenReturn(configId); + when(handler.onGetTaskPushNotificationConfig(any(), any())).thenThrow(new InternalError("delegated")); + routes.getTaskPushNotificationConfiguration(rc); + ArgumentCaptor query = + ArgumentCaptor.forClass(GetTaskPushNotificationConfigParams.class); + verify(handler).onGetTaskPushNotificationConfig(query.capture(), any()); + assertEquals(configId == null ? "task" : "", query.getValue().id()); + assertError(500, "InternalError", "delegated"); + } + } + + @Test + public void testListPushConfigsPathValidationOrder() { + assertBadPath(context -> routes.listTaskPushNotificationConfigurations(context)); + } + + @Test + public void testDeletePushConfigPathValidationOrder() { + assertBadPath(context -> routes.deleteTaskPushNotificationConfiguration(context)); + for (String configId : new String[] {null, ""}) { + for (boolean capabilities : new boolean[] {true, false}) { + fixture(capabilities); + when(rc.pathParam("configId")).thenReturn(configId); + routes.deleteTaskPushNotificationConfiguration(rc); + assertError(422, "InvalidParamsError", "bad config id"); + verifyNoInteractions(handler); + } + } + } + + @ParameterizedTest(name = "taskId={0}, history_length={1}, historyLength={2}") + @MethodSource("taskAndAliasValues") + public void testGetTaskForwardsRawPathAndQueryValues(@Nullable String taskId, @Nullable String snake, + @Nullable String camel) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + when(rc.pathParam("id")).thenReturn(taskId); + when(rc.pathParam("param0")).thenReturn(taskId); + if (snake != null) { + params.set("history_length", snake); + } + if (camel != null) { + params.set("historyLength", camel); + } + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.getTask(eq(taskId), eq(snake), eq(camel), any())).thenReturn(error); + + routes.getTask(rc); + + verify(transport).getTask(eq(taskId), eq(snake), eq(camel), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testCancelTaskForwardsRawPathValue(@Nullable String taskId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.cancelTask(eq(taskId), any())).thenReturn(error); + + routes.cancelTask(rc); + + verify(transport).cancelTask(eq(taskId), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testResubscribeTaskForwardsRawPathValue(@Nullable String taskId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.resubscribeTask(eq(taskId), any())).thenReturn(error); + + routes.resubscribeTask(rc); + + verify(transport).resubscribeTask(eq(taskId), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testSetPushConfigForwardsRawPathValue(@Nullable String taskId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.setTaskPushNotificationConfiguration(eq(taskId), eq("{broken"), any())).thenReturn(error); + + routes.setTaskPushNotificationConfiguration("{broken", rc); + + verify(transport).setTaskPushNotificationConfiguration(eq(taskId), eq("{broken"), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}, configId={1}") + @MethodSource("taskAndConfigIds") + public void testGetPushConfigForwardsRawPathValues(@Nullable String taskId, @Nullable String configId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + when(rc.pathParam("configId")).thenReturn(configId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.getTaskPushNotificationConfiguration(eq(taskId), eq(configId), any())).thenReturn(error); + + routes.getTaskPushNotificationConfiguration(rc); + + verify(transport).getTaskPushNotificationConfiguration(eq(taskId), eq(configId), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testListPushConfigsForwardsRawPathValue(@Nullable String taskId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.listTaskPushNotificationConfigurations(eq(taskId), any())).thenReturn(error); + + routes.listTaskPushNotificationConfigurations(rc); + + verify(transport).listTaskPushNotificationConfigurations(eq(taskId), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}, configId={1}") + @MethodSource("taskAndConfigIds") + public void testDeletePushConfigForwardsRawPathValues(@Nullable String taskId, @Nullable String configId) { + fixture(true); + RestHandler_v0_3 transport = mock(RestHandler_v0_3.class); + routes.jsonRestHandler = transport; + setTaskId(taskId); + when(rc.pathParam("configId")).thenReturn(configId); + RestHandler_v0_3.HTTPRestResponse error = rawForwardingError(); + when(transport.deleteTaskPushNotificationConfiguration(eq(taskId), eq(configId), any())).thenReturn(error); + + routes.deleteTaskPushNotificationConfiguration(rc); + + verify(transport).deleteTaskPushNotificationConfiguration(eq(taskId), eq(configId), any()); + verifyNoMoreInteractions(transport); + assertRawForwardingResponse(error); + } + + private static Stream taskIds() { + return Stream.of(Arguments.of((Object) null), Arguments.of(""), Arguments.of("task")); + } + + private static Stream taskAndConfigIds() { + return Stream.of((String) null, "", "task") + .flatMap(taskId -> Stream.of((String) null, "", "config") + .map(configId -> Arguments.of(taskId, configId))); + } + + private static Stream taskAndAliasValues() { + String[][] aliases = {{null, null}, {"", null}, {null, ""}, {"bad", null}, {null, "bad"}, + {"2", null}, {null, "2"}, {"", "bad"}}; + return Stream.of((String) null, "", "task") + .flatMap(taskId -> Stream.of(aliases) + .map(alias -> Arguments.of(taskId, alias[0], alias[1]))); + } + + private void setTaskId(@Nullable String taskId) { + when(rc.pathParam("id")).thenReturn(taskId); + when(rc.pathParam("param0")).thenReturn(taskId); + } + + private RestHandler_v0_3.HTTPRestResponse rawForwardingError() { + return new RestHandler_v0_3.HTTPRestResponse(422, "application/json", "{\"transportError\":true}"); + } + + private void assertRawForwardingResponse(RestHandler_v0_3.HTTPRestResponse error) { + verify(response).setStatusCode(422); + verify(response).putHeader(CONTENT_TYPE, "application/json"); + verify(response).end(error.getBody()); + verifyNoMoreInteractions(response); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_Test.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_Test.java index 58e8d6952..9c9dff961 100644 --- a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_Test.java +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_Test.java @@ -1,5 +1,7 @@ package org.a2aproject.sdk.compat03.server.rest.quarkus; +import static org.junit.jupiter.api.Assertions.assertEquals; + import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -10,8 +12,6 @@ import org.a2aproject.sdk.compat03.spec.TransportProtocol_v0_3; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; - public abstract class QuarkusA2ARest_v0_3_Test extends AbstractA2AServerServerTest_v0_3 { public QuarkusA2ARest_v0_3_Test() { @@ -51,4 +51,32 @@ public void testMethodNotFound() throws Exception { response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); assertEquals(405, response.statusCode()); } + @Test + public void testHistoryAliasWireValidation() throws Exception { + String[][] cases = { + {"", "404", "TaskNotFoundError", "Task not found"}, + {"history_length=", "422", "InvalidParamsError", "bad history_length or historyLength"}, + {"historyLength=", "422", "InvalidParamsError", "bad history_length or historyLength"}, + {"history_length=&historyLength=bad", "422", "InvalidParamsError", + "Only one of 'history_length' or 'historyLength' may be specified"}, + {"history_length=-1", "422", "InvalidParamsError", "bad history_length or historyLength"}, + {"history_length=2", "404", "TaskNotFoundError", "Task not found"}, + {"historyLength=2", "404", "TaskNotFoundError", "Task not found"}, + {"history_length=2&historyLength=bad", "422", "InvalidParamsError", + "Only one of 'history_length' or 'historyLength' may be specified"}, + {"history_length=bad", "422", "InvalidParamsError", "bad history_length or historyLength"}, + {"historyLength=bad", "422", "InvalidParamsError", "bad history_length or historyLength"} + }; + HttpClient client = HttpClient.newHttpClient(); + for (String[] entry : cases) { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/v1/tasks/missing-alias-task?" + entry[0])) + .GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(Integer.parseInt(entry[1]), response.statusCode(), response.body()); + assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow()); + assertEquals("{\"error\": \"org.a2aproject.sdk.compat03.spec." + entry[2] + + "_v0_3\", \"message\": \"" + entry[3] + "\"}", response.body()); + } + } + } diff --git a/compat-0.3/transport/jsonrpc/pom.xml b/compat-0.3/transport/jsonrpc/pom.xml index 26d9dc057..92d13a584 100644 --- a/compat-0.3/transport/jsonrpc/pom.xml +++ b/compat-0.3/transport/jsonrpc/pom.xml @@ -53,6 +53,11 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core diff --git a/compat-0.3/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3.java b/compat-0.3/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3.java new file mode 100644 index 000000000..7c8c8b646 --- /dev/null +++ b/compat-0.3/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3.java @@ -0,0 +1,151 @@ +package org.a2aproject.sdk.compat03.transport.jsonrpc.handler; + +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetAuthenticatedExtendedCardRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCMessage_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; +import org.a2aproject.sdk.compat03.spec.NonStreamingJSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingJSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; +import org.a2aproject.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** Decodes compat JSON-RPC envelopes and typed requests without a server runtime. */ +public final class JSONRPCRequestDecoder_v0_3 { + + private JSONRPCRequestDecoder_v0_3() { + } + + public sealed interface DecodeResult permits DecodedRequest, DecodeFailure { + } + + /** The recovered ID is kept separately from the deserialized wrapper's ID. */ + public record DecodedRequest(@Nullable Object requestId, String methodName, + JSONRPCRequest_v0_3 request) implements DecodeResult { + public DecodedRequest { + Assert.checkNotNullParam("methodName", methodName); + Assert.checkNotNullParam("request", request); + } + } + + /** The method is available only after envelope validation has recovered it. */ + public record DecodeFailure(@Nullable Object requestId, @Nullable String methodName, + JSONRPCError_v0_3 error) implements DecodeResult { + public DecodeFailure { + Assert.checkNotNullParam("error", error); + } + } + + public static DecodeResult decode(@Nullable String body) { + Object requestId = null; + String methodName = null; + try { + com.google.gson.JsonObject node; + try { + node = parseBody(body); + } catch (Exception e) { + throw new JSONParseError_v0_3(e.getMessage()); + } + // Parsing above rejects a null body, so it is safe to use the non-null body here. + @SuppressWarnings("NullAway") + String requestBody = (String) body; + + // Extract id field early so error responses can include it + com.google.gson.JsonElement idElement = node.get("id"); + if (idElement != null && !idElement.isJsonNull() && !idElement.isJsonPrimitive()) { + throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: 'id' must be a string, number, or null"); + } + if (idElement != null && !idElement.isJsonNull() && idElement.isJsonPrimitive()) { + com.google.gson.JsonPrimitive idPrimitive = idElement.getAsJsonPrimitive(); + requestId = idPrimitive.isNumber() ? idPrimitive.getAsLong() : idPrimitive.getAsString(); + } + + // Validate jsonrpc field + com.google.gson.JsonElement jsonrpcElement = node.get("jsonrpc"); + if (jsonrpcElement == null || !jsonrpcElement.isJsonPrimitive() + || !JSONRPCMessage_v0_3.JSONRPC_VERSION.equals(jsonrpcElement.getAsString())) { + throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); + } + + // Validate method field + com.google.gson.JsonElement methodElement = node.get("method"); + if (methodElement == null || !methodElement.isJsonPrimitive() + || !methodElement.getAsJsonPrimitive().isString()) { + throw new InvalidRequestError_v0_3("Invalid JSON-RPC request: missing or invalid 'method' field"); + } + + methodName = methodElement.getAsString(); + + boolean streaming = SendStreamingMessageRequest_v0_3.METHOD.equals(methodName) + || TaskResubscriptionRequest_v0_3.METHOD.equals(methodName); + JSONRPCRequest_v0_3 request = streaming + ? deserializeStreamingRequest(requestBody, methodName) + : deserializeNonStreamingRequest(requestBody, methodName); + return new DecodedRequest(requestId, methodName, request); + } catch (JSONRPCError_v0_3 e) { + return new DecodeFailure(requestId, methodName, e); + } catch (JsonSyntaxException e) { + return new DecodeFailure(requestId, methodName, new JSONParseError_v0_3(e.getMessage())); + } catch (Throwable t) { + return new DecodeFailure(requestId, methodName, new InternalError_v0_3(t.getMessage())); + } + } + + private static com.google.gson.JsonObject parseBody(@Nullable String body) { + if (body == null) { + throw new JsonSyntaxException("Request body is required"); + } + return JsonParser.parseString(body).getAsJsonObject(); + } + + private static NonStreamingJSONRPCRequest_v0_3 deserializeNonStreamingRequest(String body, String methodName) { + try { + return switch (methodName) { + case GetTaskRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetTaskRequest_v0_3.class); + case CancelTaskRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, CancelTaskRequest_v0_3.class); + case SendMessageRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SendMessageRequest_v0_3.class); + case SetTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SetTaskPushNotificationConfigRequest_v0_3.class); + case GetTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetTaskPushNotificationConfigRequest_v0_3.class); + case ListTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, ListTaskPushNotificationConfigRequest_v0_3.class); + case DeleteTaskPushNotificationConfigRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, DeleteTaskPushNotificationConfigRequest_v0_3.class); + case GetAuthenticatedExtendedCardRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, GetAuthenticatedExtendedCardRequest_v0_3.class); + default -> throw new MethodNotFoundError_v0_3(); + }; + } catch (JSONRPCError_v0_3 e) { + throw e; + } catch (Exception e) { + throw new InvalidParamsError_v0_3(e.getMessage()); + } + } + + private static StreamingJSONRPCRequest_v0_3 deserializeStreamingRequest(String body, String methodName) { + try { + return switch (methodName) { + case SendStreamingMessageRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, SendStreamingMessageRequest_v0_3.class); + case TaskResubscriptionRequest_v0_3.METHOD -> JsonUtil_v0_3.fromJson(body, TaskResubscriptionRequest_v0_3.class); + default -> throw new MethodNotFoundError_v0_3(); + }; + } catch (JSONRPCError_v0_3 e) { + throw e; + } catch (Exception e) { + throw new InvalidParamsError_v0_3(e.getMessage()); + } + } + +} diff --git a/compat-0.3/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3_Test.java b/compat-0.3/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3_Test.java new file mode 100644 index 000000000..6cf9d2c4e --- /dev/null +++ b/compat-0.3/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/transport/jsonrpc/handler/JSONRPCRequestDecoder_v0_3_Test.java @@ -0,0 +1,215 @@ +package org.a2aproject.sdk.compat03.transport.jsonrpc.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.stream.Stream; + +import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetAuthenticatedExtendedCardRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; +import org.a2aproject.sdk.compat03.spec.NonStreamingJSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingJSONRPCRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; +import org.a2aproject.sdk.compat03.transport.jsonrpc.handler.JSONRPCRequestDecoder_v0_3.DecodeFailure; +import org.a2aproject.sdk.compat03.transport.jsonrpc.handler.JSONRPCRequestDecoder_v0_3.DecodedRequest; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** Decoder parity with the reference route's captured wire responses. */ +public class JSONRPCRequestDecoder_v0_3_Test { + + private static final String MESSAGE_PARAMS = "{\"message\":{\"kind\":\"message\",\"messageId\":\"m\"," + + "\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}]}}"; + + @Test + public void testInvalidJsonBeforeEnvelopeValidation() { + String[][] cases = { + {"{", "java.io.EOFException: End of input at line 1 column 2 path $."}, + {"[]", "Not a JSON Object: []"}, + {"null", "Not a JSON Object: null"}, + {"42", "Not a JSON Object: 42"}, + {"\"text\"", "Not a JSON Object: \"text\""}, + {"", "Not a JSON Object: null"} + }; + for (String[] entry : cases) { + assertFailure(entry[0], null, null, JSONParseError_v0_3.class, -32700, entry[1]); + } + } + + @Test + public void testNullBodyIsJsonParseError() { + DecodeFailure failure = assertInstanceOf(DecodeFailure.class, JSONRPCRequestDecoder_v0_3.decode(null)); + assertInstanceOf(JSONParseError_v0_3.class, failure.error()); + assertEquals(-32700, failure.error().getCode()); + assertEquals("Request body is required", failure.error().getMessage()); + } + + @Test + public void testInvalidIdBeforeVersionAndMethod() { + for (String id : List.of("{}", "[]")) { + assertFailure("{\"id\":" + id + ",\"jsonrpc\":false,\"method\":null}", null, null, + InvalidRequestError_v0_3.class, -32600, + "Invalid JSON-RPC request: 'id' must be a string, number, or null"); + } + } + + @Test + public void testVersionFieldErrorRetainsId() { + for (String id : List.of("\"original\"", "17")) { + for (String version : new String[] {null, "null", "{}", "[]", "true", "1", "\"1.0\""}) { + String field = version == null ? "" : ",\"jsonrpc\":" + version; + assertFailure("{\"id\":" + id + ",\"method\":{}" + field + "}", + id.equals("17") ? Long.valueOf(17) : "original", null, + InvalidRequestError_v0_3.class, -32600, + "Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); + } + } + } + + @Test + public void testMethodFieldErrorRetainsId() { + for (String method : new String[] {null, "null", "{}", "[]", "true", "17"}) { + String field = method == null ? "" : ",\"method\":" + method; + assertFailure("{\"jsonrpc\":\"2.0\",\"id\":17" + field + "}", 17L, null, + InvalidRequestError_v0_3.class, -32600, + "Invalid JSON-RPC request: missing or invalid 'method' field"); + } + } + + @Test + public void testUnknownMethodBeforeParams() { + String[][] cases = {{"\"unknown\"", "unknown"}, {"\"\"", ""}}; + for (String[] entry : cases) { + assertFailure("{\"jsonrpc\":\"2.0\",\"id\":\"unknown\",\"method\":" + entry[0] + + ",\"params\":[]}", "unknown", entry[1], MethodNotFoundError_v0_3.class, -32601, "Method not found"); + } + } + + @Test + public void testParamsErrorRetainsId() { + for (String params : List.of("[]", "\"bad\"", "{\"historyLength\":-1}", + "{\"id\":\"task\",\"historyLength\":\"bad\"}")) { + assertFailure(request("tasks/get", params), "original", "tasks/get", InvalidParamsError_v0_3.class, -32602, + params.equals("{\"historyLength\":-1}") + ? null + : "Failed to parse JSON"); + } + for (String params : new String[] {null, "null"}) { + for (String method : List.of("tasks/get", "message/send", "message/stream", "tasks/resubscribe")) { + DecodedRequest decoded = assertInstanceOf(DecodedRequest.class, + JSONRPCRequestDecoder_v0_3.decode(request(method, params))); + assertEquals("original", decoded.requestId()); + assertEquals(method, decoded.methodName()); + assertEquals(method, decoded.request().getMethod()); + assertNull(decoded.request().getParams()); + } + } + for (String params : List.of("{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"invalid\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}]}}", + "{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{}]}}", + "{\"message\":{\"kind\":\"message\",\"messageId\":\"m\",\"role\":\"user\",\"parts\":[{\"kind\":\"file\",\"file\":{}}]}}")) { + assertFailure(request("message/send", params), "original", "message/send", InvalidParamsError_v0_3.class, + -32602, "Failed to parse JSON"); + } + } + + @Test + public void testStreamingDecodeFailureIsHttpJson() { + for (String method : List.of("message/stream", "tasks/resubscribe")) { + assertFailure(request(method, "[]"), "original", method, InvalidParamsError_v0_3.class, + -32602, "Failed to parse JSON"); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("supportedMethods") + public void testSupportedMethodsDecode(String method, @Nullable String params, + Class> requestClass, boolean streaming) { + DecodedRequest decoded = assertInstanceOf(DecodedRequest.class, + JSONRPCRequestDecoder_v0_3.decode(request(method, params))); + assertEquals("original", decoded.requestId()); + assertEquals(method, decoded.methodName()); + assertEquals(requestClass, decoded.request().getClass()); + assertEquals("original", decoded.request().getId()); + assertEquals(method, decoded.request().getMethod()); + assertEquals("2.0", decoded.request().getJsonrpc()); + if (streaming) { + assertInstanceOf(StreamingJSONRPCRequest_v0_3.class, decoded.request()); + } else { + assertInstanceOf(NonStreamingJSONRPCRequest_v0_3.class, decoded.request()); + } + if (params != null) { + assertNotNull(decoded.request().getParams()); + } else { + assertNull(decoded.request().getParams()); + } + } + + private static Stream supportedMethods() { + return Stream.of( + Arguments.of("tasks/get", "{\"id\":\"task\"}", GetTaskRequest_v0_3.class, false), + Arguments.of("tasks/cancel", "{\"id\":\"task\"}", CancelTaskRequest_v0_3.class, false), + Arguments.of("message/send", MESSAGE_PARAMS, SendMessageRequest_v0_3.class, false), + Arguments.of("tasks/pushNotificationConfig/set", "{\"taskId\":\"task\",\"pushNotificationConfig\":{\"url\":\"http://localhost/callback\"}}", SetTaskPushNotificationConfigRequest_v0_3.class, false), + Arguments.of("tasks/pushNotificationConfig/get", "{\"id\":\"task\"}", GetTaskPushNotificationConfigRequest_v0_3.class, false), + Arguments.of("tasks/pushNotificationConfig/list", "{\"id\":\"task\"}", ListTaskPushNotificationConfigRequest_v0_3.class, false), + Arguments.of("tasks/pushNotificationConfig/delete", "{\"id\":\"task\",\"pushNotificationConfigId\":\"config\"}", DeleteTaskPushNotificationConfigRequest_v0_3.class, false), + Arguments.of("agent/getAuthenticatedExtendedCard", null, GetAuthenticatedExtendedCardRequest_v0_3.class, false), + Arguments.of("message/stream", MESSAGE_PARAMS, SendStreamingMessageRequest_v0_3.class, true), + Arguments.of("tasks/resubscribe", "{\"id\":\"task\"}", TaskResubscriptionRequest_v0_3.class, true)); + } + + @Test + public void testExistingIdCoercion() { + Object[][] cases = {{"\"request\"", "request", "request"}, {"7", 7L, 7L}, {"7.9", 7L, 7.9}, + {"true", "true", true}, {"null", null, null}, {null, null, null}}; + for (Object[] entry : cases) { + String id = entry[0] == null ? "" : "\"id\":" + entry[0] + ","; + assertFailure("{" + id + "\"jsonrpc\":\"wrong\"}", entry[1], null, + InvalidRequestError_v0_3.class, -32600, + "Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"); + DecodedRequest decoded = assertInstanceOf(DecodedRequest.class, JSONRPCRequestDecoder_v0_3.decode( + "{" + id + "\"jsonrpc\":\"2.0\",\"method\":\"tasks/get\",\"params\":{\"id\":\"task\"}}")); + assertEquals(entry[1], decoded.requestId()); + assertEquals(entry[2], decoded.request().getId()); + } + } + + private static String request(String method, @Nullable String params) { + return "{\"jsonrpc\":\"2.0\",\"id\":\"original\",\"method\":\"" + method + "\"" + + (params == null ? "" : ",\"params\":" + params) + "}"; + } + + private static void assertFailure(String body, @Nullable Object id, @Nullable String method, + Class errorClass, int code, @Nullable String message) { + DecodeFailure failure = assertInstanceOf(DecodeFailure.class, JSONRPCRequestDecoder_v0_3.decode(body)); + assertEquals(id, failure.requestId()); + assertEquals(method, failure.methodName()); + assertEquals(errorClass, failure.error().getClass()); + assertEquals(code, failure.error().getCode()); + if (message == null) { + assertNotNull(failure.error().getMessage()); + } else { + assertEquals(message, failure.error().getMessage()); + } + assertNull(failure.error().getData()); + } +} diff --git a/compat-0.3/transport/rest/pom.xml b/compat-0.3/transport/rest/pom.xml index ebb1e3a05..c513578b6 100644 --- a/compat-0.3/transport/rest/pom.xml +++ b/compat-0.3/transport/rest/pom.xml @@ -57,6 +57,11 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core diff --git a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java index 8a8443d2d..57d948ae0 100644 --- a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java +++ b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java @@ -41,10 +41,11 @@ import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; -import org.a2aproject.sdk.server.util.async.Internal; -import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; import org.a2aproject.sdk.compat03.conversion.ErrorConverter_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.server.util.async.Internal; +import org.a2aproject.sdk.util.Assert; import org.a2aproject.sdk.spec.A2AError; import jakarta.enterprise.inject.Instance; import java.util.concurrent.CompletableFuture; @@ -121,12 +122,10 @@ public HTTPRestResponse sendStreamingMessage(String body, ServerCallContext cont } } - public HTTPRestResponse cancelTask(String taskId, ServerCallContext context) { + public HTTPRestResponse cancelTask(@Nullable String taskId, ServerCallContext context) { try { - if (taskId == null || taskId.isEmpty()) { - throw new InvalidParamsError_v0_3(); - } - TaskIdParams_v0_3 params = new TaskIdParams_v0_3(taskId); + String requiredTaskId = requireNonEmpty("task id", taskId); + TaskIdParams_v0_3 params = new TaskIdParams_v0_3(requiredTaskId); Task_v0_3 task = requestHandler.onCancelTask(params, context); if (task != null) { return createSuccessResponse(200, org.a2aproject.sdk.compat03.grpc.Task.newBuilder(ProtoUtils_v0_3.ToProto.task(task))); @@ -141,13 +140,43 @@ public HTTPRestResponse cancelTask(String taskId, ServerCallContext context) { } } - public HTTPRestResponse setTaskPushNotificationConfiguration(String taskId, String body, ServerCallContext context) { + public HTTPRestResponse setTaskPushNotificationConfiguration(@Nullable String taskId, String body, ServerCallContext context) { try { + String requiredTaskId = requireNonEmpty("task id", taskId); if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError_v0_3(); } org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.newBuilder(); parseRequestBody(body, builder); + String parent = "tasks/" + requiredTaskId; + if (!builder.getParent().isBlank() && !parent.equals(builder.getParent())) { + throw new InvalidParamsError_v0_3("bad parent"); + } + String configNamePrefix = parent + "/pushNotificationConfigs"; + String name = builder.getConfig().getName(); + String nameConfigId = ""; + if (!name.isBlank()) { + if (name.equals(configNamePrefix) || name.equals(configNamePrefix + "/")) { + // The name omits a config ID; use config_id or the URL task ID. + } else if (name.startsWith(configNamePrefix + "/")) { + nameConfigId = name.substring(configNamePrefix.length() + 1); + if (nameConfigId.isBlank() || nameConfigId.contains("/")) { + throw new InvalidParamsError_v0_3("bad config name"); + } + } else { + throw new InvalidParamsError_v0_3("bad config name"); + } + } + String requestConfigId = builder.getConfigId().isBlank() ? "" : builder.getConfigId(); + if (requestConfigId.contains("/") || (!nameConfigId.isEmpty() && !requestConfigId.isEmpty() + && !nameConfigId.equals(requestConfigId))) { + throw new InvalidParamsError_v0_3("bad config id"); + } + String configId = !requestConfigId.isEmpty() ? requestConfigId + : !nameConfigId.isEmpty() ? nameConfigId : requiredTaskId; + builder.setParent(parent); + builder.setConfigId(configId); + builder.getConfigBuilder().setName(configNamePrefix + "/" + configId); TaskPushNotificationConfig_v0_3 result = requestHandler.onSetTaskPushNotificationConfig(ProtoUtils_v0_3.FromProto.taskPushNotificationConfig(builder), context); return createSuccessResponse(201, org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils_v0_3.ToProto.taskPushNotificationConfig(result))); } catch (A2AError e) { @@ -159,12 +188,18 @@ public HTTPRestResponse setTaskPushNotificationConfiguration(String taskId, Stri } } - public HTTPRestResponse resubscribeTask(String taskId, ServerCallContext context) { + public HTTPRestResponse resubscribeTask(@Nullable String taskId, ServerCallContext context) { + String requiredTaskId; + try { + requiredTaskId = requireNonEmpty("task id", taskId); + } catch (InvalidParamsError_v0_3 e) { + return createErrorResponse(e); + } try { if (!agentCard.capabilities().streaming()) { return createErrorResponse(new InvalidRequestError_v0_3("Streaming is not supported by the agent")); } - TaskIdParams_v0_3 params = new TaskIdParams_v0_3(taskId); + TaskIdParams_v0_3 params = new TaskIdParams_v0_3(requiredTaskId); Flow.Publisher publisher = requestHandler.onResubscribeToTask(params, context); return createStreamingResponse(publisher); } catch (A2AError e) { @@ -176,9 +211,33 @@ public HTTPRestResponse resubscribeTask(String taskId, ServerCallContext context } } - public HTTPRestResponse getTask(String taskId, int historyLength, ServerCallContext context) { + public HTTPRestResponse getTask(@Nullable String taskId, @Nullable String historyLengthSnakeCase, + @Nullable String historyLengthCamelCase, ServerCallContext context) { + String requiredTaskId; try { - TaskQueryParams_v0_3 params = new TaskQueryParams_v0_3(taskId, historyLength); + requiredTaskId = requireNonEmpty("task id", taskId); + } catch (InvalidParamsError_v0_3 e) { + return createErrorResponse(e); + } + if (historyLengthSnakeCase != null && historyLengthCamelCase != null) { + return createErrorResponse(new InvalidParamsError_v0_3( + "Only one of 'history_length' or 'historyLength' may be specified")); + } + int historyLength = 0; + try { + if (historyLengthSnakeCase != null) { + historyLength = Integer.parseInt(historyLengthSnakeCase); + } else if (historyLengthCamelCase != null) { + historyLength = Integer.parseInt(historyLengthCamelCase); + } + } catch (NumberFormatException e) { + return createErrorResponse(new InvalidParamsError_v0_3("bad history_length or historyLength")); + } + if (historyLength < 0) { + return createErrorResponse(new InvalidParamsError_v0_3("bad history_length or historyLength")); + } + try { + TaskQueryParams_v0_3 params = new TaskQueryParams_v0_3(requiredTaskId, historyLength); Task_v0_3 task = requestHandler.onGetTask(params, context); if (task != null) { return createSuccessResponse(200, org.a2aproject.sdk.compat03.grpc.Task.newBuilder(ProtoUtils_v0_3.ToProto.task(task))); @@ -193,12 +252,16 @@ public HTTPRestResponse getTask(String taskId, int historyLength, ServerCallCont } } - public HTTPRestResponse getTaskPushNotificationConfiguration(String taskId, @Nullable String configId, ServerCallContext context) { + /** + * A missing or empty config ID selects the task's default push notification configuration in v0.3. + */ + public HTTPRestResponse getTaskPushNotificationConfiguration(@Nullable String taskId, @Nullable String configId, ServerCallContext context) { try { + String requiredTaskId = requireNonEmpty("task id", taskId); if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError_v0_3(); } - GetTaskPushNotificationConfigParams_v0_3 params = new GetTaskPushNotificationConfigParams_v0_3(taskId, configId); + GetTaskPushNotificationConfigParams_v0_3 params = new GetTaskPushNotificationConfigParams_v0_3(requiredTaskId, configId); TaskPushNotificationConfig_v0_3 config = requestHandler.onGetTaskPushNotificationConfig(params, context); return createSuccessResponse(200, org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils_v0_3.ToProto.taskPushNotificationConfig(config))); } catch (A2AError e) { @@ -210,12 +273,13 @@ public HTTPRestResponse getTaskPushNotificationConfiguration(String taskId, @Nul } } - public HTTPRestResponse listTaskPushNotificationConfigurations(String taskId, ServerCallContext context) { + public HTTPRestResponse listTaskPushNotificationConfigurations(@Nullable String taskId, ServerCallContext context) { try { + String requiredTaskId = requireNonEmpty("task id", taskId); if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError_v0_3(); } - ListTaskPushNotificationConfigParams_v0_3 params = new ListTaskPushNotificationConfigParams_v0_3(taskId); + ListTaskPushNotificationConfigParams_v0_3 params = new ListTaskPushNotificationConfigParams_v0_3(requiredTaskId); List configs = requestHandler.onListTaskPushNotificationConfig(params, context); return createSuccessResponse(200, org.a2aproject.sdk.compat03.grpc.ListTaskPushNotificationConfigResponse.newBuilder(ProtoUtils_v0_3.ToProto.listTaskPushNotificationConfigResponse(configs))); } catch (A2AError e) { @@ -227,12 +291,14 @@ public HTTPRestResponse listTaskPushNotificationConfigurations(String taskId, Se } } - public HTTPRestResponse deleteTaskPushNotificationConfiguration(String taskId, String configId, ServerCallContext context) { + public HTTPRestResponse deleteTaskPushNotificationConfiguration(@Nullable String taskId, @Nullable String configId, ServerCallContext context) { try { + String requiredTaskId = requireNonEmpty("task id", taskId); + String requiredConfigId = requireNonEmpty("config id", configId); if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError_v0_3(); } - DeleteTaskPushNotificationConfigParams_v0_3 params = new DeleteTaskPushNotificationConfigParams_v0_3(taskId, configId); + DeleteTaskPushNotificationConfigParams_v0_3 params = new DeleteTaskPushNotificationConfigParams_v0_3(requiredTaskId, requiredConfigId); requestHandler.onDeleteTaskPushNotificationConfig(params, context); return new HTTPRestResponse(204, "application/json", ""); } catch (A2AError e) { @@ -259,6 +325,13 @@ private void parseRequestBody(String body, com.google.protobuf.Message.Builder b } } + private static String requireNonEmpty(String parameter, @Nullable String value) { + if (Assert.isNullOrEmpty(value)) { + throw new InvalidParamsError_v0_3("bad " + parameter); + } + return Assert.checkNotNullParam(parameter, value); + } + private void validate(String json) { try { JsonParser.parseString(json); diff --git a/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java b/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java index 032d50503..e7aae196e 100644 --- a/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java +++ b/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java @@ -1,24 +1,37 @@ package org.a2aproject.sdk.compat03.transport.rest.handler; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + import java.util.HashSet; import java.util.Map; import org.a2aproject.sdk.compat03.conversion.A2AProtocol_v0_3; import org.a2aproject.sdk.compat03.conversion.AbstractA2ARequestHandlerTest_v0_3; -import org.a2aproject.sdk.server.requesthandlers.LogCaptureAssertions; import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; +import org.a2aproject.sdk.server.requesthandlers.LogCaptureAssertions; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.ArgumentCaptor; /** * Test suite for v0.3 RestHandler with v1.0 backend. @@ -47,14 +60,14 @@ public void testGetTaskSuccess() { // Save v0.3 task by converting to v1.0 taskStore.save(TaskMapper_v0_3.INSTANCE.toV10(MINIMAL_TASK), false); - RestHandler_v0_3.HTTPRestResponse response = handler.getTask(MINIMAL_TASK.id(), 0, callContext); + RestHandler_v0_3.HTTPRestResponse response = handler.getTask(MINIMAL_TASK.id(), null, null, callContext); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); Assertions.assertTrue(response.getBody().contains(MINIMAL_TASK.id())); // Test with different version parameter - response = handler.getTask(MINIMAL_TASK.id(), 2, callContext); + response = handler.getTask(MINIMAL_TASK.id(), "2", null, callContext); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -65,7 +78,7 @@ public void testGetTaskSuccess() { public void testGetTaskNotFound() { RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); - RestHandler_v0_3.HTTPRestResponse response = handler.getTask("nonexistent", 0, callContext); + RestHandler_v0_3.HTTPRestResponse response = handler.getTask("nonexistent", null, null, callContext); Assertions.assertEquals(404, response.getStatusCode()); Assertions.assertEquals("application/json", response.getContentType()); @@ -321,6 +334,76 @@ public void testPushNotificationConfigNotSupported() { assertTrue(response.getBody().contains("PushNotificationNotSupportedError")); } + @Test + public void testSetPushConfigUsesUrlTaskIdAndExplicitConfigId() { + for (String body : new String[] { + pushConfigBody("\"parent\":\"tasks/task\",\"configId\":\"chosen\",", + "tasks/task/pushNotificationConfigs/chosen"), + pushConfigBody("\"configId\":\"chosen\",", ""), + pushConfigBody("", "tasks/task/pushNotificationConfigs/chosen")}) { + RequestHandler backend = mock(RequestHandler.class); + when(backend.onCreateTaskPushNotificationConfig(any(), any())).thenThrow(new InternalError("delegated")); + assertWireError(validationHandler(true, backend).setTaskPushNotificationConfiguration("task", body, callContext), + 500, "InternalError", "delegated"); + ArgumentCaptor config = ArgumentCaptor.forClass(TaskPushNotificationConfig.class); + verify(backend).onCreateTaskPushNotificationConfig(config.capture(), any()); + assertEquals("task", config.getValue().taskId()); + assertEquals("chosen", config.getValue().id()); + } + } + + @Test + public void testSetPushConfigDefaultsAbsentConfigIdToUrlTaskId() { + for (String configId : new String[] {null, "", " "}) { + for (String name : new String[] {"", "tasks/task/pushNotificationConfigs", "tasks/task/pushNotificationConfigs/"}) { + String fields = configId == null ? "" : "\"configId\":\"" + configId + "\","; + RequestHandler backend = mock(RequestHandler.class); + when(backend.onCreateTaskPushNotificationConfig(any(), any())).thenThrow(new InternalError("delegated")); + assertWireError(validationHandler(true, backend).setTaskPushNotificationConfiguration("task", + pushConfigBody(fields, name), callContext), 500, "InternalError", "delegated"); + ArgumentCaptor config = ArgumentCaptor.forClass(TaskPushNotificationConfig.class); + verify(backend).onCreateTaskPushNotificationConfig(config.capture(), any()); + assertEquals("task", config.getValue().taskId()); + assertEquals("task", config.getValue().id()); + } + } + } + + @Test + public void testSetPushConfigRejectsConflictingAndMalformedResourceNames() { + for (String body : new String[] { + pushConfigBody("\"parent\":\"tasks/other\",", "tasks/task/pushNotificationConfigs/"), + pushConfigBody("\"parent\":\"tasks/task/extra\",", "tasks/task/pushNotificationConfigs/"), + pushConfigBody("", "tasks/other/pushNotificationConfigs/chosen"), + pushConfigBody("", "tasks/task/pushNotificationConfigs/chosen/extra"), + pushConfigBody("", "tasks/task/notPushNotificationConfigs/chosen"), + pushConfigBody("\"configId\":\"other\",", "tasks/task/pushNotificationConfigs/chosen")}) { + RequestHandler backend = mock(RequestHandler.class); + RestHandler_v0_3.HTTPRestResponse response = validationHandler(true, backend) + .setTaskPushNotificationConfiguration("task", body, callContext); + assertEquals(422, response.getStatusCode(), body); + assertTrue(response.getBody().contains("InvalidParamsError"), response.getBody()); + verifyNoInteractions(backend); + } + } + + @Test + public void testSetPushConfigCapabilityPreflightPrecedesBodyValidation() { + for (String body : new String[] {"{broken", pushConfigBody("\"parent\":\"tasks/other\",", "bad/name")}) { + RequestHandler backend = mock(RequestHandler.class); + RestHandler_v0_3.HTTPRestResponse response = validationHandler(false, backend) + .setTaskPushNotificationConfiguration("task", body, callContext); + assertEquals(501, response.getStatusCode()); + assertTrue(response.getBody().contains("PushNotificationNotSupportedError"), response.getBody()); + verifyNoInteractions(backend); + } + } + + private String pushConfigBody(String fields, String name) { + return "{" + fields + "\"config\":{\"name\":\"" + name + + "\",\"pushNotificationConfig\":{\"url\":\"https://example.com/callback\"}}}"; + } + @Test public void testGetPushNotificationConfig() { RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); @@ -404,4 +487,154 @@ public void testPushNotificationConfigParseError_DoesNotLogSensitiveData() { "secret-token-12345", "oauth-secret-67890"); } + @Test + public void testSharedErrorConversionPreservesWireMessage() { + RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); + RestHandler_v0_3.HTTPRestResponse response = handler.getTask("missing-conversion-task", null, null, callContext); + assertEquals(404, response.getStatusCode()); + assertEquals("application/json", response.getContentType()); + assertEquals(Map.of(), response.getHeaders()); + assertEquals("{\"error\": \"org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3\", " + + "\"message\": \"Task not found\"}", response.getBody()); + response = handler.getTask("task", "-1", null, callContext); + assertEquals(422, response.getStatusCode()); + assertEquals("application/json", response.getContentType()); + assertEquals("{\"error\": \"org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3\", " + + "\"message\": \"bad history_length or historyLength\"}", response.getBody()); + } + + private RestHandler_v0_3 validationHandler(boolean capabilities, RequestHandler backend) { + AgentCard_v0_3 card = new AgentCard_v0_3.Builder(CARD) + .capabilities(new AgentCapabilities_v0_3(capabilities, capabilities, false, null)).build(); + return new RestHandler_v0_3(card, internalExecutor, new Convert_v0_3_To10RequestHandler(backend)); + } + + private void assertWireError(RestHandler_v0_3.HTTPRestResponse response, int status, String type, String message) { + assertEquals(RestHandler_v0_3.HTTPRestResponse.class, response.getClass()); + assertEquals(status, response.getStatusCode()); + assertEquals("application/json", response.getContentType()); + assertEquals(Map.of(), response.getHeaders()); + assertEquals("{\"error\": \"org.a2aproject.sdk.compat03.spec." + type + + "_v0_3\", \"message\": \"" + message + "\"}", response.getBody()); + } + + @ParameterizedTest + @NullAndEmptySource + public void testGetTaskRawAliasValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + RestHandler_v0_3 handler = validationHandler(true, backend); + assertWireError(handler.getTask(taskId, "bad", "bad", callContext), 422, "InvalidParamsError", "bad task id"); + assertWireError(handler.getTask(taskId, "bad", null, callContext), 422, "InvalidParamsError", "bad task id"); + assertWireError(handler.getTask(taskId, null, "bad", callContext), 422, "InvalidParamsError", "bad task id"); + verifyNoInteractions(backend); + } + + @Test + public void testGetTaskRawAliases() { + RequestHandler backend = mock(RequestHandler.class); + RestHandler_v0_3 handler = validationHandler(true, backend); + for (String snake : new String[] {"", "bad", "2"}) { + assertWireError(handler.getTask("task", snake, "bad", callContext), 422, "InvalidParamsError", + "Only one of 'history_length' or 'historyLength' may be specified"); + } + for (String value : new String[] {"", "bad", "2147483648", "-1"}) { + assertWireError(handler.getTask("task", value, null, callContext), 422, "InvalidParamsError", + "bad history_length or historyLength"); + assertWireError(handler.getTask("task", null, value, callContext), 422, "InvalidParamsError", + "bad history_length or historyLength"); + } + verifyNoInteractions(backend); + for (String[] aliases : new String[][] {{null, null}, {"2", null}, {null, "2"}}) { + RequestHandler successfulBackend = mock(RequestHandler.class); + RestHandler_v0_3 successfulHandler = validationHandler(true, successfulBackend); + when(successfulBackend.onGetTask(any(), any())).thenReturn(TaskMapper_v0_3.INSTANCE.toV10(MINIMAL_TASK)); + RestHandler_v0_3.HTTPRestResponse response = successfulHandler.getTask("task", aliases[0], aliases[1], callContext); + assertEquals(200, response.getStatusCode()); + assertEquals("application/json", response.getContentType()); + assertTrue(response.getBody().contains(MINIMAL_TASK.id())); + ArgumentCaptor query = ArgumentCaptor.forClass(TaskQueryParams.class); + verify(successfulBackend).onGetTask(query.capture(), any()); + assertEquals(aliases[0] == null && aliases[1] == null ? null : Integer.valueOf(2), query.getValue().historyLength()); + } + } + + @ParameterizedTest + @NullAndEmptySource + public void testCancelTaskPathValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + assertWireError(validationHandler(true, backend).cancelTask(taskId, callContext), + 422, "InvalidParamsError", "bad task id"); + verifyNoInteractions(backend); + } + + @ParameterizedTest + @NullAndEmptySource + public void testResubscribeTaskPathValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + for (boolean capabilities : new boolean[] {true, false}) { + assertWireError(validationHandler(capabilities, backend).resubscribeTask(taskId, callContext), + 422, "InvalidParamsError", "bad task id"); + } + verifyNoInteractions(backend); + } + + @ParameterizedTest + @NullAndEmptySource + public void testSetPushConfigPathValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + for (boolean capabilities : new boolean[] {true, false}) { + assertWireError(validationHandler(capabilities, backend).setTaskPushNotificationConfiguration(taskId, "{broken", callContext), + 422, "InvalidParamsError", "bad task id"); + } + verifyNoInteractions(backend); + } + + @ParameterizedTest + @NullAndEmptySource + public void testGetPushConfigPathValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + for (boolean capabilities : new boolean[] {true, false}) { + assertWireError(validationHandler(capabilities, backend).getTaskPushNotificationConfiguration(taskId, null, callContext), + 422, "InvalidParamsError", "bad task id"); + } + verifyNoInteractions(backend); + } + + @ParameterizedTest + @NullAndEmptySource + public void testGetPushConfigPermitsMissingConfigId(String configId) { + RequestHandler backend = mock(RequestHandler.class); + when(backend.onGetTaskPushNotificationConfig(any(), any())).thenThrow(new InternalError("delegated")); + assertWireError(validationHandler(true, backend).getTaskPushNotificationConfiguration("task", configId, callContext), + 500, "InternalError", "delegated"); + ArgumentCaptor query = ArgumentCaptor.forClass(GetTaskPushNotificationConfigParams.class); + verify(backend).onGetTaskPushNotificationConfig(query.capture(), any()); + assertEquals(configId == null ? "task" : "", query.getValue().id()); + } + + @ParameterizedTest + @NullAndEmptySource + public void testListPushConfigsPathValidationOrder(String taskId) { + RequestHandler backend = mock(RequestHandler.class); + for (boolean capabilities : new boolean[] {true, false}) { + assertWireError(validationHandler(capabilities, backend).listTaskPushNotificationConfigurations(taskId, callContext), + 422, "InvalidParamsError", "bad task id"); + } + verifyNoInteractions(backend); + } + + @ParameterizedTest + @NullAndEmptySource + public void testDeletePushConfigPathValidationOrder(String id) { + RequestHandler backend = mock(RequestHandler.class); + for (boolean capabilities : new boolean[] {true, false}) { + RestHandler_v0_3 handler = validationHandler(capabilities, backend); + assertWireError(handler.deleteTaskPushNotificationConfiguration(id, id, callContext), + 422, "InvalidParamsError", "bad task id"); + assertWireError(handler.deleteTaskPushNotificationConfiguration("task", id, callContext), + 422, "InvalidParamsError", "bad config id"); + } + verifyNoInteractions(backend); + } + } diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java index 4c5d4ce5e..ce73b3b11 100644 --- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java @@ -240,7 +240,8 @@ void setupRoutes(@Observes Router router) { .setStatusCode(200) .putHeader(CONTENT_TYPE, APPLICATION_JSON) .end(agentCard); - } catch (JsonProcessingException e) { + } catch (Exception e) { + LOGGER.error("Internal error while resolving agent card", e); ctx.response().setStatusCode(500).putHeader(CONTENT_TYPE, "text/plain").end("Internal Server Error"); } }); diff --git a/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java b/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java index 6e89f5f0d..312a9f742 100644 --- a/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java +++ b/reference/jsonrpc/src/test/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutesTest.java @@ -1,20 +1,20 @@ package org.a2aproject.sdk.server.apps.quarkus; +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static java.util.Collections.singletonList; +import static org.a2aproject.sdk.spec.A2AMethods.CANCEL_TASK_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SEND_MESSAGE_METHOD; +import static org.a2aproject.sdk.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SUBSCRIBE_TO_TASK_METHOD; -import static org.a2aproject.sdk.spec.A2AMethods.CANCEL_TASK_METHOD; -import static org.a2aproject.sdk.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; -import static org.a2aproject.sdk.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; import static org.a2aproject.sdk.transport.jsonrpc.context.JSONRPCContextKeys.METHOD_NAME_KEY; import static org.a2aproject.sdk.transport.jsonrpc.context.JSONRPCContextKeys.TENANT_KEY; -import static java.util.Collections.singletonList; -import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; -import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -28,16 +28,26 @@ import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Flow; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; - import jakarta.enterprise.inject.Instance; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; import org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskResponse; +import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigRequest; +import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.DeleteTaskPushNotificationConfigRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.DeleteTaskPushNotificationConfigResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.GetExtendedAgentCardRequest; @@ -55,11 +65,12 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageResponse; -import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigRequest; -import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.SubscribeToTaskRequest; import org.a2aproject.sdk.server.AgentCardCacheMetadata; +import org.a2aproject.sdk.server.FixedInstance; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; @@ -70,11 +81,6 @@ import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatus; import org.a2aproject.sdk.transport.jsonrpc.handler.JSONRPCHandler; -import io.vertx.core.MultiMap; -import io.vertx.core.http.HttpServerRequest; -import io.vertx.core.http.HttpServerResponse; -import io.vertx.ext.web.RequestBody; -import io.vertx.ext.web.RoutingContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -269,6 +275,24 @@ public void testGetTask_MethodNameSetInContext() { verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } + @Test + public void testGetTask_ForwardsTransportPolicyErrorAndResponseId() { + String jsonRpcRequest = """ + {"jsonrpc":"2.0","id":"request-id","method":"GetTask","params":{"id":"task"}} + """; + GetTaskResponse policyResponse = new GetTaskResponse("policy-error-id", + new org.a2aproject.sdk.spec.UnsupportedOperationError()); + when(mockJsonRpcHandler.onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class))) + .thenReturn(policyResponse); + + routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext); + + verify(mockJsonRpcHandler).onGetTask(any(GetTaskRequest.class), any(ServerCallContext.class)); + JsonObject response = captureResponse(); + assertEquals("policy-error-id", response.get("id").getAsString()); + assertTrue(response.has("error")); + } + @Test public void testCancelTask_MethodNameSetInContext() { // Arrange - using protobuf JSON format @@ -828,6 +852,100 @@ public void testGetTenantAgentCardReturnsNullWhenNoPublicCard() throws Exception verify(mockHttpResponse, never()).putHeader(any(CharSequence.class), any(CharSequence.class)); } + @Test + void malformedEnvelopeWinsOverUnknownTenantAndUnsupportedVersion() { + AgentCardRouter router = tenant -> { + throw new AssertionError("Envelope decoding must precede card policy"); + }; + JSONRPCHandler handler = new JSONRPCHandler(FixedInstance.empty(), null, mock(RequestHandler.class), + mockExecutor, new FixedInstance<>(router)); + setField(routes, "jsonRpcHandler", handler); + mockHeaders.set("A2A-Version", "2.0"); + when(mockRoutingContext.normalizedPath()).thenReturn("/unknown"); + + routes.invokeJSONRPCHandler("[]", mockRoutingContext); + + assertEquals(-32600, captureResponse().getAsJsonObject("error").get("code").getAsInt()); + } + + @Test + void tenantPublicCardInvalidNameReturns400Text() { + when(mockRoutingContext.pathParam("tenant")).thenReturn("bad?tenant"); + + publicCardRoute(true).handle(mockRoutingContext); + + verify(mockHttpResponse).setStatusCode(400); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, "text/plain"); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(mockHttpResponse).end(body.capture()); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", body.getValue()); + org.mockito.Mockito.verifyNoInteractions(mockJsonRpcHandler); + } + + @Test + void unknownRoutedPublicCardReturns404() { + AgentCardRouter router = tenant -> null; + setField(routes, "jsonRpcHandler", new JSONRPCHandler(FixedInstance.empty(), null, + mock(RequestHandler.class), mockExecutor, new FixedInstance<>(router))); + when(mockRoutingContext.pathParam("tenant")).thenReturn("unknown"); + + publicCardRoute(true).handle(mockRoutingContext); + + verify(mockHttpResponse).setStatusCode(404); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, "text/plain"); + verify(mockHttpResponse).end("No public agent card registered for tenant: unknown"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void publicCardTransportConfigurationFailuresReturnSanitized500(boolean routed) { + AgentCard invalid = AgentCard.builder() + .name("invalid") + .description("invalid transport configuration") + .version("1.0") + .supportedInterfaces(List.of(new AgentInterface("NOT_INSTALLED", "http://localhost"))) + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .build(); + AgentCardRouter router = new AgentCardRouter() { + @Override + public AgentCard resolvePublicCard(String tenant) { + return invalid; + } + + @Override + public AgentCard resolveExtendedCard(String tenant) { + return null; + } + }; + setField(routes, "jsonRpcHandler", new JSONRPCHandler(new FixedInstance<>(invalid), null, + mock(RequestHandler.class), mockExecutor, routed ? new FixedInstance<>(router) : null)); + when(mockRoutingContext.pathParam("tenant")).thenReturn("acme"); + + publicCardRoute(routed).handle(mockRoutingContext); + + verify(mockHttpResponse).setStatusCode(500); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, "text/plain"); + verify(mockHttpResponse).end("Internal Server Error"); + } + + private Handler publicCardRoute(boolean routed) { + Router router = mock(Router.class); + Route route = mock(Route.class, org.mockito.Mockito.RETURNS_SELF); + Route publicRoute = mock(Route.class, org.mockito.Mockito.RETURNS_SELF); + Route tenantRoute = mock(Route.class, org.mockito.Mockito.RETURNS_SELF); + when(router.post(anyString())).thenReturn(route); + when(router.postWithRegex(anyString())).thenReturn(route); + when(router.get(anyString())).thenReturn(publicRoute); + when(router.getWithRegex(anyString())).thenReturn(tenantRoute); + routes.setupRoutes(router); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Handler.class); + verify(routed ? tenantRoute : publicRoute).handler(captor.capture()); + return captor.getValue(); + } + /** * Helper method to set a field via reflection for testing purposes. */ @@ -840,4 +958,43 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException("Failed to set field: " + fieldName, e); } } + + @Test + public void testEmptyMessageIdDecodeErrorRetainsId() { + routes.invokeJSONRPCHandler(""" + {"jsonrpc":"2.0","id":"empty-message","method":"SendMessage","params":{ + "message":{"messageId":"","role":"ROLE_USER","parts":[{"text":"hello"}]}}} + """, mockRoutingContext); + verify(mockHttpResponse).setStatusCode(200); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(mockHttpResponse).end(body.capture()); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"empty-message","error":{"code":-32602, + "message":"Required field cannot be null or empty","data":[{ + "@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(body.getValue())); + org.mockito.Mockito.verifyNoInteractions(mockJsonRpcHandler); + } + + @Test + public void testMalformedProtobufParamsDecodeErrorHasNullId() { + routes.invokeJSONRPCHandler(""" + {"jsonrpc":"2.0","id":"protobuf-error","method":"GetTask", + "params":{"id":"task","historyLength":"bad"}} + """, mockRoutingContext); + verify(mockHttpResponse).setStatusCode(200); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(mockHttpResponse).end(body.capture()); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":null,"error":{"code":-32600, + "message":"Invalid request content: Not an int32 value: \\"bad\\". Please verify the request matches the expected schema for this method.", + "data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_REQUEST", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(body.getValue())); + org.mockito.Mockito.verifyNoInteractions(mockJsonRpcHandler); + } + } diff --git a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java index 266d95333..bd9d23454 100644 --- a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java @@ -36,10 +36,8 @@ import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; import org.a2aproject.sdk.spec.InternalError; -import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.MethodNotFoundError; import org.a2aproject.sdk.spec.TransportProtocol; -import org.a2aproject.sdk.spec.util.Utils; import org.a2aproject.sdk.transport.rest.handler.RestHandler; import org.a2aproject.sdk.transport.rest.handler.RestHandler.HTTPRestResponse; import org.a2aproject.sdk.transport.rest.handler.RestHandler.HTTPRestStreamingResponse; @@ -354,8 +352,8 @@ public void sendMessageStreaming(String body, RoutingContext rc) { HTTPRestResponse error = null; try { HTTPRestResponse response = jsonRestHandler.sendStreamingMessage(context, extractTenant(rc), body); - if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { - streamingResponse = hTTPRestStreamingResponse; + if (response instanceof HTTPRestStreamingResponse httpRestStreamingResponse) { + streamingResponse = httpRestStreamingResponse; } else { error = response; } @@ -402,39 +400,10 @@ public void listTasks(RoutingContext rc) { ServerCallContext context = createCallContext(rc, LIST_TASK_METHOD); HTTPRestResponse response = null; try { - // Extract query parameters - String contextId = rc.request().params().get("contextId"); - String statusStr = rc.request().params().get("status"); - if (statusStr != null && !statusStr.isEmpty()) { - statusStr = statusStr.toUpperCase(); - } - String pageSizeStr = rc.request().params().get(PAGE_SIZE_PARAM); - String pageToken = rc.request().params().get(PAGE_TOKEN_PARAM); - String historyLengthStr = rc.request().params().get(HISTORY_LENGTH_PARAM); - String statusTimestampAfter = rc.request().params().get(STATUS_TIMESTAMP_AFTER); - String includeArtifactsStr = rc.request().params().get("includeArtifacts"); - - // Parse optional parameters - Integer pageSize = null; - if (pageSizeStr != null && !pageSizeStr.isEmpty()) { - pageSize = Integer.valueOf(pageSizeStr); - } - - Integer historyLength = null; - if (historyLengthStr != null && !historyLengthStr.isEmpty()) { - historyLength = Integer.valueOf(historyLengthStr); - } - - Boolean includeArtifacts = null; - if (includeArtifactsStr != null && !includeArtifactsStr.isEmpty()) { - includeArtifacts = Boolean.valueOf(includeArtifactsStr); - } - response = jsonRestHandler.listTasks(context, extractTenant(rc), contextId, statusStr, pageSize, pageToken, - historyLength, statusTimestampAfter, includeArtifacts); - } catch (NumberFormatException e) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("Invalid number format in parameters")); - } catch (IllegalArgumentException e) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("Invalid parameter value: " + e.getMessage())); + response = jsonRestHandler.listTasks(context, extractTenant(rc), rc.request().params().get("contextId"), + rc.request().params().get("status"), rc.request().params().get(PAGE_SIZE_PARAM), + rc.request().params().get(PAGE_TOKEN_PARAM), rc.request().params().get(HISTORY_LENGTH_PARAM), + rc.request().params().get(STATUS_TIMESTAMP_AFTER), rc.request().params().get("includeArtifacts")); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); @@ -459,17 +428,8 @@ public void getTask(RoutingContext rc) { ServerCallContext context = createCallContext(rc, GET_TASK_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else { - Integer historyLength = null; - if (rc.request().params().contains(HISTORY_LENGTH_PARAM)) { - historyLength = Integer.valueOf(rc.request().params().get(HISTORY_LENGTH_PARAM)); - } - response = jsonRestHandler.getTask(context, extractTenant(rc), taskId, historyLength); - } - } catch (NumberFormatException e) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad historyLength")); + response = jsonRestHandler.getTask(context, extractTenant(rc), taskId, + rc.request().params().get(HISTORY_LENGTH_PARAM)); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); @@ -497,11 +457,7 @@ public void cancelTask(String body, RoutingContext rc) { ServerCallContext context = createCallContext(rc, CANCEL_TASK_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else { - response = jsonRestHandler.cancelTask(context, extractTenant(rc), body, taskId); - } + response = jsonRestHandler.cancelTask(context, extractTenant(rc), body, taskId); } catch (Throwable t) { if (t instanceof A2AError error) { response = jsonRestHandler.createErrorResponse(error); @@ -562,15 +518,11 @@ public void subscribeToTask(RoutingContext rc) { HTTPRestStreamingResponse streamingResponse = null; HTTPRestResponse error = null; try { - if (taskId == null || taskId.isEmpty()) { - error = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); + HTTPRestResponse response = jsonRestHandler.subscribeToTask(context, extractTenant(rc), taskId); + if (response instanceof HTTPRestStreamingResponse httpRestStreamingResponse) { + streamingResponse = httpRestStreamingResponse; } else { - HTTPRestResponse response = jsonRestHandler.subscribeToTask(context, extractTenant(rc), taskId); - if (response instanceof HTTPRestStreamingResponse hTTPRestStreamingResponse) { - streamingResponse = hTTPRestStreamingResponse; - } else { - error = response; - } + error = response; } } finally { if (error != null) { @@ -611,11 +563,7 @@ public void createTaskPushNotificationConfiguration(String body, RoutingContext ServerCallContext context = createCallContext(rc, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else { - response = jsonRestHandler.createTaskPushNotificationConfiguration(context, extractTenant(rc), body, taskId); - } + response = jsonRestHandler.createTaskPushNotificationConfiguration(context, extractTenant(rc), body, taskId); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); @@ -641,13 +589,7 @@ public void getTaskPushNotificationConfiguration(RoutingContext rc) { ServerCallContext context = createCallContext(rc, GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else if (configId == null || configId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad configuration id")); - }else { - response = jsonRestHandler.getTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); - } + response = jsonRestHandler.getTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); @@ -679,21 +621,8 @@ public void listTaskPushNotificationConfigurations(RoutingContext rc) { ServerCallContext context = createCallContext(rc, LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else { - int pageSize = 0; - if (rc.request().params().contains(PAGE_SIZE_PARAM)) { - pageSize = Integer.parseInt(rc.request().params().get(PAGE_SIZE_PARAM)); - } - String pageToken = ""; - if (rc.request().params().contains(PAGE_TOKEN_PARAM)) { - pageToken = Utils.defaultIfNull(rc.request().params().get(PAGE_TOKEN_PARAM), ""); - } - response = jsonRestHandler.listTaskPushNotificationConfigurations(context, extractTenant(rc), taskId, pageSize, pageToken); - } - } catch (NumberFormatException e) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad " + PAGE_SIZE_PARAM)); + response = jsonRestHandler.listTaskPushNotificationConfigurations(context, extractTenant(rc), taskId, + rc.request().params().get(PAGE_SIZE_PARAM), rc.request().params().get(PAGE_TOKEN_PARAM)); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); @@ -721,13 +650,7 @@ public void deleteTaskPushNotificationConfiguration(RoutingContext rc) { ServerCallContext context = createCallContext(rc, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD); HTTPRestResponse response = null; try { - if (taskId == null || taskId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad task id")); - } else if (configId == null || configId.isEmpty()) { - response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad config id")); - } else { - response = jsonRestHandler.deleteTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); - } + response = jsonRestHandler.deleteTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } catch (Throwable t) { LOGGER.error("Internal error while processing request", t); response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); diff --git a/reference/rest/src/test/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutesTest.java b/reference/rest/src/test/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutesTest.java index 6b908644b..15d35c4a6 100644 --- a/reference/rest/src/test/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutesTest.java +++ b/reference/rest/src/test/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutesTest.java @@ -15,30 +15,52 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import java.util.List; import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.stream.Stream; import jakarta.enterprise.inject.Instance; -import org.a2aproject.sdk.server.ServerCallContext; -import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; -import org.a2aproject.sdk.transport.rest.handler.RestHandler; -import org.a2aproject.sdk.transport.rest.handler.RestHandler.HTTPRestResponse; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; import io.vertx.ext.web.RequestBody; import io.vertx.ext.web.RoutingContext; +import org.jspecify.annotations.Nullable; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.AgentCardCacheMetadata; +import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.transport.rest.handler.RestHandler.HTTPRestResponse; +import org.a2aproject.sdk.transport.rest.handler.RestHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; /** @@ -403,7 +425,7 @@ public void testListTaskPushNotificationConfigurations_MethodNameSetInContext() when(mockHttpResponse.getStatusCode()).thenReturn(200); when(mockHttpResponse.getContentType()).thenReturn(APPLICATION_JSON); when(mockHttpResponse.getBody()).thenReturn("{}"); - when(mockRestHandler.listTaskPushNotificationConfigurations(any(ServerCallContext.class), anyString(), anyString(), anyInt(), anyString())) + when(mockRestHandler.listTaskPushNotificationConfigurations(any(ServerCallContext.class), anyString(), anyString(), any(), any())) .thenReturn(mockHttpResponse); ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class); @@ -412,7 +434,7 @@ public void testListTaskPushNotificationConfigurations_MethodNameSetInContext() routes.listTaskPushNotificationConfigurations(mockRoutingContext); // Assert - verify(mockRestHandler).listTaskPushNotificationConfigurations(contextCaptor.capture(), anyString(), eq("task123"), anyInt(), anyString()); + verify(mockRestHandler).listTaskPushNotificationConfigurations(contextCaptor.capture(), anyString(), eq("task123"), any(), any()); ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); @@ -497,6 +519,24 @@ public void testSendMessage_UnsupportedProtocolVersion_ReturnsVersionNotSupporte verify(mockResponse).setStatusCode(400); } + @Test + public void testSendMessage_ForwardsTransportPolicyResponseWithoutRouteValidation() { + HTTPRestResponse policyResponse = mock(HTTPRestResponse.class); + when(policyResponse.getStatusCode()).thenReturn(422); + when(policyResponse.getContentType()).thenReturn(APPLICATION_JSON); + when(policyResponse.getBody()).thenReturn("{\"error\":{\"code\":422,\"status\":\"INVALID_ARGUMENT\"}}"); + when(mockRoutingContext.pathParam("tenant")).thenReturn("bad?tenant"); + when(mockRestHandler.sendMessage(any(ServerCallContext.class), eq("bad?tenant"), anyString())) + .thenReturn(policyResponse); + + routes.sendMessage("{}", mockRoutingContext); + + verify(mockRestHandler).sendMessage(any(ServerCallContext.class), eq("bad?tenant"), eq("{}")); + verify(mockResponse).setStatusCode(422); + verify(mockResponse).putHeader(io.vertx.core.http.HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); + verify(mockResponse).end("{\"error\":{\"code\":422,\"status\":\"INVALID_ARGUMENT\"}}"); + } + /** * Helper method to set a field via reflection for testing purposes. */ @@ -509,4 +549,390 @@ private void setField(Object target, String fieldName, Object value) { throw new RuntimeException("Failed to set field: " + fieldName, e); } } + private RequestHandler validationHandler; + + private void validationFixture(String version, boolean extension, boolean capabilities) { + setUp(); + validationHandler = mock(RequestHandler.class); + AgentCard card = AgentCard.builder().name("validation").description("Validation baseline").version("1") + .supportedInterfaces(List.of(new AgentInterface("HTTP+JSON", "http://localhost:9999"))) + .capabilities(AgentCapabilities.builder().streaming(capabilities).pushNotifications(capabilities) + .extensions(extension ? List.of(AgentExtension.builder().uri("urn:test:required") + .required(true).build()) : List.of()).build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()).build(); + setField(routes, "jsonRestHandler", new RestHandler(card, + new AgentCardCacheMetadata(card, new DefaultValuesConfigProvider()), validationHandler, Runnable::run)); + mockHeaders.set("A2A-Version", version); + when(mockRequest.getHeader(anyString())).thenAnswer(invocation -> mockHeaders.get(invocation.getArgument(0).toString())); + when(mockRoutingContext.pathParam("taskId")).thenReturn("task"); + when(mockRoutingContext.pathParam("configId")).thenReturn("config"); + } + + private void assertValidationError(int status, String reason, String message) { + assertValidationError(status, reason, message, new JsonObject()); + } + + private void assertValidationError(int status, String reason, String message, JsonObject metadata) { + verify(mockResponse).setStatusCode(status); + verify(mockResponse).putHeader(io.vertx.core.http.HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(mockResponse).end(body.capture()); + JsonObject expected = JsonParser.parseString(""" + {"error":{"code":422,"status":"INVALID_ARGUMENT","message":"", "details":[{ + "@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """).getAsJsonObject(); + JsonObject error = expected.getAsJsonObject("error"); + error.addProperty("code", status); + error.addProperty("status", switch (reason) { + case "VERSION_NOT_SUPPORTED", "PUSH_NOTIFICATION_NOT_SUPPORTED", "UNSUPPORTED_OPERATION" -> "UNIMPLEMENTED"; + case "EXTENSION_SUPPORT_REQUIRED" -> "FAILED_PRECONDITION"; + default -> "INVALID_ARGUMENT"; + }); + error.addProperty("message", message); + JsonObject info = error.getAsJsonArray("details").get(0).getAsJsonObject(); + info.addProperty("reason", reason); + info.add("metadata", metadata); + assertEquals(expected, JsonParser.parseString(body.getValue())); + verifyNoMoreInteractions(mockResponse); + verifyNoInteractions(validationHandler); + } + + private void assertBadTaskPath(Consumer operation) { + for (String taskId : new String[] {null, ""}) { + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict == 1 ? "2.0" : "1.0", conflict == 2, conflict != 3); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + when(mockRoutingContext.pathParam("configId")).thenReturn(""); + mockParams.set("historyLength", "bad").set("pageSize", "bad"); + operation.accept(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", "bad task id"); + } + } + } + + @Test + public void testGetTaskRawInputValidationOrder() { + assertBadTaskPath(rc -> routes.getTask(rc)); + for (String history : new String[] {"", "bad", "2147483648"}) { + for (int conflict = 0; conflict < 3; conflict++) { + validationFixture(conflict == 1 ? "2.0" : "1.0", conflict == 2, true); + mockParams.set("historyLength", history); + routes.getTask(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", "bad historyLength"); + } + } + validationFixture("2.0", false, true); + mockParams.set("historyLength", "-1"); + routes.getTask(mockRoutingContext); + assertValidationError(400, "VERSION_NOT_SUPPORTED", + "Protocol version '2.0' is not supported. Supported versions: [1.0]"); + validationFixture("1.0", false, true); + mockParams.set("historyLength", "-1"); + routes.getTask(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", "Invalid history length"); + } + + @Test + public void testListTasksRawQueryValidationOrder() { + for (String field : List.of("pageSize", "historyLength")) { + for (int conflict = 0; conflict < 3; conflict++) { + validationFixture(conflict == 1 ? "2.0" : "1.0", conflict == 2, true); + mockParams.set(field, "bad").set("status", "invalid").set("statusTimestampAfter", "invalid"); + if (field.equals("pageSize")) { + mockParams.set("historyLength", "also-bad"); + } + routes.listTasks(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", "Invalid number format in parameters"); + } + validationFixture("2.0", false, true); + mockParams.set(field, "-1"); + routes.listTasks(mockRoutingContext); + assertValidationError(400, "VERSION_NOT_SUPPORTED", + "Protocol version '2.0' is not supported. Supported versions: [1.0]"); + } + for (String raw : new String[] {null, "", "true", "TrUe", "false", "not-true", "1"}) { + validationFixture("1.0", false, true); + if (raw != null) { + mockParams.set("includeArtifacts", raw).set("pageSize", "").set("historyLength", ""); + } + mockParams.set("status", "task_state_submitted"); + when(validationHandler.onListTasks(any(), any())).thenReturn(new ListTasksResult(List.of())); + routes.listTasks(mockRoutingContext); + ArgumentCaptor params = ArgumentCaptor.forClass(ListTasksParams.class); + verify(validationHandler).onListTasks(params.capture(), any()); + assertEquals(TaskState.TASK_STATE_SUBMITTED, params.getValue().status()); + assertNull(params.getValue().pageSize()); + assertNull(params.getValue().historyLength()); + assertEquals(raw == null || raw.isEmpty() ? null : Boolean.valueOf(raw), params.getValue().includeArtifacts()); + verify(mockResponse).setStatusCode(200); + verify(mockResponse).putHeader(io.vertx.core.http.HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); + } + for (String field : List.of("status", "statusTimestampAfter")) { + validationFixture("1.0", false, true); + mockParams.set(field, ""); + routes.listTasks(mockRoutingContext); + JsonObject metadata = new JsonObject(); + metadata.addProperty("parameter", field); + metadata.addProperty("reason", field.equals("status") + ? "Must be one of: " + java.util.Arrays.stream(TaskState.values()).map(TaskState::name) + .collect(java.util.stream.Collectors.joining(", ")) + : "Must be an ISO-8601 timestamp"); + assertValidationError(422, "INVALID_PARAMS", "Invalid params", metadata); + } + } + + @Test + public void testCancelTaskPathValidationOrder() { + assertBadTaskPath(rc -> routes.cancelTask("{broken", rc)); + } + + @Test + public void testSubscribeToTaskPathValidationOrder() { + assertBadTaskPath(rc -> routes.subscribeToTask(rc)); + } + + @Test + public void testCreatePushConfigPathValidationOrder() { + assertBadTaskPath(rc -> routes.createTaskPushNotificationConfiguration("{broken", rc)); + } + + @Test + public void testGetPushConfigPathValidationOrder() { + assertBadTaskPath(rc -> routes.getTaskPushNotificationConfiguration(rc)); + assertBadConfigPath(rc -> routes.getTaskPushNotificationConfiguration(rc), "bad configuration id"); + } + + @Test + public void testDeletePushConfigPathValidationOrder() { + assertBadTaskPath(rc -> routes.deleteTaskPushNotificationConfiguration(rc)); + assertBadConfigPath(rc -> routes.deleteTaskPushNotificationConfiguration(rc), "bad config id"); + } + + private void assertBadConfigPath(Consumer operation, String message) { + for (String configId : new String[] {null, ""}) { + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict == 1 ? "2.0" : "1.0", conflict == 2, conflict != 3); + when(mockRoutingContext.pathParam("configId")).thenReturn(configId); + operation.accept(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", message); + } + } + } + + @Test + public void testListPushConfigsRawInputValidationOrder() { + assertBadTaskPath(rc -> routes.listTaskPushNotificationConfigurations(rc)); + for (String page : List.of("", "bad", "2147483648")) { + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict == 1 ? "2.0" : "1.0", conflict == 2, conflict != 3); + mockParams.set("pageSize", page); + routes.listTaskPushNotificationConfigurations(mockRoutingContext); + assertValidationError(422, "INVALID_PARAMS", "bad pageSize"); + } + } + for (String token : new String[] {null, ""}) { + validationFixture("1.0", false, true); + if (token != null) { + mockParams.set("pageToken", token); + } + when(validationHandler.onListTaskPushNotificationConfigs(any(), any())) + .thenReturn(new ListTaskPushNotificationConfigsResult(List.of())); + routes.listTaskPushNotificationConfigurations(mockRoutingContext); + ArgumentCaptor params = + ArgumentCaptor.forClass(ListTaskPushNotificationConfigsParams.class); + verify(validationHandler).onListTaskPushNotificationConfigs(params.capture(), any()); + assertEquals(0, params.getValue().pageSize()); + assertEquals("", params.getValue().pageToken()); + verify(mockResponse).setStatusCode(200); + } + } + + @Test + public void testCancelContentTypeBeforePathValidation() { + validationFixture("2.0", true, false); + when(mockRoutingContext.pathParam("taskId")).thenReturn(""); + when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain"); + routes.cancelTask("{broken", mockRoutingContext); + assertValidationError(415, "CONTENT_TYPE_NOT_SUPPORTED", "Incompatible content types"); + } + + @Test + public void testCreatePushContentTypeBeforePathValidation() { + validationFixture("2.0", true, false); + when(mockRoutingContext.pathParam("taskId")).thenReturn(""); + when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain"); + routes.createTaskPushNotificationConfiguration("{broken", mockRoutingContext); + assertValidationError(415, "CONTENT_TYPE_NOT_SUPPORTED", "Incompatible content types"); + } + + @ParameterizedTest(name = "taskId={0}, historyLength={1}") + @MethodSource("taskAndRawValues") + public void testGetTaskForwardsRawPathAndQueryValues(@Nullable String taskId, @Nullable String raw) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + if (raw != null) { + mockParams.set("historyLength", raw); + } + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.getTask(any(), anyString(), eq(taskId), eq(raw))).thenReturn(error); + + routes.getTask(mockRoutingContext); + + verify(mockRestHandler).getTask(any(), anyString(), eq(taskId), eq(raw)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "raw query values={0}") + @MethodSource("rawValues") + public void testListTasksForwardsRawQueryValues(@Nullable String raw) { + setUp(); + if (raw != null) { + mockParams.set("pageSize", raw).set("pageToken", raw).set("historyLength", raw) + .set("status", "task_state_submitted") + .set("includeArtifacts", raw).set("contextId", raw).set("statusTimestampAfter", raw); + } + String status = raw == null ? null : "task_state_submitted"; + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.listTasks(any(), anyString(), eq(raw), eq(status), eq(raw), eq(raw), eq(raw), eq(raw), eq(raw))) + .thenReturn(error); + + routes.listTasks(mockRoutingContext); + + verify(mockRestHandler).listTasks(any(), anyString(), eq(raw), eq(status), eq(raw), eq(raw), eq(raw), eq(raw), eq(raw)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testCancelTaskForwardsRawPathValue(@Nullable String taskId) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.cancelTask(any(), anyString(), eq("{broken"), eq(taskId))).thenReturn(error); + + routes.cancelTask("{broken", mockRoutingContext); + + verify(mockRestHandler).cancelTask(any(), anyString(), eq("{broken"), eq(taskId)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testSubscribeToTaskForwardsRawPathValue(@Nullable String taskId) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.subscribeToTask(any(), anyString(), eq(taskId))).thenReturn(error); + + routes.subscribeToTask(mockRoutingContext); + + verify(mockRestHandler).subscribeToTask(any(), anyString(), eq(taskId)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}") + @MethodSource("taskIds") + public void testCreatePushConfigForwardsRawPathValue(@Nullable String taskId) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.createTaskPushNotificationConfiguration(any(), anyString(), eq("{broken"), eq(taskId))) + .thenReturn(error); + + routes.createTaskPushNotificationConfiguration("{broken", mockRoutingContext); + + verify(mockRestHandler).createTaskPushNotificationConfiguration(any(), anyString(), eq("{broken"), eq(taskId)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}, configId={1}") + @MethodSource("taskAndConfigValues") + public void testGetPushConfigForwardsRawPathValues(@Nullable String taskId, @Nullable String configId) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + when(mockRoutingContext.pathParam("configId")).thenReturn(configId); + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.getTaskPushNotificationConfiguration(any(), anyString(), eq(taskId), eq(configId))) + .thenReturn(error); + + routes.getTaskPushNotificationConfiguration(mockRoutingContext); + + verify(mockRestHandler).getTaskPushNotificationConfiguration(any(), anyString(), eq(taskId), eq(configId)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}, pageSize={1}") + @MethodSource("taskAndRawValues") + public void testListPushConfigsForwardsRawPathAndQueryValues(@Nullable String taskId, @Nullable String raw) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + if (raw != null) { + mockParams.set("pageSize", raw).set("pageToken", raw); + } + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.listTaskPushNotificationConfigurations(any(), anyString(), eq(taskId), eq(raw), eq(raw))) + .thenReturn(error); + + routes.listTaskPushNotificationConfigurations(mockRoutingContext); + + verify(mockRestHandler).listTaskPushNotificationConfigurations(any(), anyString(), eq(taskId), eq(raw), eq(raw)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + @ParameterizedTest(name = "taskId={0}, configId={1}") + @MethodSource("taskAndConfigValues") + public void testDeletePushConfigForwardsRawPathValues(@Nullable String taskId, @Nullable String configId) { + setUp(); + when(mockRoutingContext.pathParam("taskId")).thenReturn(taskId); + when(mockRoutingContext.pathParam("configId")).thenReturn(configId); + HTTPRestResponse error = rawForwardingError(); + when(mockRestHandler.deleteTaskPushNotificationConfiguration(any(), anyString(), eq(taskId), eq(configId))) + .thenReturn(error); + + routes.deleteTaskPushNotificationConfiguration(mockRoutingContext); + + verify(mockRestHandler).deleteTaskPushNotificationConfiguration(any(), anyString(), eq(taskId), eq(configId)); + verifyNoMoreInteractions(mockRestHandler); + assertRawForwardingResponse(error); + } + + private static Stream taskIds() { + return Stream.of(Arguments.of((Object) null), Arguments.of(""), Arguments.of("task")); + } + + private static Stream rawValues() { + return Stream.of(Arguments.of((Object) null), Arguments.of(""), Arguments.of("bad"), Arguments.of("2")); + } + + private static Stream taskAndRawValues() { + return Stream.of((String) null, "", "task") + .flatMap(taskId -> Stream.of((String) null, "", "bad", "2") + .map(raw -> Arguments.of(taskId, raw))); + } + + private static Stream taskAndConfigValues() { + return Stream.of((String) null, "", "task") + .flatMap(taskId -> Stream.of((String) null, "", "config") + .map(configId -> Arguments.of(taskId, configId))); + } + + private HTTPRestResponse rawForwardingError() { + return new HTTPRestResponse(422, APPLICATION_JSON, "{\"transportError\":true}"); + } + + private void assertRawForwardingResponse(HTTPRestResponse error) { + verify(mockResponse).setStatusCode(422); + verify(mockResponse).putHeader(io.vertx.core.http.HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); + verify(mockResponse).end(error.getBody()); + verifyNoMoreInteractions(mockResponse); + } + } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardOperation.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardOperation.java new file mode 100644 index 000000000..49acbc931 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardOperation.java @@ -0,0 +1,33 @@ +package org.a2aproject.sdk.server; + +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.a2aproject.sdk.util.Assert; + +/** The capability and transport-specific failure for an operation. */ +public record AgentCardOperation(Predicate capability, + Supplier capabilityFailure) { + public static final AgentCardOperation STREAMING = new AgentCardOperation( + card -> card.capabilities().streaming(), + () -> new UnsupportedOperationError(null, "Streaming is not supported by the agent", null)); + public static final AgentCardOperation PUSH_NOTIFICATIONS = new AgentCardOperation( + card -> card.capabilities().pushNotifications(), PushNotificationNotSupportedError::new); + public static final AgentCardOperation EXTENDED_CARD = new AgentCardOperation( + card -> card.capabilities().extendedAgentCard(), UnsupportedOperationError::new); + + public AgentCardOperation { + Assert.checkNotNullParam("capability", capability); + Assert.checkNotNullParam("capabilityFailure", capabilityFailure); + } + + public static AgentCardOperation none() { + return new AgentCardOperation(card -> true, + () -> new InternalError("No capability failure was configured")); + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardRequestPolicy.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardRequestPolicy.java new file mode 100644 index 000000000..0830c2d70 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardRequestPolicy.java @@ -0,0 +1,69 @@ +package org.a2aproject.sdk.server; + +import java.util.Objects; + +import org.a2aproject.sdk.server.extensions.A2AExtensions; +import org.a2aproject.sdk.server.version.A2AVersionValidator; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** Shared v1 request preflight, with explicit stages for transports that check capability first. */ +public final class AgentCardRequestPolicy { + private final AgentCardResolver resolver; + + public AgentCardRequestPolicy(AgentCardResolver resolver) { + this.resolver = Assert.checkNotNullParam("resolver", resolver); + } + + public EffectiveAgentCard preflight(@Nullable String tenant, ServerCallContext context, + AgentCardTransportValidator validator, AgentCardOperation operation) { + String normalizedTenant = normalizeTenant(tenant); + AgentCard card = resolver.resolveEffectiveCardForNormalizedTenant(normalizedTenant); + validator.validate(card); + validateContext(card, context); + validateCapability(card, operation); + return attach(card, normalizedTenant, context); + } + + public AgentCard resolveAndValidateCapability(@Nullable String tenant, + AgentCardTransportValidator validator, AgentCardOperation operation) { + String normalizedTenant = normalizeTenant(tenant); + AgentCard card = resolver.resolveEffectiveCardForNormalizedTenant(normalizedTenant); + validator.validate(card); + validateCapability(card, operation); + return card; + } + + public EffectiveAgentCard validateContextAndAttach(AgentCard card, @Nullable String tenant, + ServerCallContext context) { + String normalizedTenant = normalizeTenant(tenant); + validateContext(card, context); + return attach(card, normalizedTenant, context); + } + + private static void validateContext(AgentCard card, ServerCallContext context) { + A2AVersionValidator.validateProtocolVersion(card, context); + A2AExtensions.validateRequiredExtensions(card, context); + } + + private static void validateCapability(AgentCard card, AgentCardOperation operation) { + if (!operation.capability().test(card)) { + throw operation.capabilityFailure().get(); + } + } + + private static EffectiveAgentCard attach(AgentCard card, @Nullable String tenant, ServerCallContext context) { + context.attachEffectiveAgentCard(tenant, card); + return Objects.requireNonNull(context.getEffectiveAgentCard()); + } + + private static @Nullable String normalizeTenant(@Nullable String tenant) { + try { + return AgentCardResolver.normalizeTenantForResolution(tenant); + } catch (IllegalArgumentException e) { + throw new InvalidParamsError(e.getMessage()); + } + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardResolver.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardResolver.java new file mode 100644 index 000000000..85b6bbf21 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardResolver.java @@ -0,0 +1,105 @@ +package org.a2aproject.sdk.server; + +import java.util.function.Supplier; + +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.util.Utils; +import org.a2aproject.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** Selects request and discovery cards independently of transport validation. */ +public final class AgentCardResolver { + private final Supplier<@Nullable AgentCard> publicCard; + private final Supplier<@Nullable AgentCard> extendedCard; + private final @Nullable AgentCardRouter router; + + private AgentCardResolver(Supplier<@Nullable AgentCard> publicCard, + Supplier<@Nullable AgentCard> extendedCard, @Nullable AgentCardRouter router) { + this.publicCard = Assert.checkNotNullParam("publicCard", publicCard); + this.extendedCard = Assert.checkNotNullParam("extendedCard", extendedCard); + this.router = router; + } + + public static AgentCardResolver of(Supplier<@Nullable AgentCard> publicCard, + Supplier<@Nullable AgentCard> extendedCard, @Nullable AgentCardRouter router) { + return new AgentCardResolver(publicCard, extendedCard, router); + } + + /** Selects public then extended cards, never using defaults for a routed nonblank tenant. */ + public AgentCard resolveEffectiveCard(@Nullable String tenant) { + String normalizedTenant = normalizeTenantForResolution(tenant); + return resolveEffectiveCardForNormalizedTenant(normalizedTenant); + } + + /** Resolves a card for a tenant already normalized by the shared request policy. */ + AgentCard resolveEffectiveCardForNormalizedTenant(@Nullable String normalizedTenant) { + if (router != null && normalizedTenant != null) { + AgentCard card = router.resolvePublicCard(normalizedTenant); + if (card == null) { + card = router.resolveExtendedCard(normalizedTenant); + } + if (card == null) { + throw new TenantNotFoundException(normalizedTenant); + } + return card; + } + AgentCard card = publicCard.get(); + if (card == null && router != null) { + card = router.resolvePublicCard(null); + } + if (card == null) { + card = extendedCard.get(); + } + if (card == null && router != null) { + card = router.resolveExtendedCard(null); + } + if (card == null) { + throw new IllegalStateException(AgentCardValidator.NO_AGENT_CARD_MESSAGE); + } + return card; + } + + /** Selects public cards only; extended cards are never exposed through public discovery. */ + public @Nullable AgentCard resolvePublicDiscoveryCard(@Nullable String tenant) { + String normalizedTenant = normalizeTenantForResolution(tenant); + if (router != null && normalizedTenant != null) { + AgentCard card = router.resolvePublicCard(normalizedTenant); + if (card == null) { + throw new TenantNotFoundException(normalizedTenant); + } + return card; + } + AgentCard card = publicCard.get(); + if (card == null && router != null) { + card = router.resolvePublicCard(null); + } + return card; + } + + /** Selects a routed extended card for nonblank tenants and the default extended card otherwise. */ + public @Nullable AgentCard resolveExtendedDiscoveryCard(@Nullable String tenant) { + String normalizedTenant = normalizeTenantForResolution(tenant); + if (router != null && normalizedTenant != null) { + return router.resolveExtendedCard(normalizedTenant); + } + return extendedCard.get(); + } + + /** Validates a tenant and removes its optional boundary slashes; blank denotes the default tenant. */ + public static @Nullable String normalizeTenantForResolution(@Nullable String tenant) { + Utils.validateTenant(tenant); + if (tenant == null || tenant.isBlank()) { + return null; + } + String normalizedTenant = tenant; + if (normalizedTenant.startsWith("/")) { + normalizedTenant = normalizedTenant.substring(1); + } + if (normalizedTenant.endsWith("/")) { + normalizedTenant = normalizedTenant.substring(0, normalizedTenant.length() - 1); + } + return normalizedTenant.isEmpty() ? null : normalizedTenant; + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardTransportValidator.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardTransportValidator.java new file mode 100644 index 000000000..b215d0164 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardTransportValidator.java @@ -0,0 +1,9 @@ +package org.a2aproject.sdk.server; + +import org.a2aproject.sdk.spec.AgentCard; + +/** Validates a card using the current transport's configuration and class loader. */ +@FunctionalInterface +public interface AgentCardTransportValidator { + void validate(AgentCard card); +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java index 298ff90f9..26ef8d000 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/AgentCardValidator.java @@ -6,25 +6,19 @@ import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; -import java.util.function.Supplier; import java.util.logging.Logger; import java.util.stream.Collectors; -import jakarta.enterprise.inject.Instance; - -import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; -import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; -import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.TransportProtocol; -import org.jspecify.annotations.Nullable; /** * Validates AgentCard transport configuration against available transport endpoints. */ -public class AgentCardValidator { +public final class AgentCardValidator { private static final Logger LOGGER = Logger.getLogger(AgentCardValidator.class.getName()); @@ -37,197 +31,18 @@ public class AgentCardValidator { public static final String SKIP_GRPC_PROPERTY = "org.a2aproject.sdk.transport.grpc.skipValidation"; public static final String SKIP_REST_PROPERTY = "org.a2aproject.sdk.transport.rest.skipValidation"; - /** - * Creates a new thread-safe set for tracking which {@link AgentCard} instances have already - * been validated. Each distinct card is validated at most once; subsequent requests for the - * same card skip validation. - * - * @return a concurrent set suitable for use as the {@code validatedCards} parameter - */ - public static Set newValidatedCardsSet() { - return ConcurrentHashMap.newKeySet(); - } - - /** - * Resolves an {@link AgentCard} from the given {@link Instance} and validates its transport - * configuration once per distinct card using the default - * {@link #validateTransportConfiguration} check. On failure the card is removed from the - * set so validation can be retried on the next call. - * - * @param agentCardInstance the CDI instance holding the agent card - * @param validatedCards set tracking which cards have already been validated - * @return the resolved agent card - */ - public static AgentCard resolveAndValidateOnce(Instance agentCardInstance, - Set validatedCards) { - return resolveAndValidateOnce(agentCardInstance::get, validatedCards, - AgentCardValidator::validateTransportConfiguration); + /** Creates a cache whose entries represent completed successful validation. */ + public static ConcurrentMap newValidatedCardsMap() { + return new ConcurrentHashMap<>(); } - /** - * Obtains an {@link AgentCard} from the given supplier and applies the provided validator - * once per distinct card. On failure the card is removed from the set so validation can be - * retried on the next call. - * - * @param agentCardSupplier supplier that produces the agent card - * @param validatedCards set tracking which cards have already been validated - * @param validator validation logic to apply on first access - * @return the resolved agent card - */ - public static AgentCard resolveAndValidateOnce(Supplier agentCardSupplier, - Set validatedCards, + /** Waits for validation of the same card and leaves failures uncached so they can be retried. */ + public static void validateOnce(AgentCard card, ConcurrentMap cache, Consumer validator) { - AgentCard card = agentCardSupplier.get(); - if (validatedCards.add(card)) { - try { - validator.accept(card); - } catch (RuntimeException e) { - validatedCards.remove(card); - throw e; - } - } - return card; - } - - /** - * Resolves an agent card from the public instance, falling back to the extended instance - * if the public one is absent. Throws if neither is available. - * - * @param publicCard the CDI instance for the {@code @PublicAgentCard} - * @param extendedCard the CDI instance for the {@code @ExtendedAgentCard}, may be {@code null} - * @param validatedCards set tracking which cards have already been validated - * @return the resolved agent card - * @throws IllegalStateException if neither card is available - */ - public static AgentCard resolveWithFallback(Instance publicCard, - @Nullable Instance extendedCard, - Set validatedCards) { - return resolveWithFallback(publicCard, extendedCard, null, null, validatedCards); - } - - /** - * Convenience overload that uses the default {@link #validateTransportConfiguration} validator. - * - * @see #resolveWithFallback(Instance, Instance, AgentCardRouter, String, Set, Consumer) - */ - public static AgentCard resolveWithFallback(Instance publicCard, - @Nullable Instance extendedCard, - @Nullable AgentCardRouter agentCardRouter, - @Nullable String tenant, - Set validatedCards) { - return resolveWithFallback(publicCard, extendedCard, agentCardRouter, tenant, validatedCards, - AgentCardValidator::validateTransportConfiguration); - } - - /** - * Resolves an agent card using one of two resolution strategies depending on whether a - * tenant-scoped request is being made, and validates using the supplied validator. - * - * @param publicCard the CDI instance for the {@code @PublicAgentCard} - * @param extendedCard the CDI instance for the {@code @ExtendedAgentCard}, may be {@code null} - * @param agentCardRouter optional router for tenant-specific card resolution - * @param tenant the tenant identifier, may be {@code null} - * @param validatedCards set tracking which cards have already been validated - * @param validator validation logic to apply on first access of each distinct card - * @return the resolved agent card - * @throws TenantNotFoundException if a non-blank tenant is specified, a router is available, - * but neither a public nor extended card is registered for that tenant - * @throws IllegalStateException if no card can be resolved (non-tenant-scoped path) - * @see #resolveTenantScoped(AgentCardRouter, String, Set, Consumer) - * @see #resolveDefaultWithRouterFallback(Instance, Instance, AgentCardRouter, String, Set, Consumer) - */ - public static AgentCard resolveWithFallback(Instance publicCard, - @Nullable Instance extendedCard, - @Nullable AgentCardRouter agentCardRouter, - @Nullable String tenant, - Set validatedCards, - Consumer validator) { - if (tenant != null && !tenant.isBlank() && agentCardRouter != null) { - return resolveTenantScoped(agentCardRouter, tenant, validatedCards, validator); - } - return resolveDefaultWithRouterFallback(publicCard, extendedCard, agentCardRouter, tenant, - validatedCards, validator); - } - - /** - * Tenant-scoped resolution: only the {@link AgentCardRouter} is consulted. CDI default beans - * are not used as fallbacks — doing so would let the request proceed against the - * wrong tenant's card. - *
    - *
  1. Tenant-specific public card via the router
  2. - *
  3. Tenant-specific extended card via the router
  4. - *
  5. {@link TenantNotFoundException} if neither is registered
  6. - *
- */ - private static AgentCard resolveTenantScoped(AgentCardRouter agentCardRouter, String tenant, - Set validatedCards, Consumer validator) { - AgentCard routerCard = agentCardRouter.resolvePublicCard(tenant); - if (routerCard != null) { - return resolveAndValidateOnce(() -> routerCard, validatedCards, validator); - } - AgentCard routerExtCard = agentCardRouter.resolveExtendedCard(tenant); - if (routerExtCard != null) { - return resolveAndValidateOnce(() -> routerExtCard, validatedCards, validator); - } - throw new TenantNotFoundException(tenant); - } - - /** - * Non-tenant-scoped resolution with optional router fallback: - *
    - *
  1. Unqualified {@code @PublicAgentCard} CDI bean
  2. - *
  3. Router's public card (when no default bean exists)
  4. - *
  5. Unqualified {@code @ExtendedAgentCard} CDI bean
  6. - *
  7. Router's extended card (when no default bean exists)
  8. - *
  9. {@link IllegalStateException} if nothing resolves
  10. - *
- */ - private static AgentCard resolveDefaultWithRouterFallback(Instance publicCard, - @Nullable Instance extendedCard, - @Nullable AgentCardRouter agentCardRouter, - @Nullable String tenant, - Set validatedCards, - Consumer validator) { - AgentCard resolved = CdiUtils.resolveDefault(publicCard); - if (resolved != null) { - return resolveAndValidateOnce(() -> resolved, validatedCards, validator); - } - if (agentCardRouter != null) { - AgentCard routerCard = agentCardRouter.resolvePublicCard(tenant); - if (routerCard != null) { - return resolveAndValidateOnce(() -> routerCard, validatedCards, validator); - } - } - AgentCard extResolved = CdiUtils.resolveDefault(extendedCard); - if (extResolved != null) { - return resolveAndValidateOnce(() -> extResolved, validatedCards, validator); - } - if (agentCardRouter != null) { - AgentCard routerExtCard = agentCardRouter.resolveExtendedCard(tenant); - if (routerExtCard != null) { - return resolveAndValidateOnce(() -> routerExtCard, validatedCards, validator); - } - } - throw new IllegalStateException(NO_AGENT_CARD_MESSAGE); - } - - /** - * Returns the first non-null agent card, preferring the public card. - * Throws if both are {@code null}. - * - * @param publicCard the public agent card, may be {@code null} - * @param extendedCard the extended agent card, may be {@code null} - * @return the first non-null card - * @throws IllegalStateException if both cards are {@code null} - */ - public static AgentCard requireFirst(@Nullable AgentCard publicCard, @Nullable AgentCard extendedCard) { - if (publicCard != null) { - return publicCard; - } - if (extendedCard != null) { - return extendedCard; - } - throw new IllegalStateException(NO_AGENT_CARD_MESSAGE); + cache.computeIfAbsent(card, selected -> { + validator.accept(selected); + return Boolean.TRUE; + }); } /** diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/CachingAgentCardTransportValidator.java b/server-common/src/main/java/org/a2aproject/sdk/server/CachingAgentCardTransportValidator.java new file mode 100644 index 000000000..c840ae2c4 --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/CachingAgentCardTransportValidator.java @@ -0,0 +1,22 @@ +package org.a2aproject.sdk.server; + +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; + +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.util.Assert; + +/** Caches successful validation within one transport configuration. */ +public final class CachingAgentCardTransportValidator implements AgentCardTransportValidator { + private final ConcurrentMap validatedCards = AgentCardValidator.newValidatedCardsMap(); + private final Consumer validator; + + public CachingAgentCardTransportValidator(Consumer validator) { + this.validator = Assert.checkNotNullParam("validator", validator); + } + + @Override + public void validate(AgentCard card) { + AgentCardValidator.validateOnce(card, validatedCards, validator); + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/EffectiveAgentCard.java b/server-common/src/main/java/org/a2aproject/sdk/server/EffectiveAgentCard.java new file mode 100644 index 000000000..f5dbb9b6c --- /dev/null +++ b/server-common/src/main/java/org/a2aproject/sdk/server/EffectiveAgentCard.java @@ -0,0 +1,12 @@ +package org.a2aproject.sdk.server; + +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** The canonical tenant and Agent Card selected for a request. */ +public record EffectiveAgentCard(@Nullable String tenant, AgentCard card) { + public EffectiveAgentCard { + Assert.checkNotNullParam("card", card); + } +} diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/ServerCallContext.java b/server-common/src/main/java/org/a2aproject/sdk/server/ServerCallContext.java index 2dfccf4cc..7cf0acae4 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/ServerCallContext.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/ServerCallContext.java @@ -2,13 +2,16 @@ import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.a2aproject.sdk.server.auth.User; +import org.a2aproject.sdk.spec.AgentCard; import org.jspecify.annotations.Nullable; public class ServerCallContext { + private static final String EFFECTIVE_AGENT_CARD_KEY = ServerCallContext.class.getName() + ".effectiveAgentCard"; /** * Key for transport protocol type in the state map. * Value should be a {@link org.a2aproject.sdk.spec.TransportProtocol} instance. @@ -55,6 +58,29 @@ public Map getState() { return state; } + /** Atomically attaches a card; only the same canonical tenant and card instance may be reattached. */ + public void attachEffectiveAgentCard(@Nullable String tenant, AgentCard card) { + String normalizedTenant = AgentCardResolver.normalizeTenantForResolution(tenant); + state.compute(EFFECTIVE_AGENT_CARD_KEY, (key, oldValue) -> { + EffectiveAgentCard next = new EffectiveAgentCard(normalizedTenant, card); + if (oldValue == null) { + return next; + } + if (oldValue instanceof EffectiveAgentCard current + && Objects.equals(current.tenant(), next.tenant()) + && current.card() == next.card()) { + return current; + } + throw new IllegalStateException("Conflicting effective Agent Card"); + }); + } + + /** Returns the v1 effective card, or null when no request policy has attached one. */ + public @Nullable EffectiveAgentCard getEffectiveAgentCard() { + Object value = state.get(EFFECTIVE_AGENT_CARD_KEY); + return value instanceof EffectiveAgentCard card ? card : null; + } + public User getUser() { return user; } diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java index 3a669ae7a..ebda76a1c 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java @@ -28,6 +28,7 @@ import jakarta.inject.Inject; import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.EffectiveAgentCard; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; @@ -64,6 +65,7 @@ import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; @@ -503,6 +505,7 @@ public DefaultRequestHandler build() { @Override public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "id", params.id()); LOGGER.debug("onGetTask {}", params.id()); enforceRead(context, params.id(), TaskOperation.GET_TASK); Task task = taskStore.get(params.id()); @@ -652,6 +655,7 @@ public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext con @Override public Task onCancelTask(CancelTaskParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "id", params.id()); enforceWrite(context, params.id(), TaskOperation.CANCEL_TASK); // Serialize check-then-act per task so two concurrent cancels (or a cancel racing // a concurrent completion) cannot both act on the pre-transition state. @@ -752,6 +756,7 @@ private Task doCancelTask(CancelTaskParams params, ServerCallContext context) th @SuppressWarnings("NullAway") public EventKind onMessageSend(MessageSendParams rawParams, ServerCallContext context) throws A2AError { MessageSendParams params = normalizeBlankIds(rawParams); + validateEmbeddedPushConfig(params, context); LOGGER.debug("onMessageSend - task: {}; context {}", params.message().taskId(), params.message().contextId()); String msgTaskId = params.message().taskId(); if (msgTaskId != null) { @@ -965,6 +970,7 @@ public EventKind onMessageSend(MessageSendParams rawParams, ServerCallContext co public Flow.Publisher onMessageSendStream( MessageSendParams rawParams, ServerCallContext context) throws A2AError { MessageSendParams params = normalizeBlankIds(rawParams); + validateEmbeddedPushConfig(params, context); LOGGER.debug("onMessageSendStream START - task: {}; context: {}; runningAgents: {}", params.message().taskId(), params.message().contextId(), runningAgents.size()); String msgTaskId = params.message().taskId(); @@ -1129,6 +1135,7 @@ public void onComplete() { @Override public TaskPushNotificationConfig onCreateTaskPushNotificationConfig( TaskPushNotificationConfig params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "taskId", params.taskId()); if (pushConfigStore == null || !pushNotificationsEnabled) { throw new UnsupportedOperationError(); } @@ -1149,6 +1156,8 @@ public TaskPushNotificationConfig onCreateTaskPushNotificationConfig( @Override public TaskPushNotificationConfig onGetTaskPushNotificationConfig( GetTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "taskId", params.taskId()); + validateRequiredId(context, "id", params.id()); if (pushConfigStore == null) { throw new UnsupportedOperationError(); } @@ -1164,7 +1173,8 @@ public TaskPushNotificationConfig onGetTaskPushNotificationConfig( } String requestedConfigId = params.id(); - String configId = requestedConfigId == null || requestedConfigId.isEmpty() ? params.taskId() : requestedConfigId; + String configId = Assert.isNullOrEmpty(requestedConfigId) ? params.taskId() + : Assert.checkNotNullParam("requestedConfigId", requestedConfigId); return getTaskPushNotificationConfig(listTaskPushNotificationConfigsResult, configId); } @@ -1180,6 +1190,7 @@ private TaskPushNotificationConfig getTaskPushNotificationConfig(ListTaskPushNot @Override public Flow.Publisher onSubscribeToTask(TaskIdParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "id", params.id()); LOGGER.debug("onSubscribeToTask - taskId: {}", params.id()); enforceRead(context, params.id(), TaskOperation.SUBSCRIBE_TO_TASK); Task task = taskStore.get(params.id()); @@ -1228,6 +1239,7 @@ public Flow.Publisher onSubscribeToTask(TaskIdParams params, @Override public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs( ListTaskPushNotificationConfigsParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "id", params.id()); if (pushConfigStore == null) { throw new UnsupportedOperationError(); } @@ -1242,6 +1254,8 @@ public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs( @Override public void onDeleteTaskPushNotificationConfig( DeleteTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError { + validateRequiredId(context, "taskId", params.taskId()); + validateRequiredId(context, "id", params.id()); if (pushConfigStore == null) { throw new UnsupportedOperationError(); } @@ -1255,6 +1269,22 @@ public void onDeleteTaskPushNotificationConfig( pushConfigStore.deleteInfo(params.taskId(), params.id()); } + private static void validateRequiredId(@Nullable ServerCallContext context, String parameter, @Nullable String id) { + if (context != null && context.getEffectiveAgentCard() != null && (id == null || id.isBlank())) { + throw new InvalidParamsError(parameter + " is required"); + } + } + + private void validateEmbeddedPushConfig(MessageSendParams params, @Nullable ServerCallContext context) { + EffectiveAgentCard effectiveCard = context != null ? context.getEffectiveAgentCard() : null; + if (effectiveCard != null && params.configuration() != null + && params.configuration().taskPushNotificationConfig() != null + && (!pushNotificationsEnabled || pushConfigStore == null + || !effectiveCard.card().capabilities().pushNotifications())) { + throw new PushNotificationNotSupportedError(); + } + } + private boolean shouldAddPushInfo(MessageSendParams params) { return pushNotificationsEnabled && pushConfigStore != null @@ -1525,8 +1555,12 @@ public void authorizeTaskAccess(@Nullable String requestedTaskId, ServerCallCont if (requestedTaskId == null || requestedTaskId.isEmpty()) { return; } - enforceRead(context, requestedTaskId, operation); - Task task = taskStore.get(requestedTaskId); + String taskId = Assert.checkNotNullParam("requestedTaskId", requestedTaskId); + if (taskId.isBlank()) { + throw new InvalidParamsError("taskId is required"); + } + enforceRead(context, taskId, operation); + Task task = taskStore.get(taskId); if (task == null) { throw new TaskNotFoundError(); } diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardRequestPolicyTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardRequestPolicyTest.java new file mode 100644 index 000000000..11b166719 --- /dev/null +++ b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardRequestPolicyTest.java @@ -0,0 +1,197 @@ +package org.a2aproject.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.a2aproject.sdk.server.auth.UnauthenticatedUser; +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.ExtensionSupportRequiredError; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.a2aproject.sdk.spec.VersionNotSupportedError; +import org.junit.jupiter.api.Test; + +class AgentCardRequestPolicyTest { + + @Test + void operationRequiresFailureForAnUnsatisfiedCapability() { + assertThrows(IllegalArgumentException.class, + () -> new AgentCardOperation(card -> false, null)); + } + + @Test + void sharedOperationsMatchTheirAgentCardCapabilities() { + AgentCard capabilitiesDisabled = AgentCardResolverTest.card("disabled"); + AgentCard capabilitiesEnabled = AgentCardResolverTest.card("enabled", AgentCapabilities.builder() + .streaming(true).pushNotifications(true).extendedAgentCard(true).build()); + + assertEquals(false, AgentCardOperation.STREAMING.capability().test(capabilitiesDisabled)); + assertEquals(true, AgentCardOperation.STREAMING.capability().test(capabilitiesEnabled)); + assertInstanceOf(UnsupportedOperationError.class, AgentCardOperation.STREAMING.capabilityFailure().get()); + + assertEquals(false, AgentCardOperation.PUSH_NOTIFICATIONS.capability().test(capabilitiesDisabled)); + assertEquals(true, AgentCardOperation.PUSH_NOTIFICATIONS.capability().test(capabilitiesEnabled)); + assertInstanceOf(PushNotificationNotSupportedError.class, + AgentCardOperation.PUSH_NOTIFICATIONS.capabilityFailure().get()); + + assertEquals(false, AgentCardOperation.EXTENDED_CARD.capability().test(capabilitiesDisabled)); + assertEquals(true, AgentCardOperation.EXTENDED_CARD.capability().test(capabilitiesEnabled)); + assertInstanceOf(UnsupportedOperationError.class, AgentCardOperation.EXTENDED_CARD.capabilityFailure().get()); + } + + @Test + void preflightValidatesBeforeCapabilityAndAttachesCanonicalCard() { + AgentCard card = AgentCardResolverTest.card("public"); + List calls = new ArrayList<>(); + AgentCardRequestPolicy policy = new AgentCardRequestPolicy(AgentCardResolver.of(() -> { + calls.add("resolve"); + return card; + }, () -> null, null)); + ServerCallContext context = context("1.0", Set.of()); + EffectiveAgentCard effective = policy.preflight("/acme/", context, selected -> { + assertSame(card, selected); + assertNull(context.getEffectiveAgentCard()); + calls.add("validate"); + }, new AgentCardOperation(selected -> { + assertNull(context.getEffectiveAgentCard()); + calls.add("capability"); + return true; + }, UnsupportedOperationError::new)); + + assertEquals(List.of("resolve", "validate", "capability"), calls); + assertEquals("acme", effective.tenant()); + assertSame(card, effective.card()); + assertSame(effective, context.getEffectiveAgentCard()); + } + + @Test + void invalidTenantWinsBeforeResolutionTransportAndVersionValidation() { + AgentCardRequestPolicy policy = new AgentCardRequestPolicy(AgentCardResolver.of(() -> { + throw new AssertionError("must not resolve an invalid tenant"); + }, () -> null, null)); + ServerCallContext context = context("2.0", Set.of()); + InvalidParamsError failure = assertThrows(InvalidParamsError.class, + () -> policy.preflight("acme/other", context, card -> { + throw new AssertionError("must not validate an invalid tenant"); + }, AgentCardOperation.none())); + assertEquals("Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed", failure.getMessage()); + assertNull(context.getEffectiveAgentCard()); + assertThrows(InvalidParamsError.class, + () -> policy.resolveAndValidateCapability("bad tenant", card -> {}, AgentCardOperation.none())); + assertThrows(InvalidParamsError.class, + () -> policy.validateContextAndAttach(AgentCardResolverTest.card("public"), "bad tenant", context)); + } + + @Test + void unknownTenantRemainsDistinctFromInvalidParams() { + AgentCardRouter router = tenant -> null; + AgentCardRequestPolicy policy = new AgentCardRequestPolicy(AgentCardResolver.of( + () -> AgentCardResolverTest.card("default"), () -> null, router)); + assertThrows(TenantNotFoundException.class, + () -> policy.preflight("unknown", context("1.0", Set.of()), card -> {}, AgentCardOperation.none())); + } + + @Test + void transportFailureWinsOverVersionAndLeavesContextUnattached() { + AgentCardRequestPolicy policy = policy(AgentCardResolverTest.card("public")); + ServerCallContext context = context("2.0", Set.of()); + IllegalStateException failure = new IllegalStateException("invalid transport"); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> policy.preflight(null, context, card -> { throw failure; }, AgentCardOperation.none()))); + assertNull(context.getEffectiveAgentCard()); + } + + @Test + void versionWinsOverRequiredExtensionAndCapability() { + AgentCardRequestPolicy policy = policy(cardWithRequiredExtension()); + ServerCallContext context = context("2.0", Set.of()); + assertThrows(VersionNotSupportedError.class, + () -> policy.preflight(null, context, card -> {}, disabledOperation())); + assertNull(context.getEffectiveAgentCard()); + } + + @Test + void missingExtensionWinsOverCapability() { + AgentCardRequestPolicy policy = policy(cardWithRequiredExtension()); + ServerCallContext context = context("1.0", Set.of()); + assertThrows(ExtensionSupportRequiredError.class, + () -> policy.preflight(null, context, card -> {}, disabledOperation())); + assertNull(context.getEffectiveAgentCard()); + } + + @Test + void requiredExtensionAllowsSuccessfulAttachmentWhenRequested() { + AgentCard card = cardWithRequiredExtension(); + ServerCallContext context = context("1.0", Set.of("urn:test:required")); + EffectiveAgentCard effective = policy(card).preflight(" ", context, selected -> {}, AgentCardOperation.none()); + assertNull(effective.tenant()); + assertSame(card, effective.card()); + } + + @Test + void capabilityUsesExactTransportFailureFactoriesWithoutAttaching() { + for (A2AError failure : List.of(new UnsupportedOperationError(null, "stream disabled", null), + new PushNotificationNotSupportedError(), new UnsupportedOperationError())) { + ServerCallContext context = context("1.0", Set.of()); + AgentCardOperation operation = new AgentCardOperation(card -> false, () -> failure); + assertSame(failure, assertThrows(A2AError.class, + () -> policy(AgentCardResolverTest.card("public")).preflight(null, context, card -> {}, operation))); + assertNull(context.getEffectiveAgentCard()); + } + } + + @Test + void splitPreflightValidatesCapabilityBeforeContextAndDoesNotResolveAgain() { + AgentCard card = AgentCardResolverTest.card("public"); + List calls = new ArrayList<>(); + AgentCardRequestPolicy policy = new AgentCardRequestPolicy(AgentCardResolver.of(() -> { + calls.add("resolve"); + return card; + }, () -> null, null)); + assertThrows(PushNotificationNotSupportedError.class, + () -> policy.resolveAndValidateCapability(null, selected -> calls.add("validate"), disabledOperation())); + assertEquals(List.of("resolve", "validate"), calls); + calls.clear(); + AgentCard selected = policy.resolveAndValidateCapability("/acme/", ignored -> calls.add("validate"), + AgentCardOperation.none()); + ServerCallContext invalid = context("2.0", Set.of()); + assertThrows(VersionNotSupportedError.class, () -> policy.validateContextAndAttach(selected, "/acme/", invalid)); + assertNull(invalid.getEffectiveAgentCard()); + ServerCallContext valid = context("1.0", Set.of()); + EffectiveAgentCard effective = policy.validateContextAndAttach(selected, "/acme/", valid); + assertEquals("acme", effective.tenant()); + assertSame(card, effective.card()); + assertEquals(List.of("resolve", "validate"), calls); + } + + private static AgentCardRequestPolicy policy(AgentCard card) { + return new AgentCardRequestPolicy(AgentCardResolver.of(() -> card, () -> null, null)); + } + + private static AgentCardOperation disabledOperation() { + return new AgentCardOperation(card -> false, PushNotificationNotSupportedError::new); + } + + private static AgentCard cardWithRequiredExtension() { + return AgentCardResolverTest.card("required-extension", AgentCapabilities.builder().extensions(List.of( + new AgentExtension(null, null, true, "urn:test:required"))).build()); + } + + private static ServerCallContext context(String version, Set extensions) { + return new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of(), extensions, version); + } +} diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardResolverTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardResolverTest.java new file mode 100644 index 000000000..01640b772 --- /dev/null +++ b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardResolverTest.java @@ -0,0 +1,173 @@ +package org.a2aproject.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; +import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class AgentCardResolverTest { + + @Test + void effectiveDefaultFallbackKeepsExactSupplierAndRouterOrder() { + AgentCard[] cards = {card("default-public"), card("router-public"), + card("default-extended"), card("router-extended")}; + List order = List.of("default-public", "router-public:null", "default-extended", "router-extended:null"); + for (int selected = 0; selected < cards.length; selected++) { + int index = selected; + List calls = new ArrayList<>(); + AgentCardResolver resolver = AgentCardResolver.of(() -> { + calls.add("default-public"); + return index == 0 ? cards[0] : null; + }, () -> { + calls.add("default-extended"); + return index == 2 ? cards[2] : null; + }, new AgentCardRouter() { + @Override + public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { + calls.add("router-public:" + tenant); + return index == 1 ? cards[1] : null; + } + + @Override + public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { + calls.add("router-extended:" + tenant); + return index == 3 ? cards[3] : null; + } + }); + + assertSame(cards[index], resolver.resolveEffectiveCard(" ")); + assertEquals(order.subList(0, index + 1), calls); + } + } + + @Test + void routedTenantUsesOnlyItsPublicThenExtendedCards() { + AgentCard defaultPublic = card("default-public"); + AgentCard defaultExtended = card("default-extended"); + AgentCard routedPublic = card("routed-public"); + AgentCard routedExtended = card("routed-extended"); + AgentCardResolver resolver = AgentCardResolver.of(() -> defaultPublic, () -> defaultExtended, + router(routedPublic, routedExtended)); + assertSame(routedPublic, resolver.resolveEffectiveCard("/acme/")); + assertSame(routedPublic, resolver.resolvePublicDiscoveryCard("/acme/")); + assertSame(routedExtended, resolver.resolveExtendedDiscoveryCard("/acme/")); + + AgentCardResolver extendedOnly = AgentCardResolver.of(() -> defaultPublic, () -> defaultExtended, + router(null, routedExtended)); + assertSame(routedExtended, extendedOnly.resolveEffectiveCard("acme")); + assertThrows(TenantNotFoundException.class, () -> extendedOnly.resolvePublicDiscoveryCard("acme")); + } + + @Test + void unknownRoutedTenantDoesNotFallBackToDefaultCards() { + AgentCardResolver resolver = AgentCardResolver.of(() -> card("default-public"), + () -> card("default-extended"), router(null, null)); + TenantNotFoundException failure = assertThrows(TenantNotFoundException.class, + () -> resolver.resolveEffectiveCard("unknown")); + assertEquals("unknown", failure.getTenant()); + assertThrows(TenantNotFoundException.class, () -> resolver.resolvePublicDiscoveryCard("unknown")); + assertNull(resolver.resolveExtendedDiscoveryCard("unknown")); + } + + @Test + void noRouterAllowsNonblankTenantToUseDefaultCards() { + AgentCard publicCard = card("public"); + AgentCard extendedCard = card("extended"); + AgentCardResolver resolver = AgentCardResolver.of(() -> publicCard, () -> extendedCard, null); + assertSame(publicCard, resolver.resolveEffectiveCard("acme")); + assertSame(publicCard, resolver.resolvePublicDiscoveryCard("acme")); + assertSame(extendedCard, resolver.resolveExtendedDiscoveryCard("acme")); + + AgentCardResolver extendedOnly = AgentCardResolver.of(() -> null, () -> extendedCard, null); + assertSame(extendedCard, extendedOnly.resolveEffectiveCard("acme")); + assertNull(extendedOnly.resolvePublicDiscoveryCard("acme")); + } + + @Test + void defaultDiscoveryUsesOnlyTheCorrectCardKind() { + AgentCard publicCard = card("public"); + AgentCard extendedCard = card("extended"); + AgentCard routedPublic = card("routed-public"); + AgentCard routedExtended = card("routed-extended"); + AgentCardResolver resolver = AgentCardResolver.of(() -> publicCard, () -> extendedCard, + router(routedPublic, routedExtended)); + assertSame(publicCard, resolver.resolvePublicDiscoveryCard(null)); + assertSame(extendedCard, resolver.resolveExtendedDiscoveryCard(null)); + + AgentCardResolver routedOnly = AgentCardResolver.of(() -> null, () -> null, + router(routedPublic, routedExtended)); + assertSame(routedPublic, routedOnly.resolvePublicDiscoveryCard("/")); + assertNull(routedOnly.resolveExtendedDiscoveryCard(null)); + + AgentCardResolver extendedOnly = AgentCardResolver.of(() -> null, () -> extendedCard, + router(null, routedExtended)); + assertNull(extendedOnly.resolvePublicDiscoveryCard(null)); + } + + @Test + void absentCardsFailEffectiveResolutionButNotDiscovery() { + for (AgentCardRouter router : new AgentCardRouter[] {null, router(null, null)}) { + AgentCardResolver resolver = AgentCardResolver.of(() -> null, () -> null, router); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> resolver.resolveEffectiveCard(null)); + assertEquals(AgentCardValidator.NO_AGENT_CARD_MESSAGE, failure.getMessage()); + assertNull(resolver.resolvePublicDiscoveryCard(null)); + assertNull(resolver.resolveExtendedDiscoveryCard(null)); + } + } + + @Test + void canonicalTenantNormalizesDefaultSpellingsAndPermittedSlashes() { + for (String tenant : new String[] {null, "", " ", "/", "//"}) { + assertNull(AgentCardResolver.normalizeTenantForResolution(tenant)); + } + for (String tenant : List.of("acme", "/acme", "acme/", "/acme/")) { + assertEquals("acme", AgentCardResolver.normalizeTenantForResolution(tenant)); + } + assertThrows(IllegalArgumentException.class, + () -> AgentCardResolver.normalizeTenantForResolution("acme/other")); + assertThrows(IllegalArgumentException.class, + () -> AgentCardResolver.normalizeTenantForResolution(" acme ")); + assertThrows(IllegalArgumentException.class, + () -> AgentCardResolver.normalizeTenantForResolution("a".repeat(257))); + } + + static AgentCard card(String name) { + return card(name, AgentCapabilities.builder().build()); + } + + static AgentCard card(String name, AgentCapabilities capabilities) { + return AgentCard.builder().name(name).description("Test Agent") + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.JSONRPC.asString(), + "http://localhost:9999", null, "1.0"))) + .version("1.0.0").capabilities(capabilities) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()).build(); + } + + private static AgentCardRouter router(@Nullable AgentCard publicCard, @Nullable AgentCard extendedCard) { + return new AgentCardRouter() { + @Override + public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { + return publicCard; + } + + @Override + public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { + return extendedCard; + } + }; + } +} diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardValidatorTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardValidatorTest.java index 249438b10..bd8960571 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardValidatorTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/AgentCardValidatorTest.java @@ -9,7 +9,15 @@ import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import java.util.logging.Handler; import java.util.logging.LogRecord; import java.util.logging.Logger; @@ -25,6 +33,83 @@ public class AgentCardValidatorTest { + @Test + void cachingValidatorWaitsForSuccessfulValidationBeforeReturning() throws Exception { + AgentCard card = createTestAgentCardBuilder().build(); + CountDownLatch validating = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicInteger invocations = new AtomicInteger(); + CachingAgentCardTransportValidator validator = new CachingAgentCardTransportValidator(selected -> { + invocations.incrementAndGet(); + validating.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> validator.validate(card)); + assertTrue(validating.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondStarted.countDown(); + validator.validate(card); + }); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> second.get(100, TimeUnit.MILLISECONDS)); + release.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(1, invocations.get()); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void failedValidationLeavesNoCacheEntryAndRetriesSuccessfully() { + AgentCard card = createTestAgentCardBuilder().build(); + ConcurrentMap cache = AgentCardValidator.newValidatedCardsMap(); + AtomicInteger invocations = new AtomicInteger(); + Consumer validator = selected -> { + if (invocations.incrementAndGet() == 1) { + throw new IllegalStateException("retry validation"); + } + }; + assertThrows(IllegalStateException.class, () -> AgentCardValidator.validateOnce(card, cache, validator)); + assertFalse(cache.containsKey(card)); + AgentCardValidator.validateOnce(card, cache, validator); + AgentCardValidator.validateOnce(card, cache, validator); + assertEquals(Boolean.TRUE, cache.get(card)); + assertEquals(2, invocations.get()); + } + + @Test + void cachingValidatorsOwnIndependentCachesAndRetryFailures() { + AgentCard card = createTestAgentCardBuilder().build(); + AtomicInteger firstCalls = new AtomicInteger(); + AtomicInteger secondCalls = new AtomicInteger(); + CachingAgentCardTransportValidator first = new CachingAgentCardTransportValidator(selected -> { + if (firstCalls.incrementAndGet() == 1) { + throw new IllegalStateException("retry"); + } + }); + CachingAgentCardTransportValidator second = new CachingAgentCardTransportValidator( + selected -> secondCalls.incrementAndGet()); + assertThrows(IllegalStateException.class, () -> first.validate(card)); + first.validate(card); + first.validate(card); + second.validate(card); + second.validate(card); + first.validate(createTestAgentCardBuilder().name("distinct").build()); + assertEquals(3, firstCalls.get()); + assertEquals(1, secondCalls.get()); + } + private AgentCard.Builder createTestAgentCardBuilder() { return AgentCard.builder() .name("Test Agent") @@ -234,348 +319,6 @@ void testSkipPropertiesFilterWarnings() { } } - @Test - void resolveAndValidateOnceRetriesAfterFailure() { - AgentCard card = createTestAgentCardBuilder().build(); - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - AtomicInteger validationCount = new AtomicInteger(0); - - assertThrows(IllegalStateException.class, () -> - AgentCardValidator.resolveAndValidateOnce( - () -> card, validatedCards, c -> { - validationCount.incrementAndGet(); - throw new IllegalStateException("transient failure"); - })); - - assertFalse(validatedCards.contains(card), "card should be removed after failure"); - assertEquals(1, validationCount.get()); - - AgentCard result = AgentCardValidator.resolveAndValidateOnce( - () -> card, validatedCards, c -> validationCount.incrementAndGet()); - - assertTrue(validatedCards.contains(card), "card should be in set after success"); - assertEquals(2, validationCount.get()); - assertEquals(card, result); - } - - @Test - void resolveAndValidateOnceSkipsValidationOnSubsequentCalls() { - AgentCard card = createTestAgentCardBuilder().build(); - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - AtomicInteger validationCount = new AtomicInteger(0); - - AgentCardValidator.resolveAndValidateOnce( - () -> card, validatedCards, c -> validationCount.incrementAndGet()); - AgentCardValidator.resolveAndValidateOnce( - () -> card, validatedCards, c -> validationCount.incrementAndGet()); - - assertEquals(1, validationCount.get(), "validation should run only once for the same card"); - } - - @Test - void resolveAndValidateOnceValidatesEachDistinctCard() { - AgentCard card1 = createTestAgentCardBuilder().name("card-1").build(); - AgentCard card2 = createTestAgentCardBuilder().name("card-2").build(); - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - AtomicInteger validationCount = new AtomicInteger(0); - - AgentCardValidator.resolveAndValidateOnce( - () -> card1, validatedCards, c -> validationCount.incrementAndGet()); - AgentCardValidator.resolveAndValidateOnce( - () -> card2, validatedCards, c -> validationCount.incrementAndGet()); - - assertEquals(2, validationCount.get(), "validation should run once per distinct card"); - assertTrue(validatedCards.contains(card1)); - assertTrue(validatedCards.contains(card2)); - } - - @Test - void resolveWithFallbackUsesPublicCardWhenPresent() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard publicCard = createTestAgentCardBuilder().name("public").build(); - AgentCard extendedCard = createTestAgentCardBuilder().name("extended").build(); - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - new FixedInstance<>(publicCard), new FixedInstance<>(extendedCard), validatedCards); - - assertEquals("public", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackFallsBackToExtendedCard() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard extendedCard = createTestAgentCardBuilder().name("extended").build(); - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), new FixedInstance<>(extendedCard), validatedCards); - - assertEquals("extended", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackThrowsWhenBothAbsent() { - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> - AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), FixedInstance.empty(), validatedCards)); - - assertEquals(AgentCardValidator.NO_AGENT_CARD_MESSAGE, ex.getMessage()); - } - - @Test - void resolveWithFallbackThrowsWhenExtendedIsNull() { - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> - AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), null, validatedCards)); - - assertEquals(AgentCardValidator.NO_AGENT_CARD_MESSAGE, ex.getMessage()); - } - - @Test - void requireFirstReturnsPublicCard() { - AgentCard publicCard = createTestAgentCardBuilder().name("public").build(); - AgentCard extendedCard = createTestAgentCardBuilder().name("extended").build(); - - assertEquals("public", AgentCardValidator.requireFirst(publicCard, extendedCard).name()); - } - - @Test - void requireFirstReturnsExtendedWhenPublicIsNull() { - AgentCard extendedCard = createTestAgentCardBuilder().name("extended").build(); - - assertEquals("extended", AgentCardValidator.requireFirst(null, extendedCard).name()); - } - - @Test - void requireFirstThrowsWhenBothNull() { - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> - AgentCardValidator.requireFirst(null, null)); - - assertEquals(AgentCardValidator.NO_AGENT_CARD_MESSAGE, ex.getMessage()); - } - - @Test - void resolveWithFallbackUsesRouterWhenNoDefaultBean() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard routerCard = createTestAgentCardBuilder().name("router-card").build(); - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return routerCard; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), FixedInstance.empty(), router, "tenant-1", validatedCards); - - assertEquals("router-card", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackPrefersTenantCardOverDefaultBean() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard publicCard = createTestAgentCardBuilder().name("default").build(); - AgentCard routerCard = createTestAgentCardBuilder().name("tenant-card").build(); - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return "tenant-1".equals(tenant) ? routerCard : null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - new FixedInstance<>(publicCard), FixedInstance.empty(), router, "tenant-1", validatedCards); - - assertEquals("tenant-card", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackPrefersDefaultBeanOverRouterWithoutTenant() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard publicCard = createTestAgentCardBuilder().name("default").build(); - AgentCard routerCard = createTestAgentCardBuilder().name("router-default").build(); - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return routerCard; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - new FixedInstance<>(publicCard), FixedInstance.empty(), router, null, validatedCards); - - assertEquals("default", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackPrefersTenantPublicCardOverDefaultExtendedCard() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard defaultExtended = createTestAgentCardBuilder().name("default-extended").build(); - AgentCard tenantPublic = createTestAgentCardBuilder().name("tenant-public").build(); - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return "tenant-1".equals(tenant) ? tenantPublic : null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), new FixedInstance<>(defaultExtended), router, "tenant-1", validatedCards); - - assertEquals("tenant-public", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackThrowsForUnknownTenantEvenWhenDefaultExtendedExists() { - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - AgentCard defaultExtended = createTestAgentCardBuilder().name("default-extended").build(); - - TenantNotFoundException ex = assertThrows(TenantNotFoundException.class, () -> - AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), new FixedInstance<>(defaultExtended), router, "unknown-tenant", - validatedCards)); - - assertEquals("unknown-tenant", ex.getTenant()); - } - - @Test - void resolveWithFallbackThrowsForUnknownTenantEvenWhenDefaultPublicExists() { - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - AgentCard publicCard = createTestAgentCardBuilder().name("default").build(); - - TenantNotFoundException ex = assertThrows(TenantNotFoundException.class, () -> - AgentCardValidator.resolveWithFallback( - new FixedInstance<>(publicCard), FixedInstance.empty(), router, "unknown-tenant", - validatedCards)); - - assertEquals("unknown-tenant", ex.getTenant()); - } - - @Test - void resolveWithFallbackUsesRouterExtendedCardWhenPublicCardIsNull() { - System.setProperty(AgentCardValidator.SKIP_PROPERTY, "true"); - try { - AgentCard extCard = createTestAgentCardBuilder().name("router-extended").build(); - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return extCard; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - AgentCard result = AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), FixedInstance.empty(), router, "tenant-1", validatedCards); - - assertEquals("router-extended", result.name()); - } finally { - System.clearProperty(AgentCardValidator.SKIP_PROPERTY); - } - } - - @Test - void resolveWithFallbackThrowsWhenRouterReturnsNull() { - AgentCardRouter router = new AgentCardRouter() { - @Override - public @Nullable AgentCard resolveExtendedCard(@Nullable String tenant) { - return null; - } - - @Override - public @Nullable AgentCard resolvePublicCard(@Nullable String tenant) { - return null; - } - }; - Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - TenantNotFoundException ex = assertThrows(TenantNotFoundException.class, () -> - AgentCardValidator.resolveWithFallback( - FixedInstance.empty(), FixedInstance.empty(), router, "unknown", validatedCards)); - - assertEquals("unknown", ex.getTenant()); - } - - // A simple log handler for testing private static class TestLogHandler extends Handler { private final List logMessages = new java.util.ArrayList<>(); diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/ServerCallContextTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/ServerCallContextTest.java index d9fb1c992..9bdb4b4a1 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/ServerCallContextTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/ServerCallContextTest.java @@ -2,18 +2,122 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.a2aproject.sdk.server.auth.User; +import org.a2aproject.sdk.spec.AgentCard; import org.junit.jupiter.api.Test; class ServerCallContextTest { + @Test + void effectiveCardAttachmentNormalizesTenantAndRejectsConflictsWithoutReplacement() { + ServerCallContext context = new ServerCallContext(new TestUser(), Map.of(), Set.of()); + AgentCard card = AgentCardResolverTest.card("public"); + assertNull(context.getEffectiveAgentCard()); + context.attachEffectiveAgentCard("/acme/", card); + EffectiveAgentCard attached = context.getEffectiveAgentCard(); + context.attachEffectiveAgentCard("acme", card); + assertSame(attached, context.getEffectiveAgentCard()); + assertEquals("acme", attached.tenant()); + assertSame(card, attached.card()); + assertThrows(IllegalStateException.class, () -> context.attachEffectiveAgentCard("other", card)); + assertThrows(IllegalStateException.class, + () -> context.attachEffectiveAgentCard("acme", AgentCardResolverTest.card("public"))); + assertSame(attached, context.getEffectiveAgentCard()); + + ServerCallContext defaults = new ServerCallContext(new TestUser(), Map.of(), Set.of()); + defaults.attachEffectiveAgentCard(null, card); + EffectiveAgentCard defaultCard = defaults.getEffectiveAgentCard(); + for (String tenant : new String[] {"", " ", "/", "//"}) { + defaults.attachEffectiveAgentCard(tenant, card); + assertSame(defaultCard, defaults.getEffectiveAgentCard()); + } + } + + @Test + void concurrentSameCardAttachmentIsIdempotent() throws Exception { + ServerCallContext context = new ServerCallContext(new TestUser(), Map.of(), Set.of()); + AgentCard card = AgentCardResolverTest.card("public"); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> { + assertTrue(start.await(5, TimeUnit.SECONDS)); + context.attachEffectiveAgentCard("/acme/", card); + return context.getEffectiveAgentCard(); + }); + Future second = executor.submit(() -> { + assertTrue(start.await(5, TimeUnit.SECONDS)); + context.attachEffectiveAgentCard("acme", card); + return context.getEffectiveAgentCard(); + }); + start.countDown(); + assertSame(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)); + assertSame(card, context.getEffectiveAgentCard().card()); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + @Test + void concurrentDifferentCardsInstallExactlyOneValueAndRejectTheOther() throws Exception { + ServerCallContext context = new ServerCallContext(new TestUser(), Map.of(), Set.of()); + AgentCard firstCard = AgentCardResolverTest.card("first"); + AgentCard secondCard = AgentCardResolverTest.card("second"); + AtomicInteger successes = new AtomicInteger(); + AtomicInteger conflicts = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + List> calls = new ArrayList<>(); + for (AgentCard card : List.of(firstCard, secondCard)) { + calls.add(executor.submit(() -> { + try { + assertTrue(start.await(5, TimeUnit.SECONDS)); + context.attachEffectiveAgentCard("acme", card); + successes.incrementAndGet(); + } catch (IllegalStateException expected) { + conflicts.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + })); + } + start.countDown(); + for (Future call : calls) { + call.get(5, TimeUnit.SECONDS); + } + assertEquals(1, successes.get()); + assertEquals(1, conflicts.get()); + EffectiveAgentCard attached = context.getEffectiveAgentCard(); + AgentCard loser = attached.card() == firstCard ? secondCard : firstCard; + assertThrows(IllegalStateException.class, () -> context.attachEffectiveAgentCard("acme", loser)); + assertSame(attached, context.getEffectiveAgentCard()); + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + @Test void testDefaultConstructor() { User user = new TestUser(); diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerBuilderAuthorizationTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerBuilderAuthorizationTest.java index a8e390722..e8002f4c4 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerBuilderAuthorizationTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerBuilderAuthorizationTest.java @@ -4,34 +4,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.auth.SimpleTaskAuthorizationProvider; +import org.a2aproject.sdk.server.auth.TaskAuthorizationProvider; import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.auth.User; import org.a2aproject.sdk.server.events.EventQueueUtil; import org.a2aproject.sdk.server.events.InMemoryQueueManager; import org.a2aproject.sdk.server.events.MainEventBus; import org.a2aproject.sdk.server.events.MainEventBusProcessor; +import org.a2aproject.sdk.server.events.QueueManager; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; +import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.PushNotificationSender; +import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; -import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.spec.EventKind; import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; @@ -49,6 +57,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; class DefaultRequestHandlerBuilderAuthorizationTest { @@ -97,6 +107,61 @@ void tearDown() { } } + @ParameterizedTest(name = "{0} rejects blank {1}") + @CsvSource({ + "getTask, ''", "getTask, ' '", + "cancelTask, ''", "cancelTask, ' '", + "subscribe, ''", "subscribe, ' '", + "createPush, ''", "createPush, ' '", + "getPush, ''", "getPush, ' '", + "getPushId, ''", "getPushId, ' '", + "listPush, ''", "listPush, ' '", + "deletePush, ''", "deletePush, ' '", + "deletePushId, ''", "deletePushId, ' '" + }) + void effectiveCardRejectsBlankRequiredIdsBeforeDependencies(String operation, String id) { + AgentExecutor agent = mock(AgentExecutor.class); + TaskStore store = mock(TaskStore.class); + PushNotificationConfigStore pushStore = mock(PushNotificationConfigStore.class); + TaskAuthorizationProvider authorization = mock(TaskAuthorizationProvider.class); + QueueManager queues = mock(QueueManager.class); + Executor execution = mock(Executor.class); + RequestHandler validatedHandler = DefaultRequestHandler.builder() + .agentExecutor(agent).taskStore(store).queueManager(queues) + .pushConfigStore(pushStore).mainEventBusProcessor(mainEventBusProcessor) + .executor(execution).eventConsumerExecutor(execution) + .authorizationProvider(authorization).build(); + ServerCallContext context = DefaultRequestHandlerTest.contextWithEffectiveCard(true); + + assertThrows(InvalidParamsError.class, () -> { + switch (operation) { + case "getTask" -> validatedHandler.onGetTask(new TaskQueryParams(id, null, null), context); + case "cancelTask" -> validatedHandler.onCancelTask(new CancelTaskParams(id), context); + case "subscribe" -> validatedHandler.onSubscribeToTask(new TaskIdParams(id), context); + case "createPush" -> validatedHandler.onCreateTaskPushNotificationConfig( + TaskPushNotificationConfig.builder().taskId(id).url("https://example.com/push").build(), context); + case "getPush" -> validatedHandler.onGetTaskPushNotificationConfig( + new GetTaskPushNotificationConfigParams(id, "config"), context); + case "getPushId" -> validatedHandler.onGetTaskPushNotificationConfig( + new GetTaskPushNotificationConfigParams("task", id), context); + case "listPush" -> validatedHandler.onListTaskPushNotificationConfigs( + new ListTaskPushNotificationConfigsParams(id), context); + case "deletePush" -> validatedHandler.onDeleteTaskPushNotificationConfig( + new DeleteTaskPushNotificationConfigParams(id, "config"), context); + case "deletePushId" -> validatedHandler.onDeleteTaskPushNotificationConfig( + new DeleteTaskPushNotificationConfigParams("task", id), context); + default -> throw new AssertionError(operation); + } + }); + verifyNoInteractions(agent, store, pushStore, authorization, queues, execution); + } + + @Test + void blankTaskIdWithoutEffectiveCardRetainsAuthorizationBehavior() { + assertThrows(TaskNotFoundError.class, + () -> handler.onGetTask(new TaskQueryParams("", null, null), contextForUser("anyone"))); + } + @Nested class NoProviderFailClosedTests { diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java index 7e3d21bde..66340fd0f 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandlerTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.time.Instant; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -28,7 +29,7 @@ import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; -import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; +import org.a2aproject.sdk.server.auth.TaskOperation; import org.a2aproject.sdk.server.config.A2AConfigProvider; import org.a2aproject.sdk.server.events.EventQueue; import org.a2aproject.sdk.server.events.EventQueueItem; @@ -37,41 +38,47 @@ import org.a2aproject.sdk.server.events.MainEventBus; import org.a2aproject.sdk.server.events.MainEventBusProcessor; import org.a2aproject.sdk.server.events.MainEventBusProcessorCallback; +import org.a2aproject.sdk.server.multitenancy.AgentExecutorRouter; import org.a2aproject.sdk.server.tasks.AgentEmitter; import org.a2aproject.sdk.server.tasks.InMemoryPushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.InMemoryTaskStore; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.PushNotificationSender; -import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.server.tasks.TaskStateProvider; +import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.spec.Event; import org.a2aproject.sdk.spec.EventKind; import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.InternalError; import org.a2aproject.sdk.spec.InvalidParamsError; -import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendConfiguration; import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; -import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskNotCancelableError; +import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatus; -import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.a2aproject.sdk.spec.TextPart; import org.a2aproject.sdk.spec.UnsupportedOperationError; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; /** * Integration tests for DefaultRequestHandler focusing on AUTH_REQUIRED workflow. @@ -109,6 +116,12 @@ public class DefaultRequestHandlerTest { protected final Executor internalExecutor = Executors.newCachedThreadPool(); + @Test + public void authorizeTaskAccessRejectsWhitespaceOnlyTaskId() { + assertThrows(InvalidParamsError.class, + () -> requestHandler.authorizeTaskAccess(" ", NULL_CONTEXT, TaskOperation.SUBSCRIBE_TO_TASK)); + } + @BeforeEach public void init() { // Create test AgentExecutor with mocked execute/cancel methods @@ -902,8 +915,9 @@ void testSendMessageStream_WithNonExistentTaskId_ThrowsTaskNotFoundError() { * mistaken for a reference to an existing task with id "", causing a spurious * TaskNotFoundError instead of starting a new task. */ - @Test - void testSendMessage_WithBlankTaskId_TreatedAsNewTask() throws Exception { + @ParameterizedTest + @NullAndEmptySource + void testSendMessage_WithBlankTaskId_TreatedAsNewTask(String taskId) throws Exception { CountDownLatch agentCompleted = new CountDownLatch(1); agentExecutorExecute = (context, emitter) -> { emitter.complete(); @@ -913,7 +927,7 @@ void testSendMessage_WithBlankTaskId_TreatedAsNewTask() throws Exception { Message message = Message.builder() .messageId("msg-blank-task-id") .role(Message.Role.ROLE_USER) - .taskId("") + .taskId(taskId) .contextId("") .parts(new TextPart("hello")) .build(); @@ -923,7 +937,7 @@ void testSendMessage_WithBlankTaskId_TreatedAsNewTask() throws Exception { .configuration(DEFAULT_CONFIG) .build(); - EventKind result = requestHandler.onMessageSend(params, NULL_CONTEXT); + EventKind result = requestHandler.onMessageSend(params, contextWithEffectiveCard(true)); assertInstanceOf(Task.class, result, "A blank taskId should start a new task, not fail lookup"); Task task = (Task) result; @@ -937,10 +951,11 @@ void testSendMessage_WithBlankTaskId_TreatedAsNewTask() throws Exception { } /** - * Streaming counterpart of {@link #testSendMessage_WithBlankTaskId_TreatedAsNewTask()}. + * Streaming counterpart of {@link #testSendMessage_WithBlankTaskId_TreatedAsNewTask(String)}. */ - @Test - void testSendMessageStream_WithBlankTaskId_TreatedAsNewTask() throws Exception { + @ParameterizedTest + @NullAndEmptySource + void testSendMessageStream_WithBlankTaskId_TreatedAsNewTask(String taskId) throws Exception { CountDownLatch agentCompleted = new CountDownLatch(1); agentExecutorExecute = (context, emitter) -> { emitter.complete(); @@ -950,7 +965,7 @@ void testSendMessageStream_WithBlankTaskId_TreatedAsNewTask() throws Exception { Message message = Message.builder() .messageId("msg-stream-blank-task-id") .role(Message.Role.ROLE_USER) - .taskId("") + .taskId(taskId) .contextId("") .parts(new TextPart("hello")) .build(); @@ -965,7 +980,7 @@ void testSendMessageStream_WithBlankTaskId_TreatedAsNewTask() throws Exception { AtomicReference taskIdRef = new AtomicReference<>(); Flow.Publisher publisher = - requestHandler.onMessageSendStream(params, contextWithVersion("1.0")); + requestHandler.onMessageSendStream(params, contextWithEffectiveCard(true)); publisher.subscribe(new Flow.Subscriber<>() { @Override public void onSubscribe(Flow.Subscription s) { @@ -1114,6 +1129,47 @@ private static ServerCallContext contextWithVersion(String version) { return new ServerCallContext(null, Map.of(), Set.of(), version); } + static ServerCallContext contextWithEffectiveCard(boolean pushNotifications) { + ServerCallContext context = contextWithVersion("1.0"); + context.attachEffectiveAgentCard(null, AgentCard.builder() + .name("test").description("Test agent").version("1.0") + .supportedInterfaces(List.of()).skills(List.of()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .capabilities(AgentCapabilities.builder().pushNotifications(pushNotifications).build()) + .build()); + return context; + } + + @ParameterizedTest(name = "streaming={0}, advertised={1}, enabled={2}, store={3}") + @CsvSource({ + "false, false, true, true", "true, false, true, true", + "false, true, false, true", "true, true, false, true", + "false, true, true, false", "true, true, true, false" + }) + void embeddedPushRequiresEffectiveCardAndConfiguredSupport( + boolean streaming, boolean advertised, boolean enabled, boolean hasStore) { + DefaultRequestHandler handler = DefaultRequestHandler.builder() + .agentExecutor(executor).taskStore(taskStore).queueManager(queueManager) + .pushConfigStore(hasStore ? pushConfigStore : null).pushNotificationsEnabled(enabled) + .mainEventBusProcessor(mainEventBusProcessor) + .executor(internalExecutor).eventConsumerExecutor(internalExecutor) + .build(); + MessageSendParams params = MessageSendParams.builder().message(MESSAGE) + .configuration(MessageSendConfiguration.builder() + .taskPushNotificationConfig(TaskPushNotificationConfig.builder() + .url("https://example.com/push").build()).build()) + .build(); + ServerCallContext context = contextWithEffectiveCard(advertised); + + assertThrows(PushNotificationNotSupportedError.class, () -> { + if (streaming) { + handler.onMessageSendStream(params, context); + } else { + handler.onMessageSend(params, context); + } + }); + } + /** * Verify that onCreateTaskPushNotificationConfig stores the protocol version * from the ServerCallContext in the PushNotificationConfigStore. @@ -1957,4 +2013,15 @@ void testCreatePushNotificationConfigRejectedWhenPushNotificationsDisabled() thr () -> handlerNoPush.onCreateTaskPushNotificationConfig(pushConfig, NULL_CONTEXT), "CreateTaskPushNotificationConfig should throw UnsupportedOperationError when push is disabled"); } + @Test + public void testListTasksFutureTimestampValidation() { + Instant future = Instant.now().plusSeconds(60); + ListTasksParams params = ListTasksParams.builder().statusTimestampAfter(future).build(); + InvalidParamsError error = assertThrows(InvalidParamsError.class, + () -> requestHandler.onListTasks(params, NULL_CONTEXT)); + assertEquals("Invalid params", error.getMessage()); + assertEquals(Map.of("parameter", "statusTimestampAfter", "reason", "Timestamp cannot be in the future"), + error.getDetails()); + } + } diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java index 26bed34c5..600eecfd4 100644 --- a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/utils/ProtoUtils.java @@ -44,6 +44,7 @@ import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.util.Assert; /** * Utility class to convert between GRPC and Spec objects. @@ -314,7 +315,7 @@ public static Task task(org.a2aproject.sdk.grpc.TaskOrBuilder task) { } public static Message message(org.a2aproject.sdk.grpc.MessageOrBuilder message) { - if (message.getMessageId().isEmpty()) { + if (Assert.isNullOrEmpty(message.getMessageId())) { throw new InvalidParamsError(); } org.a2aproject.sdk.grpc.Message messageProto = message instanceof org.a2aproject.sdk.grpc.Message diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java index 9117a32e0..ece90b364 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java @@ -26,7 +26,6 @@ import com.google.gson.JsonArray; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; - import org.a2aproject.sdk.grpc.SendMessageResponse; import org.a2aproject.sdk.jsonrpc.common.json.InvalidParamsJsonMappingException; import org.a2aproject.sdk.jsonrpc.common.json.JsonMappingException; @@ -804,4 +803,19 @@ void parseGetExtendedAgentCardBodyTenantTakesPrecedence() throws Exception { assertEquals("body-tenant", extRequest.getParams().tenant()); } + @Test + public void testRequestAndResponseEmptyMessageIdsKeepDistinctErrors() { + String body = """ + {"jsonrpc":"2.0","id":"empty-message","method":"SendMessage","params":{ + "message":{"messageId":"","role":"ROLE_USER","parts":[{"text":"hello"}]}}} + """; + InvalidParamsJsonMappingException requestError = assertThrows(InvalidParamsJsonMappingException.class, + () -> JSONRPCUtils.parseRequestBody(body, null)); + assertEquals("Required field cannot be null or empty", requestError.getMessage()); + assertEquals("empty-message", requestError.getId()); + InvalidParamsError responseError = assertThrows(InvalidParamsError.class, + () -> ProtoUtils.FromProto.message(org.a2aproject.sdk.grpc.Message.getDefaultInstance())); + assertEquals("Invalid parameters", responseError.getMessage()); + } + } diff --git a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPCTest.java b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPCTest.java index 5ca83cf9e..bd7996d2a 100644 --- a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPCTest.java +++ b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPCTest.java @@ -1,14 +1,24 @@ package org.a2aproject.sdk.tests.multiversion.jsonrpc; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import jakarta.inject.Inject; + +import com.google.gson.JsonParser; import io.quarkus.test.junit.QuarkusTest; import io.vertx.core.Vertx; -import jakarta.inject.Inject; import org.a2aproject.sdk.client.ClientBuilder; import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; import org.a2aproject.sdk.server.apps.common.AbstractA2AServerTest; import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.Test; @QuarkusTest public class MultiVersionJSONRPCTest extends AbstractA2AServerTest { @@ -36,4 +46,52 @@ protected void configureTransport(ClientBuilder builder) { new JSONRPCTransportConfigBuilder() .httpClient(new VertxA2AHttpClient(vertx))); } + @Test + public void testUnsupportedVersionBeforeBodyValidation() throws Exception { + for (String body : new String[] {"{", "{\"id\":\"recoverable\",\"method\":false}"}) { + HttpResponse response = decodeResponse("2.0", "", body); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":null,"error":{"code":-32009, + "message":"Protocol version '2.0' is not supported. Supported versions: [1.0, 0.3]", + "data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"VERSION_NOT_SUPPORTED", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(response.body())); + } + } + + @Test + public void testSelectedVersionDecodeErrors() throws Exception { + HttpResponse response = decodeResponse("1.0", "", """ + {"id":"original","method":"SendMessage","params":[]} + """); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"original","error":{"code":-32600, + "message":"Missing required 'jsonrpc' field. All requests must include 'jsonrpc': '2.0'", + "data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_REQUEST", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(response.body())); + response = decodeResponse("1.0", "", """ + {"jsonrpc":"2.0","id":"original","method":"SendMessage","params":{ + "message":{"role":"ROLE_USER","parts":[{"text":"hello"}]}}} + """); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"original","error":{"code":-32602, + "message":"Required field cannot be null or empty", + "data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(response.body())); + } + private HttpResponse decodeResponse(String header, String query, String body) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/" + query)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body)); + if (header != null) { + request.header("A2A-Version", header); + } + HttpResponse response = HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow()); + return response; + } + } diff --git a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPC_v0_3_Test.java b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPC_v0_3_Test.java index bd3f46d82..b6c5ce98e 100644 --- a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPC_v0_3_Test.java +++ b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/MultiVersionJSONRPC_v0_3_Test.java @@ -1,11 +1,20 @@ package org.a2aproject.sdk.tests.multiversion.jsonrpc; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import com.google.gson.JsonParser; import io.quarkus.test.junit.QuarkusTest; import org.a2aproject.sdk.compat03.client.ClientBuilder_v0_3; -import org.a2aproject.sdk.compat03.client.transport.jsonrpc.JSONRPCTransport_v0_3; import org.a2aproject.sdk.compat03.client.transport.jsonrpc.JSONRPCTransportConfigBuilder_v0_3; +import org.a2aproject.sdk.compat03.client.transport.jsonrpc.JSONRPCTransport_v0_3; import org.a2aproject.sdk.compat03.conversion.AbstractA2AServerServerTest_v0_3; import org.a2aproject.sdk.compat03.spec.TransportProtocol_v0_3; +import org.junit.jupiter.api.Test; @QuarkusTest public class MultiVersionJSONRPC_v0_3_Test extends AbstractA2AServerServerTest_v0_3 { @@ -28,4 +37,49 @@ protected String getTransportUrl() { protected void configureTransport(ClientBuilder_v0_3 builder) { builder.withTransport(JSONRPCTransport_v0_3.class, new JSONRPCTransportConfigBuilder_v0_3()); } + @Test + public void testSelectedVersionDecodeErrors() throws Exception { + assertVersionFieldError(decodeResponse("0.3", "", "{\"id\":\"original\",\"method\":null}")); + HttpResponse response = decodeResponse("0.3", "", """ + {"jsonrpc":"2.0","id":"original","method":"message/stream","params":[]} + """); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"original","error":{"code":-32602,"message":"Failed to parse JSON"}} + """), JsonParser.parseString(response.body())); + } + + @Test + public void testVersionSelectionBeforeDecode() throws Exception { + String body = "{\"id\":\"original\",\"method\":null}"; + assertVersionFieldError(decodeResponse(null, "", body)); + assertVersionFieldError(decodeResponse("0.3", "?A2A-Version=1.0", body)); + assertVersionFieldError(decodeResponse(null, "?A2A-Version=0.3", body)); + HttpResponse response = decodeResponse("1.0", "?A2A-Version=0.3", body); + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"original","error":{"code":-32600, + "message":"Missing required 'jsonrpc' field. All requests must include 'jsonrpc': '2.0'", + "data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_REQUEST", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(response.body())); + } + + private void assertVersionFieldError(HttpResponse response) { + assertEquals(JsonParser.parseString(""" + {"jsonrpc":"2.0","id":"original","error":{"code":-32600, + "message":"Invalid JSON-RPC request: missing or invalid 'jsonrpc' field"}} + """), JsonParser.parseString(response.body())); + } + private HttpResponse decodeResponse(String header, String query, String body) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/" + query)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body)); + if (header != null) { + request.header("A2A-Version", header); + } + HttpResponse response = HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow()); + return response; + } + } diff --git a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersionRestTest.java b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersionRestTest.java index 3341ec378..cb347b4e6 100644 --- a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersionRestTest.java +++ b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersionRestTest.java @@ -1,14 +1,24 @@ package org.a2aproject.sdk.tests.multiversion.rest; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import jakarta.inject.Inject; + +import com.google.gson.JsonParser; import io.quarkus.test.junit.QuarkusTest; import io.vertx.core.Vertx; -import jakarta.inject.Inject; import org.a2aproject.sdk.client.ClientBuilder; import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; import org.a2aproject.sdk.client.transport.rest.RestTransport; import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; import org.a2aproject.sdk.server.apps.common.AbstractA2AServerTest; import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.Test; @QuarkusTest public class MultiVersionRestTest extends AbstractA2AServerTest { @@ -36,4 +46,34 @@ protected void configureTransport(ClientBuilder builder) { new RestTransportConfigBuilder() .httpClient(new VertxA2AHttpClient(vertx))); } + @Test + public void testUnsupportedVersionBeforeMalformedHistory() throws Exception { + HttpResponse response = historyResponse("2.0", "historyLength=bad"); + assertEquals(400, response.statusCode()); + assertEquals(JsonParser.parseString(""" + {"error":{"code":-32009,"message":"Protocol version '2.0' is not supported. Supported versions: [1.0, 0.3]"}} + """), JsonParser.parseString(response.body())); + } + + @Test + public void testSelectedVersionHistoryValidation() throws Exception { + HttpResponse response = historyResponse("1.0", "historyLength=bad"); + assertEquals(422, response.statusCode()); + assertEquals(JsonParser.parseString(""" + {"error":{"code":422,"status":"INVALID_ARGUMENT","message":"bad historyLength","details":[{ + "@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(response.body())); + } + private HttpResponse historyResponse(String header, String query) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/v1/tasks/missing-history-task?" + query)).GET(); + if (header != null) { + request.header("A2A-Version", header); + } + HttpResponse response = HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow()); + return response; + } + } diff --git a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersion_v0_3_RestTest.java b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersion_v0_3_RestTest.java index d3250580c..9462576e2 100644 --- a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersion_v0_3_RestTest.java +++ b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/MultiVersion_v0_3_RestTest.java @@ -1,11 +1,20 @@ package org.a2aproject.sdk.tests.multiversion.rest; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import com.google.gson.JsonParser; import io.quarkus.test.junit.QuarkusTest; import org.a2aproject.sdk.compat03.client.ClientBuilder_v0_3; -import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; import org.a2aproject.sdk.compat03.client.transport.rest.RestTransportConfigBuilder_v0_3; +import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; import org.a2aproject.sdk.compat03.conversion.AbstractA2AServerServerTest_v0_3; import org.a2aproject.sdk.compat03.spec.TransportProtocol_v0_3; +import org.junit.jupiter.api.Test; @QuarkusTest public class MultiVersion_v0_3_RestTest extends AbstractA2AServerServerTest_v0_3 { @@ -28,4 +37,39 @@ protected String getTransportUrl() { protected void configureTransport(ClientBuilder_v0_3 builder) { builder.withTransport(RestTransport_v0_3.class, new RestTransportConfigBuilder_v0_3()); } + @Test + public void testSelectedVersionHistoryValidation() throws Exception { + assertAliasConflict(historyResponse("0.3", "history_length=1&historyLength=bad")); + } + + @Test + public void testVersionSelectionBeforeHistoryValidation() throws Exception { + assertAliasConflict(historyResponse(null, "history_length=1&historyLength=bad")); + assertAliasConflict(historyResponse("0.3", "A2A-Version=1.0&history_length=1&historyLength=bad")); + assertAliasConflict(historyResponse(null, "A2A-Version=0.3&history_length=1&historyLength=bad")); + HttpResponse selectedV1 = historyResponse("1.0", "A2A-Version=0.3&historyLength=bad"); + assertEquals(422, selectedV1.statusCode()); + assertEquals(JsonParser.parseString(""" + {"error":{"code":422,"status":"INVALID_ARGUMENT","message":"bad historyLength","details":[{ + "@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """), JsonParser.parseString(selectedV1.body())); + } + + private void assertAliasConflict(HttpResponse response) { + assertEquals(422, response.statusCode()); + assertEquals("{\"error\": \"org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3\", " + + "\"message\": \"Only one of 'history_length' or 'historyLength' may be specified\"}", response.body()); + } + private HttpResponse historyResponse(String header, String query) throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/v1/tasks/missing-history-task?" + query)).GET(); + if (header != null) { + request.header("A2A-Version", header); + } + HttpResponse response = HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow()); + return response; + } + } diff --git a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java index 39a23c289..ea70660b9 100644 --- a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java @@ -1,5 +1,9 @@ package org.a2aproject.sdk.transport.grpc.handler; +import static org.a2aproject.sdk.server.AgentCardOperation.EXTENDED_CARD; +import static org.a2aproject.sdk.server.AgentCardOperation.PUSH_NOTIFICATIONS; +import static org.a2aproject.sdk.server.AgentCardOperation.STREAMING; + import static org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto; import static org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto; import static org.a2aproject.sdk.server.ServerCallContext.TRANSPORT_KEY; @@ -8,6 +12,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -24,8 +29,12 @@ import org.a2aproject.sdk.grpc.StreamResponse; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.server.AgentCardOperation; +import org.a2aproject.sdk.server.AgentCardRequestPolicy; +import org.a2aproject.sdk.server.AgentCardResolver; +import org.a2aproject.sdk.server.AgentCardTransportValidator; import org.a2aproject.sdk.server.AgentCardValidator; -import org.a2aproject.sdk.server.FixedInstance; +import org.a2aproject.sdk.server.CachingAgentCardTransportValidator; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.auth.User; @@ -33,8 +42,8 @@ import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; -import org.a2aproject.sdk.server.version.A2AVersionValidator; import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; @@ -52,7 +61,6 @@ import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.MethodNotFoundError; -import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskIdParams; @@ -61,8 +69,6 @@ import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TransportProtocol; -import org.a2aproject.sdk.spec.A2AErrorCodes; -import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.spec.VersionNotSupportedError; import org.a2aproject.sdk.spec.util.Utils; import org.a2aproject.sdk.transport.grpc.context.GrpcContextKeys; @@ -162,7 +168,11 @@ public abstract class GrpcHandler extends A2AServiceGrpc.A2AServiceImplBase { // Without this we get intermittent failures private static volatile @Nullable Runnable streamingSubscribedRunnable; - private final Set validatedCards = AgentCardValidator.newValidatedCardsSet(); + private final AgentCardTransportValidator transportValidator = + new CachingAgentCardTransportValidator(this::validateTransportConfigurationWithCorrectClassLoader); + + private volatile @Nullable AgentCardRequestPolicy requestPolicy; + private @Nullable AgentCardResolver agentCardResolver; private static final Logger LOGGER = Logger.getLogger(GrpcHandler.class.getName()); @@ -202,9 +212,10 @@ public GrpcHandler() { public void sendMessage(org.a2aproject.sdk.grpc.SendMessageRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); + String tenant = request.getTenant(); ServerCallContext context = createCallContext(responseObserver, tenant); - MessageSendParams params = FromProto.messageSendParams(request); + MessageSendParams params = FromProto.messageSendParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); EventKind taskOrMessage = getRequestHandler().onMessageSend(params, context); org.a2aproject.sdk.grpc.SendMessageResponse response = ToProto.taskOrMessage(taskOrMessage); responseObserver.onNext(response); @@ -224,9 +235,10 @@ public void sendMessage(org.a2aproject.sdk.grpc.SendMessageRequest request, public void getTask(org.a2aproject.sdk.grpc.GetTaskRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); + String tenant = request.getTenant(); ServerCallContext context = createCallContext(responseObserver, tenant); - TaskQueryParams params = FromProto.taskQueryParams(request); + TaskQueryParams params = FromProto.taskQueryParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); Task task = getRequestHandler().onGetTask(params, context); if (task != null) { responseObserver.onNext(ToProto.task(task)); @@ -249,9 +261,10 @@ public void getTask(org.a2aproject.sdk.grpc.GetTaskRequest request, public void listTasks(org.a2aproject.sdk.grpc.ListTasksRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); + String tenant = request.getTenant(); ServerCallContext context = createCallContext(responseObserver, tenant); - org.a2aproject.sdk.spec.ListTasksParams params = FromProto.listTasksParams(request); + org.a2aproject.sdk.spec.ListTasksParams params = FromProto.listTasksParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); ListTasksResult result = getRequestHandler().onListTasks(params, context); responseObserver.onNext(ToProto.listTasksResult(result)); responseObserver.onCompleted(); @@ -270,9 +283,10 @@ public void listTasks(org.a2aproject.sdk.grpc.ListTasksRequest request, public void cancelTask(org.a2aproject.sdk.grpc.CancelTaskRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); + String tenant = request.getTenant(); ServerCallContext context = createCallContext(responseObserver, tenant); - CancelTaskParams params = FromProto.cancelTaskParams(request); + CancelTaskParams params = FromProto.cancelTaskParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); Task task = getRequestHandler().onCancelTask(params, context); if (task != null) { responseObserver.onNext(ToProto.task(task)); @@ -295,14 +309,10 @@ public void cancelTask(org.a2aproject.sdk.grpc.CancelTaskRequest request, public void createTaskPushNotificationConfig(org.a2aproject.sdk.grpc.TaskPushNotificationConfig request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - handleError(responseObserver, new PushNotificationNotSupportedError()); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); - TaskPushNotificationConfig config = FromProto.createTaskPushNotificationConfig(request); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, PUSH_NOTIFICATIONS); + TaskPushNotificationConfig config = FromProto.createTaskPushNotificationConfig( + request.toBuilder().setTenant(canonicalTenant(context)).build()); TaskPushNotificationConfig responseConfig = getRequestHandler().onCreateTaskPushNotificationConfig(config, context); responseObserver.onNext(ToProto.taskPushNotificationConfig(responseConfig)); responseObserver.onCompleted(); @@ -321,14 +331,10 @@ public void createTaskPushNotificationConfig(org.a2aproject.sdk.grpc.TaskPushNot public void getTaskPushNotificationConfig(org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - handleError(responseObserver, new PushNotificationNotSupportedError()); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); - GetTaskPushNotificationConfigParams params = FromProto.getTaskPushNotificationConfigParams(request); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, PUSH_NOTIFICATIONS); + GetTaskPushNotificationConfigParams params = FromProto.getTaskPushNotificationConfigParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); TaskPushNotificationConfig config = getRequestHandler().onGetTaskPushNotificationConfig(params, context); responseObserver.onNext(ToProto.taskPushNotificationConfig(config)); responseObserver.onCompleted(); @@ -347,14 +353,10 @@ public void getTaskPushNotificationConfig(org.a2aproject.sdk.grpc.GetTaskPushNot public void listTaskPushNotificationConfigs(org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - handleError(responseObserver, new PushNotificationNotSupportedError()); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); - ListTaskPushNotificationConfigsParams params = FromProto.listTaskPushNotificationConfigsParams(request); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, PUSH_NOTIFICATIONS); + ListTaskPushNotificationConfigsParams params = FromProto.listTaskPushNotificationConfigsParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); ListTaskPushNotificationConfigsResult result = getRequestHandler().onListTaskPushNotificationConfigs(params, context); org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsResponse response = ToProto.listTaskPushNotificationConfigsResponse(result); responseObserver.onNext(response); @@ -411,16 +413,11 @@ public void listTaskPushNotificationConfigs(org.a2aproject.sdk.grpc.ListTaskPush public void sendStreamingMessage(org.a2aproject.sdk.grpc.SendMessageRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - handleError(responseObserver, - new UnsupportedOperationError(null, "Streaming is not supported by the agent", null)); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, STREAMING); installForkedContextWrapper(context); - MessageSendParams params = FromProto.messageSendParams(request); + MessageSendParams params = FromProto.messageSendParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); Flow.Publisher publisher = getRequestHandler().onMessageSendStream(params, context); convertToStreamResponse(publisher, responseObserver, context); } catch (TenantNotFoundException e) { @@ -438,16 +435,11 @@ public void sendStreamingMessage(org.a2aproject.sdk.grpc.SendMessageRequest requ public void subscribeToTask(org.a2aproject.sdk.grpc.SubscribeToTaskRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - handleError(responseObserver, - new UnsupportedOperationError(null, "Streaming is not supported by the agent", null)); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, STREAMING); installForkedContextWrapper(context); - TaskIdParams params = FromProto.taskIdParams(request); + TaskIdParams params = FromProto.taskIdParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); Flow.Publisher publisher = getRequestHandler().onSubscribeToTask(params, context); convertToStreamResponse(publisher, responseObserver, context); } catch (TenantNotFoundException e) { @@ -593,20 +585,14 @@ private void completeStream() { public void getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); + String tenant = request.getTenant(); Utils.validateTenant(tenant); - if (!resolveAgentCard(tenant).capabilities().extendedAgentCard()) { - handleError(responseObserver, new UnsupportedOperationError()); - return; - } - createCallContext(responseObserver, tenant); - AgentCardRouter router = getAgentCardRouter(); - AgentCard extendedAgentCard; - if (tenant != null && !tenant.isBlank() && router != null) { - extendedAgentCard = router.resolveExtendedCard(tenant); - } else { - extendedAgentCard = getExtendedAgentCard(); - } + AgentCardResolver resolver = getAgentCardResolver(); + AgentCardRequestPolicy policy = getRequestPolicy(); + AgentCard card = policy.resolveAndValidateCapability(tenant, transportValidator, EXTENDED_CARD); + ServerCallContext context = createRawCallContext(responseObserver); + String normalizedTenant = policy.validateContextAndAttach(card, tenant, context).tenant(); + AgentCard extendedAgentCard = resolver.resolveExtendedDiscoveryCard(normalizedTenant); if (extendedAgentCard != null) { responseObserver.onNext(ToProto.agentCard(extendedAgentCard)); responseObserver.onCompleted(); @@ -615,6 +601,8 @@ public void getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardReq } } catch (TenantNotFoundException e) { handleTenantNotFound(responseObserver, e); + } catch (A2AError e) { + handleError(responseObserver, e); } catch (Throwable t) { handleInternalError(responseObserver, t); } @@ -624,14 +612,10 @@ public void getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardReq public void deleteTaskPushNotificationConfig(org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest request, StreamObserver responseObserver) { try { - String tenant = extractTenant(request.getTenant()); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - handleError(responseObserver, new PushNotificationNotSupportedError()); - return; - } - - ServerCallContext context = createCallContext(responseObserver, tenant); - DeleteTaskPushNotificationConfigParams params = FromProto.deleteTaskPushNotificationConfigParams(request); + String tenant = request.getTenant(); + ServerCallContext context = createCallContext(responseObserver, tenant, PUSH_NOTIFICATIONS); + DeleteTaskPushNotificationConfigParams params = FromProto.deleteTaskPushNotificationConfigParams( + request.toBuilder().setTenant(canonicalTenant(context)).build()); getRequestHandler().onDeleteTaskPushNotificationConfig(params, context); responseObserver.onNext(Empty.getDefaultInstance()); responseObserver.onCompleted(); @@ -682,7 +666,7 @@ public void deleteTaskPushNotificationConfig(org.a2aproject.sdk.grpc.DeleteTaskP * @see CallContextFactory * @see org.a2aproject.sdk.transport.grpc.context.GrpcContextKeys */ - private ServerCallContext createCallContext(StreamObserver responseObserver, @Nullable String tenant) { + private ServerCallContext createRawCallContext(StreamObserver responseObserver) { CallContextFactory factory = getCallContextFactory(); ServerCallContext context; if (factory == null) { @@ -748,12 +732,30 @@ private ServerCallContext createCallContext(StreamObserver responseObserv context = factory.create(responseObserver); // Fall back to basic create() method for now } - AgentCard agentCard = resolveAgentCard(tenant); - A2AVersionValidator.validateProtocolVersion(agentCard, context); - A2AExtensions.validateRequiredExtensions(agentCard, context); return context; } + private ServerCallContext createCallContext(StreamObserver responseObserver, @Nullable String tenant) { + ServerCallContext context = createRawCallContext(responseObserver); + getRequestPolicy().preflight( + tenant, context, transportValidator, AgentCardOperation.none()); + return context; + } + + private ServerCallContext createCallContext(StreamObserver responseObserver, @Nullable String tenant, + AgentCardOperation operation) { + AgentCardRequestPolicy policy = getRequestPolicy(); + AgentCard card = policy.resolveAndValidateCapability(tenant, transportValidator, operation); + ServerCallContext context = createRawCallContext(responseObserver); + policy.validateContextAndAttach(card, tenant, context); + return context; + } + + private static String canonicalTenant(ServerCallContext context) { + String tenant = Objects.requireNonNull(context.getEffectiveAgentCard()).tenant(); + return tenant == null ? "" : tenant; + } + /** * Forks the current gRPC context and installs it as an execution wrapper on the * server call context. This isolates agent outbound calls from the inbound call's @@ -877,18 +879,29 @@ private void handleInternalError(StreamObserver responseObserver, Throwab } - private AgentCard resolveAgentCard(@Nullable String tenant) { - AgentCard publicCard = getAgentCard(); - AgentCard extendedCard = getExtendedAgentCard(); - return AgentCardValidator.resolveWithFallback( - publicCard != null ? new FixedInstance<>(publicCard) : FixedInstance.empty(), - extendedCard != null ? new FixedInstance<>(extendedCard) : FixedInstance.empty(), - getAgentCardRouter(), tenant, validatedCards, - this::validateTransportConfigurationWithCorrectClassLoader); + /** Creates a resolver using this handler's overridable card sources and router. */ + protected AgentCardResolver createAgentCardResolver() { + return AgentCardResolver.of(this::getAgentCard, this::getExtendedAgentCard, getAgentCardRouter()); + } + + private AgentCardRequestPolicy getRequestPolicy() { + AgentCardRequestPolicy policy = requestPolicy; + if (policy == null) { + synchronized (this) { + policy = requestPolicy; + if (policy == null) { + agentCardResolver = createAgentCardResolver(); + policy = new AgentCardRequestPolicy(agentCardResolver); + requestPolicy = policy; + } + } + } + return policy; } - private static @Nullable String extractTenant(String protoTenant) { - return protoTenant.isBlank() ? null : protoTenant; + private AgentCardResolver getAgentCardResolver() { + getRequestPolicy(); + return Objects.requireNonNull(agentCardResolver); } private void handleTenantNotFound(StreamObserver responseObserver, TenantNotFoundException e) { diff --git a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java index 69f3aceae..510a65c1a 100644 --- a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java @@ -2,15 +2,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; +import java.net.URL; import java.util.ArrayList; import java.util.Collections; +import java.util.Enumeration; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import com.google.protobuf.Empty; import com.google.protobuf.Struct; @@ -40,6 +48,7 @@ import org.a2aproject.sdk.grpc.TaskState; import org.a2aproject.sdk.grpc.TaskStatus; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.TransportMetadata; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.events.EventConsumer; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; @@ -59,7 +68,6 @@ import org.a2aproject.sdk.spec.TextPart; import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.mockito.MockedConstruction; @@ -102,6 +110,31 @@ public void testOnGetTaskSuccess() throws Exception { assertEquals(TaskState.TASK_STATE_SUBMITTED, task.getStatus().getState()); } + @Test + public void resolverPolicyIsInitializedOnceAcrossRequests() throws Exception { + AtomicInteger resolverCreations = new AtomicInteger(); + GrpcHandler handler = new TestGrpcHandler(AbstractA2ARequestHandlerTest.CARD, requestHandler, internalExecutor) { + @Override + protected org.a2aproject.sdk.server.AgentCardResolver createAgentCardResolver() { + resolverCreations.incrementAndGet(); + return super.createAgentCardResolver(); + } + }; + taskStore.save(AbstractA2ARequestHandlerTest.MINIMAL_TASK, false); + GetTaskRequest request = GetTaskRequest.newBuilder() + .setId(AbstractA2ARequestHandlerTest.MINIMAL_TASK.id()) + .build(); + + StreamRecorder first = StreamRecorder.create(); + StreamRecorder second = StreamRecorder.create(); + handler.getTask(request, first); + handler.getTask(request, second); + first.awaitCompletion(5, TimeUnit.SECONDS); + second.awaitCompletion(5, TimeUnit.SECONDS); + + assertEquals(1, resolverCreations.get()); + } + @Test public void testOnGetTaskNotFound() throws Exception { GrpcHandler handler = new TestGrpcHandler(AbstractA2ARequestHandlerTest.CARD, requestHandler, internalExecutor); @@ -1530,6 +1563,318 @@ public void testListTasksEmptyResultIncludesAllFields() throws Exception { Assertions.assertNotNull(response.getNextPageToken()); } + @Test + public void testNormalOperationInvalidTenantMapsToInvalidParams() throws Exception { + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + GrpcHandler handler = new TestGrpcHandler(CARD, customHandler, internalExecutor); + StreamRecorder recorder = StreamRecorder.create(); + + handler.getTask(GetTaskRequest.newBuilder().setId("task").setTenant("bad/tenant").build(), recorder); + + assertGrpcReason(recorder, Status.Code.INVALID_ARGUMENT, "INVALID_PARAMS"); + Mockito.verifyNoInteractions(customHandler); + } + + @Test + public void testCapabilityOperationsInvalidTenantMapsToInvalidParams() throws Exception { + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + GrpcHandler handler = new TestGrpcHandler(CARD, customHandler, internalExecutor); + StreamRecorder streaming = StreamRecorder.create(); + StreamRecorder push = StreamRecorder.create(); + + handler.sendStreamingMessage(SendMessageRequest.newBuilder().setTenant("bad/tenant").build(), streaming); + handler.deleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigRequest.newBuilder() + .setTaskId("task").setId("config").setTenant("bad/tenant").build(), push); + + assertGrpcReason(streaming, Status.Code.INVALID_ARGUMENT, "INVALID_PARAMS"); + assertGrpcReason(push, Status.Code.INVALID_ARGUMENT, "INVALID_PARAMS"); + Mockito.verifyNoInteractions(customHandler); + } + + @Test + public void testCapabilityFailuresPrecedeInvalidVersion() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().build()).build(); + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + GrpcHandler handler = handlerWithVersion(card, customHandler, "2.0"); + StreamRecorder send = StreamRecorder.create(); + StreamRecorder subscribe = StreamRecorder.create(); + StreamRecorder create = StreamRecorder.create(); + StreamRecorder get = StreamRecorder.create(); + StreamRecorder list = StreamRecorder.create(); + StreamRecorder delete = StreamRecorder.create(); + + handler.sendStreamingMessage(SendMessageRequest.getDefaultInstance(), send); + handler.subscribeToTask(SubscribeToTaskRequest.getDefaultInstance(), subscribe); + handler.createTaskPushNotificationConfig(TaskPushNotificationConfig.getDefaultInstance(), create); + handler.getTaskPushNotificationConfig(GetTaskPushNotificationConfigRequest.getDefaultInstance(), get); + handler.listTaskPushNotificationConfigs(ListTaskPushNotificationConfigsRequest.getDefaultInstance(), list); + handler.deleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigRequest.getDefaultInstance(), delete); + + assertGrpcReason(send, Status.Code.UNIMPLEMENTED, "UNSUPPORTED_OPERATION"); + assertEquals("Streaming is not supported by the agent", Status.fromThrowable(send.getError()).getDescription()); + assertGrpcReason(subscribe, Status.Code.UNIMPLEMENTED, "UNSUPPORTED_OPERATION"); + for (StreamRecorder recorder : List.of(create, get, list, delete)) { + assertGrpcReason(recorder, Status.Code.UNIMPLEMENTED, "PUSH_NOTIFICATION_NOT_SUPPORTED"); + } + Mockito.verifyNoInteractions(customHandler); + } + + @Test + public void testInvalidVersionWithEnabledCapabilitiesMapsToA2AError() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder() + .streaming(true).pushNotifications(true).build()).build(); + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + GrpcHandler handler = handlerWithVersion(card, customHandler, "2.0"); + StreamRecorder ordinary = StreamRecorder.create(); + StreamRecorder streaming = StreamRecorder.create(); + StreamRecorder push = StreamRecorder.create(); + + handler.getTask(GetTaskRequest.getDefaultInstance(), ordinary); + handler.sendStreamingMessage(SendMessageRequest.getDefaultInstance(), streaming); + handler.deleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigRequest.getDefaultInstance(), push); + + for (StreamRecorder recorder : List.of(ordinary, streaming, push)) { + assertGrpcReason(recorder, Status.Code.UNIMPLEMENTED, "VERSION_NOT_SUPPORTED"); + } + Mockito.verifyNoInteractions(customHandler); + } + + @Test + public void testCanonicalTenantReachesRouterAndCustomHandler() throws Exception { + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + Mockito.when(customHandler.onGetTask(Mockito.any(), Mockito.any())).thenAnswer(invocation -> { + org.a2aproject.sdk.spec.TaskQueryParams params = invocation.getArgument(0); + ServerCallContext context = invocation.getArgument(1); + assertEquals("acme", params.tenant()); + assertEquals("acme", context.getEffectiveAgentCard().tenant()); + Assertions.assertSame(CARD, context.getEffectiveAgentCard().card()); + return MINIMAL_TASK; + }); + AgentCardRouter router = Mockito.mock(AgentCardRouter.class); + Mockito.when(router.resolvePublicCard("acme")).thenReturn(CARD); + GrpcHandler handler = new TestGrpcHandler(CARD, customHandler, internalExecutor) { + @Override + protected AgentCardRouter getAgentCardRouter() { + return router; + } + }; + StreamRecorder recorder = StreamRecorder.create(); + + handler.getTask(GetTaskRequest.newBuilder().setId("task").setTenant("/acme/").build(), recorder); + + Assertions.assertNull(recorder.getError()); + assertEquals(1, recorder.getValues().size()); + Mockito.verify(customHandler).onGetTask(Mockito.any(), Mockito.any()); + Mockito.verify(router).resolvePublicCard("acme"); + Mockito.verifyNoMoreInteractions(router); + } + + @Test + public void testStreamingAndPushUseOneResolvedCardAndCanonicalTenant() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder() + .streaming(true).pushNotifications(true).build()).build(); + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + Mockito.when(customHandler.onMessageSendStream(Mockito.any(), Mockito.any())).thenAnswer(invocation -> { + MessageSendParams params = invocation.getArgument(0); + ServerCallContext context = invocation.getArgument(1); + assertEquals("acme", params.tenant()); + assertEquals("acme", context.getEffectiveAgentCard().tenant()); + Assertions.assertSame(card, context.getEffectiveAgentCard().card()); + return ZeroPublisher.fromIterable(List.of()); + }); + Mockito.doAnswer(invocation -> { + org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams params = invocation.getArgument(0); + ServerCallContext context = invocation.getArgument(1); + assertEquals("acme", params.tenant()); + Assertions.assertSame(card, context.getEffectiveAgentCard().card()); + return null; + }).when(customHandler).onDeleteTaskPushNotificationConfig(Mockito.any(), Mockito.any()); + AgentCardRouter router = Mockito.mock(AgentCardRouter.class); + Mockito.when(router.resolvePublicCard("acme")).thenReturn(card); + GrpcHandler handler = new TestGrpcHandler(CARD, customHandler, internalExecutor) { + @Override + protected AgentCardRouter getAgentCardRouter() { + return router; + } + }; + StreamRecorder streaming = StreamRecorder.create(); + StreamRecorder push = StreamRecorder.create(); + + handler.sendStreamingMessage(SendMessageRequest.newBuilder().setMessage(GRPC_MESSAGE) + .setTenant("/acme/").build(), streaming); + handler.deleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigRequest.newBuilder() + .setTaskId("task").setId("config").setTenant("/acme/").build(), push); + streaming.awaitCompletion(5, TimeUnit.SECONDS); + + Assertions.assertNull(streaming.getError()); + Assertions.assertNull(push.getError()); + assertEquals(1, push.getValues().size()); + Mockito.verify(customHandler).onMessageSendStream(Mockito.any(), Mockito.any()); + Mockito.verify(customHandler).onDeleteTaskPushNotificationConfig(Mockito.any(), Mockito.any()); + Mockito.verify(router, Mockito.times(2)).resolvePublicCard("acme"); + Mockito.verifyNoMoreInteractions(router); + } + + @Test + public void testTransportValidationUsesDeploymentTcclAndRestoresCallerTccl() { + ClassLoader callerTccl = Thread.currentThread().getContextClassLoader(); + AtomicInteger validations = new AtomicInteger(); + ClassLoader deploymentCl = new ClassLoader(callerTccl) { + @Override + public Enumeration getResources(String name) throws IOException { + if (name.equals("META-INF/services/" + TransportMetadata.class.getName())) { + Assertions.assertSame(this, Thread.currentThread().getContextClassLoader()); + validations.incrementAndGet(); + } + return super.getResources(name); + } + }; + GrpcHandler handler = new TestGrpcHandler(CARD, requestHandler, internalExecutor) { + @Override + protected ClassLoader getDeploymentClassLoader() { + return deploymentCl; + } + }; + StreamRecorder recorder = StreamRecorder.create(); + + handler.getTask(GetTaskRequest.newBuilder().setId("missing").build(), recorder); + + assertGrpcError(recorder, Status.Code.NOT_FOUND); + assertEquals(1, validations.get()); + Assertions.assertSame(callerTccl, Thread.currentThread().getContextClassLoader()); + } + + @Test + public void testConcurrentRequestsWaitForTransportValidation() throws Exception { + CountDownLatch validating = new CountDownLatch(1); + CountDownLatch releaseValidation = new CountDownLatch(1); + CountDownLatch secondResolving = new CountDownLatch(1); + AtomicInteger resolutions = new AtomicInteger(); + AtomicInteger validations = new AtomicInteger(); + ClassLoader deploymentCl = new ClassLoader(Thread.currentThread().getContextClassLoader()) { + @Override + public Enumeration getResources(String name) throws IOException { + if (name.equals("META-INF/services/" + TransportMetadata.class.getName())) { + validations.incrementAndGet(); + validating.countDown(); + try { + if (!releaseValidation.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release transport validation"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + return super.getResources(name); + } + }; + GrpcHandler handler = new TestGrpcHandler(CARD, requestHandler, internalExecutor) { + @Override + protected AgentCard getAgentCard() { + if (resolutions.incrementAndGet() == 2) { + secondResolving.countDown(); + } + return CARD; + } + + @Override + protected ClassLoader getDeploymentClassLoader() { + return deploymentCl; + } + }; + ExecutorService callers = Executors.newFixedThreadPool(2); + StreamRecorder first = StreamRecorder.create(); + StreamRecorder second = StreamRecorder.create(); + GetTaskRequest request = GetTaskRequest.newBuilder().setId("missing").build(); + try { + Future firstCall = callers.submit(() -> handler.getTask(request, first)); + Assertions.assertTrue(validating.await(5, TimeUnit.SECONDS)); + Future secondCall = callers.submit(() -> handler.getTask(request, second)); + Assertions.assertTrue(secondResolving.await(5, TimeUnit.SECONDS)); + Assertions.assertThrows(TimeoutException.class, () -> secondCall.get(200, TimeUnit.MILLISECONDS)); + releaseValidation.countDown(); + firstCall.get(5, TimeUnit.SECONDS); + secondCall.get(5, TimeUnit.SECONDS); + assertGrpcError(first, Status.Code.NOT_FOUND); + assertGrpcError(second, Status.Code.NOT_FOUND); + assertEquals(1, validations.get()); + } finally { + releaseValidation.countDown(); + callers.shutdownNow(); + } + } + + @Test + public void testExtendedCardCapabilityFailurePrecedesInvalidVersion() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().build()).build(); + GrpcHandler handler = handlerWithVersion(card, requestHandler, "2.0"); + StreamRecorder recorder = StreamRecorder.create(); + + handler.getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.getDefaultInstance(), recorder); + + assertGrpcReason(recorder, Status.Code.UNIMPLEMENTED, "UNSUPPORTED_OPERATION"); + } + + @Test + public void testExtendedCardMissingAfterCapabilitySuccess() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + GrpcHandler handler = new TestGrpcHandler(card, requestHandler, internalExecutor) { + @Override + protected AgentCard getExtendedAgentCard() { + return null; + } + }; + StreamRecorder recorder = StreamRecorder.create(); + + handler.getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.getDefaultInstance(), recorder); + + assertGrpcReason(recorder, Status.Code.FAILED_PRECONDITION, "EXTENDED_AGENT_CARD_NOT_CONFIGURED"); + } + + @Test + public void testExtendedCardInvalidVersionMapsToA2AError() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + GrpcHandler handler = handlerWithVersion(card, requestHandler, "2.0"); + StreamRecorder recorder = StreamRecorder.create(); + + handler.getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.getDefaultInstance(), recorder); + + assertGrpcReason(recorder, Status.Code.UNIMPLEMENTED, "VERSION_NOT_SUPPORTED"); + } + + @Test + public void testExtendedCardInvalidTenantRetainsInternalMapping() throws Exception { + GrpcHandler handler = new TestGrpcHandler(CARD, requestHandler, internalExecutor); + StreamRecorder recorder = StreamRecorder.create(); + + handler.getExtendedAgentCard(org.a2aproject.sdk.grpc.GetExtendedAgentCardRequest.newBuilder() + .setTenant("bad/tenant").build(), recorder); + + assertGrpcReason(recorder, Status.Code.INTERNAL, "INTERNAL"); + } + + private GrpcHandler handlerWithVersion(AgentCard card, RequestHandler customHandler, String version) { + return new TestGrpcHandler(card, customHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver observer) { + return new ServerCallContext(UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", observer), Set.of(), version); + } + }; + } + }; + } + + private void assertGrpcReason(StreamRecorder recorder, Status.Code code, String reason) throws Exception { + assertGrpcError(recorder, code); + com.google.rpc.Status status = io.grpc.protobuf.StatusProto.fromThrowable(recorder.getError()); + assertEquals(reason, status.getDetails(0).unpack(com.google.rpc.ErrorInfo.class).getReason()); + } + private static class TestGrpcHandler extends GrpcHandler { private final AgentCard card; diff --git a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java index 4eebb2fe3..a15a181d5 100644 --- a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java @@ -1,8 +1,11 @@ package org.a2aproject.sdk.transport.jsonrpc.handler; +import static org.a2aproject.sdk.server.AgentCardOperation.EXTENDED_CARD; +import static org.a2aproject.sdk.server.AgentCardOperation.PUSH_NOTIFICATIONS; +import static org.a2aproject.sdk.server.AgentCardOperation.STREAMING; + import static org.a2aproject.sdk.server.util.async.AsyncUtils.createTubeConfig; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.Flow; @@ -37,34 +40,41 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.SubscribeToTaskRequest; +import org.a2aproject.sdk.server.AgentCardOperation; +import org.a2aproject.sdk.server.AgentCardRequestPolicy; +import org.a2aproject.sdk.server.AgentCardResolver; import org.a2aproject.sdk.server.AgentCardValidator; +import org.a2aproject.sdk.server.CachingAgentCardTransportValidator; +import org.a2aproject.sdk.server.EffectiveAgentCard; import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.FixedInstance; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskOperation; -import org.a2aproject.sdk.server.extensions.A2AExtensions; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; -import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.util.async.Internal; -import org.a2aproject.sdk.server.version.A2AVersionValidator; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.EventKind; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.InternalError; import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; -import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; -import org.a2aproject.sdk.spec.UnsupportedOperationError; -import org.a2aproject.sdk.spec.util.Utils; +import org.a2aproject.sdk.spec.TaskQueryParams; import org.jspecify.annotations.Nullable; /** @@ -150,9 +160,10 @@ public class JSONRPCHandler { private @Nullable Instance extendedAgentCard; private RequestHandler requestHandler; private Executor executor; - private final Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - private @Nullable AgentCardRouter agentCardRouter; + private final CachingAgentCardTransportValidator transportValidator = + new CachingAgentCardTransportValidator(AgentCardValidator::validateTransportConfiguration); + private AgentCardResolver agentCardResolver; + private AgentCardRequestPolicy requestPolicy; /** * No-args constructor for CDI proxy creation. @@ -166,6 +177,8 @@ protected JSONRPCHandler() { this.extendedAgentCard = null; this.requestHandler = null; this.executor = null; + this.agentCardResolver = null; + this.requestPolicy = null; } /** @@ -186,7 +199,9 @@ public JSONRPCHandler(@PublicAgentCard Instance agentCardInstance, this.extendedAgentCard = extendedAgentCard; this.requestHandler = requestHandler; this.executor = executor; - this.agentCardRouter = CdiUtils.getIfResolvable(agentCardRouterInstance); + this.agentCardResolver = AgentCardResolver.of(() -> CdiUtils.resolveDefault(this.agentCardInstance), + () -> CdiUtils.resolveDefault(this.extendedAgentCard), CdiUtils.getIfResolvable(agentCardRouterInstance)); + this.requestPolicy = new AgentCardRequestPolicy(agentCardResolver); } /** @@ -246,8 +261,10 @@ public JSONRPCHandler(@Nullable AgentCard agentCard, RequestHandler requestHandl public SendMessageResponse onMessageSend(SendMessageRequest request, ServerCallContext context) { try { String tenant = request.getParams() != null ? request.getParams().tenant() : null; - validateVersionAndExtensions(tenant, context); - EventKind taskOrMessage = requestHandler.onMessageSend(request.getParams(), context); + EffectiveAgentCard effective = preflight(tenant, context, AgentCardOperation.none()); + MessageSendParams params = request.getParams(); + EventKind taskOrMessage = requestHandler.onMessageSend(new MessageSendParams(params.message(), + params.configuration(), params.metadata(), effective.tenant()), context); return new SendMessageResponse(request.getId(), taskOrMessage); } catch (A2AError e) { return new SendMessageResponse(request.getId(), e); @@ -296,15 +313,11 @@ public Flow.Publisher onMessageSendStream( SendStreamingMessageRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - return ZeroPublisher.fromItems( - new SendStreamingMessageResponse( - request.getId(), - new UnsupportedOperationError(null, "Streaming is not supported by the agent", null))); - } + EffectiveAgentCard effective = preflight(tenant, context, STREAMING); + MessageSendParams params = request.getParams(); Flow.Publisher publisher = - requestHandler.onMessageSendStream(request.getParams(), context); + requestHandler.onMessageSendStream(new MessageSendParams(params.message(), params.configuration(), + params.metadata(), effective.tenant()), context); // We can't use the convertingProcessor convenience method since that propagates any errors as an error handled // via Subscriber.onError() rather than as part of the SendStreamingResponse payload return convertToSendStreamingMessageResponse(request.getId(), publisher); @@ -343,8 +356,9 @@ public Flow.Publisher onMessageSendStream( public CancelTaskResponse onCancelTask(CancelTaskRequest request, ServerCallContext context) { try { String tenant = request.getParams() != null ? request.getParams().tenant() : null; - validateVersionAndExtensions(tenant, context); - Task task = requestHandler.onCancelTask(request.getParams(), context); + EffectiveAgentCard effective = preflight(tenant, context, AgentCardOperation.none()); + CancelTaskParams params = request.getParams(); + Task task = requestHandler.onCancelTask(new CancelTaskParams(params.id(), effective.tenant(), params.metadata()), context); if (task != null) { return new CancelTaskResponse(request.getId(), task); } @@ -393,21 +407,16 @@ public CancelTaskResponse onCancelTask(CancelTaskRequest request, ServerCallCont public Flow.Publisher onSubscribeToTask( SubscribeToTaskRequest request, ServerCallContext context) throws A2AError { String tenant = request.getParams() != null ? request.getParams().tenant() : null; + EffectiveAgentCard effective; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - return ZeroPublisher.fromItems( - new SendStreamingMessageResponse( - request.getId(), - new UnsupportedOperationError(null, "Streaming is not supported by the agent", null))); - } + effective = preflight(tenant, context, STREAMING); } catch (A2AError e) { return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), e)); } requestHandler.authorizeTaskAccess(request.getParams().id(), context, TaskOperation.SUBSCRIBE_TO_TASK); try { Flow.Publisher publisher = - requestHandler.onSubscribeToTask(request.getParams(), context); + requestHandler.onSubscribeToTask(new TaskIdParams(request.getParams().id(), effective.tenant()), context); // We can't use the convertingProcessor convenience method since that propagates any errors as an error handled // via Subscriber.onError() rather than as part of the SendStreamingResponse payload return convertToSendStreamingMessageResponse(request.getId(), publisher); @@ -447,13 +456,11 @@ public GetTaskPushNotificationConfigResponse getPushNotificationConfig( GetTaskPushNotificationConfigRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - return new GetTaskPushNotificationConfigResponse(request.getId(), - new PushNotificationNotSupportedError()); - } + EffectiveAgentCard effective = preflight(tenant, context, PUSH_NOTIFICATIONS); + GetTaskPushNotificationConfigParams params = request.getParams(); TaskPushNotificationConfig config = - requestHandler.onGetTaskPushNotificationConfig(request.getParams(), context); + requestHandler.onGetTaskPushNotificationConfig(new GetTaskPushNotificationConfigParams( + params.taskId(), params.id(), effective.tenant()), context); return new GetTaskPushNotificationConfigResponse(request.getId(), config); } catch (A2AError e) { return new GetTaskPushNotificationConfigResponse(request.getId(), e); @@ -491,13 +498,11 @@ public CreateTaskPushNotificationConfigResponse setPushNotificationConfig( CreateTaskPushNotificationConfigRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - return new CreateTaskPushNotificationConfigResponse(request.getId(), - new PushNotificationNotSupportedError()); - } + EffectiveAgentCard effective = preflight(tenant, context, PUSH_NOTIFICATIONS); + TaskPushNotificationConfig params = request.getParams(); TaskPushNotificationConfig config = - requestHandler.onCreateTaskPushNotificationConfig(request.getParams(), context); + requestHandler.onCreateTaskPushNotificationConfig(new TaskPushNotificationConfig(params.id(), + params.taskId(), params.url(), params.token(), params.authentication(), effective.tenant()), context); return new CreateTaskPushNotificationConfigResponse(request.getId(), config); } catch (A2AError e) { return new CreateTaskPushNotificationConfigResponse(request.getId(), e); @@ -533,8 +538,9 @@ public CreateTaskPushNotificationConfigResponse setPushNotificationConfig( public GetTaskResponse onGetTask(GetTaskRequest request, ServerCallContext context) { try { String tenant = request.getParams() != null ? request.getParams().tenant() : null; - validateVersionAndExtensions(tenant, context); - Task task = requestHandler.onGetTask(request.getParams(), context); + EffectiveAgentCard effective = preflight(tenant, context, AgentCardOperation.none()); + TaskQueryParams params = request.getParams(); + Task task = requestHandler.onGetTask(new TaskQueryParams(params.id(), params.historyLength(), effective.tenant()), context); return new GetTaskResponse(request.getId(), task); } catch (A2AError e) { return new GetTaskResponse(request.getId(), e); @@ -582,8 +588,11 @@ public GetTaskResponse onGetTask(GetTaskRequest request, ServerCallContext conte public ListTasksResponse onListTasks(ListTasksRequest request, ServerCallContext context) { try { String tenant = request.getParams() != null ? request.getParams().tenant() : null; - validateVersionAndExtensions(tenant, context); - ListTasksResult result = requestHandler.onListTasks(request.getParams(), context); + EffectiveAgentCard effective = preflight(tenant, context, AgentCardOperation.none()); + ListTasksParams params = request.getParams(); + ListTasksResult result = requestHandler.onListTasks(new ListTasksParams(params.contextId(), params.status(), + params.pageSize(), params.pageToken(), params.historyLength(), params.statusTimestampAfter(), + params.includeArtifacts(), effective.tenant()), context); return new ListTasksResponse(request.getId(), result); } catch (A2AError e) { return new ListTasksResponse(request.getId(), e); @@ -620,13 +629,11 @@ public ListTaskPushNotificationConfigsResponse listPushNotificationConfigs( ListTaskPushNotificationConfigsRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - return new ListTaskPushNotificationConfigsResponse(request.getId(), - new PushNotificationNotSupportedError()); - } + EffectiveAgentCard effective = preflight(tenant, context, PUSH_NOTIFICATIONS); + ListTaskPushNotificationConfigsParams params = request.getParams(); ListTaskPushNotificationConfigsResult result = - requestHandler.onListTaskPushNotificationConfigs(request.getParams(), context); + requestHandler.onListTaskPushNotificationConfigs(new ListTaskPushNotificationConfigsParams( + params.id(), params.pageSize(), params.pageToken(), effective.tenant()), context); return new ListTaskPushNotificationConfigsResponse(request.getId(), result); } catch (A2AError e) { return new ListTaskPushNotificationConfigsResponse(request.getId(), e); @@ -664,12 +671,10 @@ public DeleteTaskPushNotificationConfigResponse deletePushNotificationConfig( DeleteTaskPushNotificationConfigRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - return new DeleteTaskPushNotificationConfigResponse(request.getId(), - new PushNotificationNotSupportedError()); - } - requestHandler.onDeleteTaskPushNotificationConfig(request.getParams(), context); + EffectiveAgentCard effective = preflight(tenant, context, PUSH_NOTIFICATIONS); + DeleteTaskPushNotificationConfigParams params = request.getParams(); + requestHandler.onDeleteTaskPushNotificationConfig(new DeleteTaskPushNotificationConfigParams( + params.taskId(), params.id(), effective.tenant()), context); return new DeleteTaskPushNotificationConfigResponse(request.getId()); } catch (A2AError e) { return new DeleteTaskPushNotificationConfigResponse(request.getId(), e); @@ -706,19 +711,8 @@ public GetExtendedAgentCardResponse onGetExtendedCardRequest( GetExtendedAgentCardRequest request, ServerCallContext context) { String tenant = request.getParams() != null ? request.getParams().tenant() : null; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().extendedAgentCard()) { - return new GetExtendedAgentCardResponse(request.getId(), new UnsupportedOperationError()); - } - if (tenant != null && !tenant.isBlank() && agentCardRouter != null) { - AgentCard card = agentCardRouter.resolveExtendedCard(tenant); - if (card == null) { - return new GetExtendedAgentCardResponse(request.getId(), - new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); - } - return new GetExtendedAgentCardResponse(request.getId(), card); - } - AgentCard extCard = CdiUtils.resolveDefault(extendedAgentCard); + EffectiveAgentCard effective = preflight(tenant, context, EXTENDED_CARD); + AgentCard extCard = agentCardResolver.resolveExtendedDiscoveryCard(effective.tenant()); if (extCard == null) { return new GetExtendedAgentCardResponse(request.getId(), new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null)); @@ -726,15 +720,13 @@ public GetExtendedAgentCardResponse onGetExtendedCardRequest( return new GetExtendedAgentCardResponse(request.getId(), extCard); } catch (A2AError e) { return new GetExtendedAgentCardResponse(request.getId(), e); - } catch(TenantNotFoundException ex) { - return new GetExtendedAgentCardResponse(request.getId(), new InvalidParamsError(ex.getResponseMessage())); } catch (Throwable t) { return new GetExtendedAgentCardResponse(request.getId(), internalError(t)); } } /** - * Validates the requested protocol version and extensions against the agent card. + * Applies shared card policy at the decoded-request dispatch boundary. * *

Section 3.6.2 of the specification requires this on every request, so every operation * calls it before reaching the request handler. @@ -742,11 +734,9 @@ public GetExtendedAgentCardResponse onGetExtendedCardRequest( * @param context the server call context carrying the requested version and extensions * @throws A2AError if the requested version or a required extension is not supported */ - private void validateVersionAndExtensions(@Nullable String tenant, ServerCallContext context) throws A2AError { + private EffectiveAgentCard preflight(@Nullable String tenant, ServerCallContext context, AgentCardOperation operation) { try { - AgentCard agentCard = resolveAgentCard(tenant); - A2AVersionValidator.validateProtocolVersion(agentCard, context); - A2AExtensions.validateRequiredExtensions(agentCard, context); + return requestPolicy.preflight(tenant, context, transportValidator, operation); } catch (TenantNotFoundException ex) { throw new InvalidParamsError(ex.getResponseMessage()); } @@ -773,7 +763,7 @@ private void validateVersionAndExtensions(@Nullable String tenant, ServerCallCon * resolves the card. If the router returns {@code null} the tenant is unknown and a * {@link TenantNotFoundException} is thrown (callers should map this to HTTP 404). *

- * When no router is configured, any non-blank tenant is logged and the default card + * When no router is configured, the default card * is returned (single-tenant server, tenant segment is ignored). * * @param tenant the tenant identifier, may be {@code null} @@ -781,37 +771,11 @@ private void validateVersionAndExtensions(@Nullable String tenant, ServerCallCon * @throws TenantNotFoundException if a router is configured but the tenant is not registered */ public @Nullable AgentCard getAgentCard(@Nullable String tenant) { - Utils.validateTenant(tenant); - if (tenant != null && !tenant.isBlank()) { - if (agentCardRouter != null) { - AgentCard card = agentCardRouter.resolvePublicCard(tenant); - if (card != null) { - return card; - } - LOGGER.fine(() -> "Tenant '" + tenant + "' not found in AgentCardRouter — returning 404"); - throw new TenantNotFoundException(tenant); - } - LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); - } - AgentCard card = CdiUtils.resolveDefault(agentCardInstance); - if (card == null && agentCardRouter != null) { - card = agentCardRouter.resolvePublicCard(tenant); - } - if (card == null) { - return null; - } - AgentCard validatedCard = card; - return AgentCardValidator.resolveAndValidateOnce(() -> validatedCard, validatedCards, - AgentCardValidator::validateTransportConfiguration); - } - - private AgentCard resolveAgentCard(@Nullable String tenant) { - try { - return AgentCardValidator.resolveWithFallback(agentCardInstance, extendedAgentCard, - agentCardRouter, tenant, validatedCards); - } catch (TenantNotFoundException ex) { - throw new InvalidParamsError(ex.getResponseMessage()); + AgentCard card = agentCardResolver.resolvePublicDiscoveryCard(tenant); + if (card != null) { + transportValidator.validate(card); } + return card; } private Flow.Publisher convertToSendStreamingMessageResponse( diff --git a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 3e3fed012..cb3cd9527 100644 --- a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -25,6 +25,7 @@ import jakarta.enterprise.inject.Instance; +import mutiny.zero.ZeroPublisher; import org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.CreateTaskPushNotificationConfigRequest; @@ -47,14 +48,15 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageResponse; import org.a2aproject.sdk.jsonrpc.common.wrappers.SubscribeToTaskRequest; - import org.a2aproject.sdk.server.FixedInstance; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.TestInstances; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.events.EventConsumer; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.requesthandlers.AbstractA2ARequestHandlerTest; import org.a2aproject.sdk.server.requesthandlers.DefaultRequestHandler; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.server.tasks.ResultAggregator; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.AgentCapabilities; @@ -70,7 +72,9 @@ import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendConfiguration; @@ -87,13 +91,8 @@ import org.a2aproject.sdk.spec.TaskStatus; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; import org.a2aproject.sdk.spec.TextPart; -import org.a2aproject.sdk.server.TestInstances; import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.spec.VersionNotSupportedError; - -import jakarta.enterprise.inject.Instance; - -import mutiny.zero.ZeroPublisher; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -113,6 +112,205 @@ private static MessageSendConfiguration defaultConfiguration() { .build(); } + @Test + void canonicalTenantAndEffectiveCardReachCustomHandlerForEveryOperation() throws Exception { + for (String operation : List.of("send", "stream", "get", "list", "cancel", "subscribe", "createPush", "getPush", "listPush", "deletePush")) { + assertCanonicalTenantDispatch(operation); + } + } + + private void assertCanonicalTenantDispatch(String operation) throws Exception { + ServerCallContext callContext = new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of(), Set.of(), "1.0"); + AtomicReference expectedParams = new AtomicReference<>(); + AtomicBoolean dispatched = new AtomicBoolean(); + AgentCard card = versionTestCard(); + RequestHandler customHandler = Mockito.mock(RequestHandler.class, invocation -> { + if (invocation.getMethod().getName().equals("authorizeTaskAccess")) { + return null; + } + assertEquals(expectedParams.get(), invocation.getArgument(0)); + ServerCallContext context = invocation.getArgument(1); + assertNotNull(context.getEffectiveAgentCard()); + assertEquals("acme", context.getEffectiveAgentCard().tenant()); + Assertions.assertSame(card, context.getEffectiveAgentCard().card()); + dispatched.set(true); + return switch (operation) { + case "send", "get", "cancel" -> MINIMAL_TASK; + case "stream", "subscribe" -> ZeroPublisher.fromItems(MINIMAL_TASK); + case "list" -> new ListTasksResult(List.of()); + case "createPush", "getPush" -> new TaskPushNotificationConfig("config", "task", "https://example.com", null, null, "acme"); + case "listPush" -> new ListTaskPushNotificationConfigsResult(List.of()); + default -> null; + }; + }); + AgentCardRouter router = new AgentCardRouter() { + @Override + public AgentCard resolvePublicCard(String tenant) { + assertEquals("acme", tenant); + return card; + } + + @Override + public AgentCard resolveExtendedCard(String tenant) { + return null; + } + }; + JSONRPCHandler handler = new JSONRPCHandler(new FixedInstance<>(card), null, customHandler, + internalExecutor, new FixedInstance<>(router)); + A2AError error; + switch (operation) { + case "send", "stream" -> { + expectedParams.set(new MessageSendParams(MESSAGE, defaultConfiguration(), Map.of("key", "value"), "acme")); + MessageSendParams params = new MessageSendParams(MESSAGE, defaultConfiguration(), Map.of("key", "value"), "/acme/"); + error = operation.equals("send") + ? handler.onMessageSend(new SendMessageRequest("1", params), callContext).getError() + : drainStream(handler.onMessageSendStream(new SendStreamingMessageRequest("1", params), callContext)).get(0).getError(); + } + case "get" -> { + expectedParams.set(new TaskQueryParams("task", 3, "acme")); + error = handler.onGetTask(new GetTaskRequest("1", new TaskQueryParams("task", 3, "/acme/")), callContext).getError(); + } + case "list" -> { + expectedParams.set(new ListTasksParams("context", TaskState.TASK_STATE_WORKING, 7, "page", 3, null, true, "acme")); + error = handler.onListTasks(new ListTasksRequest("1", new ListTasksParams("context", TaskState.TASK_STATE_WORKING, + 7, "page", 3, null, true, "/acme/")), callContext).getError(); + } + case "cancel" -> { + expectedParams.set(new CancelTaskParams("task", "acme", Map.of("key", "value"))); + error = handler.onCancelTask(new CancelTaskRequest("1", new CancelTaskParams("task", "/acme/", Map.of("key", "value"))), callContext).getError(); + } + case "subscribe" -> { + expectedParams.set(new TaskIdParams("task", "acme")); + error = drainStream(handler.onSubscribeToTask(new SubscribeToTaskRequest("1", new TaskIdParams("task", "/acme/")), callContext)).get(0).getError(); + } + case "createPush" -> { + expectedParams.set(new TaskPushNotificationConfig("config", "task", "https://example.com", "token", null, "acme")); + error = handler.setPushNotificationConfig(new CreateTaskPushNotificationConfigRequest("1", + new TaskPushNotificationConfig("config", "task", "https://example.com", "token", null, "/acme/")), callContext).getError(); + } + case "getPush" -> { + expectedParams.set(new GetTaskPushNotificationConfigParams("task", "config", "acme")); + error = handler.getPushNotificationConfig(new GetTaskPushNotificationConfigRequest("1", + new GetTaskPushNotificationConfigParams("task", "config", "/acme/")), callContext).getError(); + } + case "listPush" -> { + expectedParams.set(new ListTaskPushNotificationConfigsParams("task", 7, "page", "acme")); + error = handler.listPushNotificationConfigs(new ListTaskPushNotificationConfigsRequest("1", + new ListTaskPushNotificationConfigsParams("task", 7, "page", "/acme/")), callContext).getError(); + } + case "deletePush" -> { + expectedParams.set(new DeleteTaskPushNotificationConfigParams("task", "config", "acme")); + error = handler.deletePushNotificationConfig(new DeleteTaskPushNotificationConfigRequest("1", + new DeleteTaskPushNotificationConfigParams("task", "config", "/acme/")), callContext).getError(); + } + default -> throw new AssertionError(operation); + } + assertNull(error); + assertTrue(dispatched.get(), "The custom handler must receive the canonical params"); + } + + @Test + void defaultTenantSpellingsReachCustomHandlerAsNull() { + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + Mockito.when(customHandler.onGetTask(Mockito.any(), Mockito.any())).thenAnswer(invocation -> { + TaskQueryParams params = invocation.getArgument(0); + assertNull(params.tenant()); + ServerCallContext context = invocation.getArgument(1); + assertNull(context.getEffectiveAgentCard().tenant()); + return MINIMAL_TASK; + }); + JSONRPCHandler handler = new JSONRPCHandler(CARD, customHandler, internalExecutor); + + for (String tenant : List.of("", " ", "/")) { + ServerCallContext context = new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of(), Set.of(), "1.0"); + assertNull(handler.onGetTask(new GetTaskRequest("1", new TaskQueryParams("task", null, tenant)), context).getError()); + } + Mockito.verify(customHandler, Mockito.times(3)).onGetTask(Mockito.any(), Mockito.any()); + } + + @Test + void invalidDecodedTenantWinsOverUnsupportedVersion() { + // Constructors reject invalid tenants; model an already-decoded integration input directly. + TaskQueryParams params = Mockito.mock(TaskQueryParams.class); + Mockito.when(params.tenant()).thenReturn("bad/tenant"); + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + JSONRPCHandler handler = new JSONRPCHandler(versionTestCard(), customHandler, internalExecutor); + + GetTaskResponse response = handler.onGetTask(new GetTaskRequest("1", params), incompatibleVersionContext()); + + assertInstanceOf(InvalidParamsError.class, response.getError()); + Mockito.verifyNoInteractions(customHandler); + } + + @Test + void extendedDiscoveryDistinguishesDisabledCapabilityFromMissingCard() { + JSONRPCHandler disabled = new JSONRPCHandler(AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().extendedAgentCard(false).build()).build(), requestHandler, internalExecutor); + JSONRPCHandler missing = new JSONRPCHandler(versionTestCard(), requestHandler, internalExecutor); + + assertInstanceOf(UnsupportedOperationError.class, + disabled.onGetExtendedCardRequest(new GetExtendedAgentCardRequest("1"), callContext).getError()); + GetExtendedAgentCardResponse missingResponse = missing.onGetExtendedCardRequest(new GetExtendedAgentCardRequest("1"), callContext); + assertInstanceOf(ExtendedAgentCardNotConfiguredError.class, missingResponse.getError()); + assertEquals("Extended Card not configured", missingResponse.getError().getMessage()); + } + + @Test + void malformedTenantExtendedDiscoveryReturnsInvalidParams() { + GetExtendedAgentCardParams params = Mockito.mock(GetExtendedAgentCardParams.class); + Mockito.when(params.tenant()).thenReturn("bad/tenant"); + JSONRPCHandler handler = versionTestHandler(); + + assertInstanceOf(InvalidParamsError.class, handler.onGetExtendedCardRequest( + new GetExtendedAgentCardRequest("2.0", "1", params), callContext).getError()); + } + + @Test + void routedExtendedDiscoveryUsesCanonicalTenant() { + AgentCard card = versionTestCard(); + AgentCard extended = AgentCard.builder(card).description("extended").build(); + AgentCardRouter router = new AgentCardRouter() { + @Override + public AgentCard resolvePublicCard(String tenant) { + assertEquals("acme", tenant); + return card; + } + + @Override + public AgentCard resolveExtendedCard(String tenant) { + assertEquals("acme", tenant); + return extended; + } + }; + JSONRPCHandler handler = new JSONRPCHandler(new FixedInstance<>(card), null, requestHandler, + internalExecutor, new FixedInstance<>(router)); + + GetExtendedAgentCardResponse response = handler.onGetExtendedCardRequest( + new GetExtendedAgentCardRequest("2.0", "1", new GetExtendedAgentCardParams("/acme/")), callContext); + + assertNull(response.getError()); + Assertions.assertSame(extended, response.getResult()); + } + + @Test + void capabilityErrorsRetainStreamingMessageAndPushErrorType() throws Exception { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().build()).build(); + RequestHandler customHandler = Mockito.mock(RequestHandler.class); + JSONRPCHandler handler = new JSONRPCHandler(card, customHandler, internalExecutor); + SendStreamingMessageResponse streaming = drainStream(handler.onMessageSendStream( + new SendStreamingMessageRequest("1", new MessageSendParams(MESSAGE, null, null)), callContext)).get(0); + SendStreamingMessageResponse subscription = drainStream(handler.onSubscribeToTask( + new SubscribeToTaskRequest("1", new TaskIdParams("task")), callContext)).get(0); + + assertInstanceOf(UnsupportedOperationError.class, streaming.getError()); + assertEquals("Streaming is not supported by the agent", streaming.getError().getMessage()); + assertInstanceOf(UnsupportedOperationError.class, subscription.getError()); + assertEquals("Streaming is not supported by the agent", subscription.getError().getMessage()); + assertInstanceOf(PushNotificationNotSupportedError.class, handler.getPushNotificationConfig( + new GetTaskPushNotificationConfigRequest("1", new GetTaskPushNotificationConfigParams("task", "config")), callContext).getError()); + Mockito.verifyNoInteractions(customHandler); + } + @Test public void testOnGetTaskSuccess() throws Exception { JSONRPCHandler handler = new JSONRPCHandler(CARD, requestHandler, internalExecutor); @@ -711,7 +909,7 @@ public void testGetPushNotificationConfigSuccess() { = new GetTaskPushNotificationConfigRequest("111", new GetTaskPushNotificationConfigParams( MINIMAL_TASK.id(), "c295ea44-7543-4f78-b524-7a38915ad6e4")); - GetTaskPushNotificationConfigResponse getResponse = handler.getPushNotificationConfig(getRequest, callContext); + GetTaskPushNotificationConfigResponse getResponse = handler.getPushNotificationConfig(getRequest, newCallContext()); TaskPushNotificationConfig expectedConfig = TaskPushNotificationConfig.builder() .id("c295ea44-7543-4f78-b524-7a38915ad6e4") @@ -774,7 +972,7 @@ public void testOnMessageStreamNewMessageSendPushNotificationSuccess() throws Ex .taskId(MINIMAL_TASK.id()) .build(); SendStreamingMessageRequest request = new SendStreamingMessageRequest("1", new MessageSendParams(msg, null, null)); - Flow.Publisher response = handler.onMessageSendStream(request, callContext); + Flow.Publisher response = handler.onMessageSendStream(request, newCallContext()); final List results = Collections.synchronizedList(new ArrayList<>()); final AtomicReference subscriptionRef = new AtomicReference<>(); @@ -1404,7 +1602,7 @@ public void testListPushNotificationConfig() { .build(); ListTaskPushNotificationConfigsRequest listRequest = new ListTaskPushNotificationConfigsRequest("111", new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())); - ListTaskPushNotificationConfigsResponse listResponse = handler.listPushNotificationConfigs(listRequest, callContext); + ListTaskPushNotificationConfigsResponse listResponse = handler.listPushNotificationConfigs(listRequest, newCallContext()); assertEquals("111", listResponse.getId()); assertEquals(1, listResponse.getResult().size()); @@ -1502,7 +1700,7 @@ public void testDeletePushNotificationConfig() { DeleteTaskPushNotificationConfigRequest deleteRequest = new DeleteTaskPushNotificationConfigRequest("111", new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.id(), MINIMAL_TASK.id())); DeleteTaskPushNotificationConfigResponse deleteResponse - = handler.deletePushNotificationConfig(deleteRequest, callContext); + = handler.deletePushNotificationConfig(deleteRequest, newCallContext()); assertEquals("111", deleteResponse.getId()); assertNull(deleteResponse.getError()); @@ -1531,7 +1729,7 @@ public void testDeletePushNotificationConfigNotSupported() { DeleteTaskPushNotificationConfigRequest deleteRequest = new DeleteTaskPushNotificationConfigRequest("111", new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.id(), MINIMAL_TASK.id())); DeleteTaskPushNotificationConfigResponse deleteResponse - = handler.deletePushNotificationConfig(deleteRequest, callContext); + = handler.deletePushNotificationConfig(deleteRequest, newCallContext()); assertEquals("111", deleteResponse.getId()); assertNull(deleteResponse.getResult()); @@ -1566,7 +1764,7 @@ public void testDeletePushNotificationConfigNoPushConfigStore() { DeleteTaskPushNotificationConfigRequest deleteRequest = new DeleteTaskPushNotificationConfigRequest("111", new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.id(), MINIMAL_TASK.id())); DeleteTaskPushNotificationConfigResponse deleteResponse - = handler.deletePushNotificationConfig(deleteRequest, callContext); + = handler.deletePushNotificationConfig(deleteRequest, newCallContext()); assertEquals("111", deleteResponse.getId()); assertNull(deleteResponse.getResult()); @@ -2283,6 +2481,10 @@ private static ServerCallContext incompatibleVersionContext() { return new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of("foo", "bar"), new HashSet<>(), "2.0"); } + private static ServerCallContext newCallContext() { + return new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of("foo", "bar"), new HashSet<>(), "1.0"); + } + /** * Asserts that an operation refused the call over its version rather than for some other * reason, by checking that the rejected version is named in the message. diff --git a/transport/rest/pom.xml b/transport/rest/pom.xml index 476427a00..2636bde37 100644 --- a/transport/rest/pom.xml +++ b/transport/rest/pom.xml @@ -51,6 +51,11 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java index 019f486da..ae48c890a 100644 --- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java @@ -1,5 +1,9 @@ package org.a2aproject.sdk.transport.rest.handler; +import static org.a2aproject.sdk.server.AgentCardOperation.EXTENDED_CARD; +import static org.a2aproject.sdk.server.AgentCardOperation.PUSH_NOTIFICATIONS; +import static org.a2aproject.sdk.server.AgentCardOperation.STREAMING; + import static org.a2aproject.sdk.common.MediaType.APPLICATION_JSON; import static org.a2aproject.sdk.server.util.async.AsyncUtils.createTubeConfig; @@ -9,7 +13,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.Flow; @@ -33,19 +36,22 @@ import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.AgentCardCacheMetadata; +import org.a2aproject.sdk.server.AgentCardOperation; +import org.a2aproject.sdk.server.AgentCardRequestPolicy; +import org.a2aproject.sdk.server.AgentCardResolver; import org.a2aproject.sdk.server.AgentCardValidator; +import org.a2aproject.sdk.server.CachingAgentCardTransportValidator; +import org.a2aproject.sdk.server.EffectiveAgentCard; import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.FixedInstance; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.TaskOperation; -import org.a2aproject.sdk.server.extensions.A2AExtensions; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; import org.a2aproject.sdk.server.multitenancy.TenantNotFoundException; -import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.requesthandlers.RequestHandler; +import org.a2aproject.sdk.server.util.CdiUtils; import org.a2aproject.sdk.server.util.async.Internal; -import org.a2aproject.sdk.server.version.A2AVersionValidator; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.AgentCard; @@ -62,7 +68,6 @@ import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.MessageSendParams; -import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskIdParams; @@ -73,6 +78,7 @@ import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.spec.util.ErrorDetail; import org.a2aproject.sdk.spec.util.Utils; +import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; /** @@ -127,18 +133,16 @@ public class RestHandler { private static final Logger LOGGER = Logger.getLogger(RestHandler.class.getName()); private static final String TASK_STATE_PREFIX = "TASK_STATE_"; - // Fields set by constructor injection cannot be final. We need a noargs constructor for // Jakarta compatibility, and it seems that making fields set by constructor injection // final, is not proxyable in all runtimes - private Instance agentCardInstance; - private @Nullable Instance extendedAgentCard; + private AgentCardResolver agentCardResolver; + private AgentCardRequestPolicy agentCardPolicy; private AgentCardCacheMetadata cacheMetadata; private RequestHandler requestHandler; private Executor executor; - private final Set validatedCards = AgentCardValidator.newValidatedCardsSet(); - - private @Nullable AgentCardRouter agentCardRouter; + private final CachingAgentCardTransportValidator transportValidator = + new CachingAgentCardTransportValidator(AgentCardValidator::validateTransportConfiguration); /** * No-args constructor for CDI proxy creation. @@ -166,12 +170,12 @@ public RestHandler(@PublicAgentCard Instance agentCardInstance, @ExtendedAgentCard Instance extendedAgentCard, AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, @Internal Executor executor, @Any @Nullable Instance agentCardRouterInstance) { - this.agentCardInstance = agentCardInstance; - this.extendedAgentCard = extendedAgentCard; + this.agentCardResolver = AgentCardResolver.of(() -> CdiUtils.resolveDefault(agentCardInstance), + () -> CdiUtils.resolveDefault(extendedAgentCard), CdiUtils.getIfResolvable(agentCardRouterInstance)); + this.agentCardPolicy = new AgentCardRequestPolicy(agentCardResolver); this.cacheMetadata = cacheMetadata; this.requestHandler = requestHandler; this.executor = executor; - this.agentCardRouter = CdiUtils.getIfResolvable(agentCardRouterInstance); } /** @@ -184,10 +188,8 @@ public RestHandler(@PublicAgentCard Instance agentCardInstance, */ public RestHandler(@Nullable AgentCard agentCard, AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, Executor executor) { - this.agentCardInstance = agentCard != null ? new FixedInstance<>(agentCard) : FixedInstance.empty(); - this.cacheMetadata = cacheMetadata; - this.requestHandler = requestHandler; - this.executor = executor; + this(agentCard != null ? new FixedInstance<>(agentCard) : FixedInstance.empty(), FixedInstance.empty(), + cacheMetadata, requestHandler, executor, null); } /** @@ -238,10 +240,10 @@ public RestHandler(@Nullable AgentCard agentCard, AgentCardCacheMetadata cacheMe */ public HTTPRestResponse sendMessage(ServerCallContext context, String tenant, String body) { try { - validateVersionAndExtensions(tenant, context); + EffectiveAgentCard effectiveCard = preflight(tenant, context, AgentCardOperation.none()); org.a2aproject.sdk.grpc.SendMessageRequest.Builder request = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(); parseRequestBody(body, request); - request.setTenant(tenant); + request.setTenant(Utils.defaultIfNull(effectiveCard.tenant(), "")); EventKind result = requestHandler.onMessageSend(ProtoUtils.FromProto.messageSendParams(request), context); return createSuccessResponse(200, org.a2aproject.sdk.grpc.SendMessageResponse.newBuilder(ProtoUtils.ToProto.taskOrMessage(result))); } catch (A2AError e) { @@ -302,13 +304,15 @@ public HTTPRestResponse sendMessage(ServerCallContext context, String tenant, St */ public HTTPRestResponse sendStreamingMessage(ServerCallContext context, String tenant, String body) { try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - return createErrorResponse(new UnsupportedOperationError(null, "Streaming is not supported by the agent", null)); + EffectiveAgentCard effectiveCard; + try { + effectiveCard = preflight(tenant, context, STREAMING); + } catch (UnsupportedOperationError e) { + return createErrorResponse(e); } org.a2aproject.sdk.grpc.SendMessageRequest.Builder request = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(); parseRequestBody(body, request); - request.setTenant(tenant); + request.setTenant(Utils.defaultIfNull(effectiveCard.tenant(), "")); MessageSendParams params = ProtoUtils.FromProto.messageSendParams(request); try { requestHandler.authorizeTaskAccess(params.message().taskId(), context, TaskOperation.MESSAGE_SEND_STREAM); @@ -348,14 +352,15 @@ public HTTPRestResponse sendStreamingMessage(ServerCallContext context, String t * @see org.a2aproject.sdk.server.agentexecution.AgentExecutor#cancel */ @SuppressWarnings("unchecked") - public HTTPRestResponse cancelTask(ServerCallContext context, String tenant, String body, String taskId) { + public HTTPRestResponse cancelTask(ServerCallContext context, String tenant, String body, @Nullable String taskId) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } try { - validateVersionAndExtensions(tenant, context); - if (taskId == null || taskId.isEmpty()) { - throw new InvalidParamsError(); - } + EffectiveAgentCard effectiveCard = preflight(tenant, context, AgentCardOperation.none()); Map metadata = JsonUtil.readMetadata(body); - CancelTaskParams params = CancelTaskParams.builder().id(taskId).tenant(tenant).metadata(metadata).build(); + CancelTaskParams params = CancelTaskParams.builder().id(requiredTaskId).tenant(effectiveCard.tenant()).metadata(metadata).build(); Task task = requestHandler.onCancelTask(params, context); if (task != null) { return createSuccessResponse(200, org.a2aproject.sdk.grpc.Task.newBuilder(ProtoUtils.ToProto.task(task))); @@ -377,21 +382,22 @@ public HTTPRestResponse cancelTask(ServerCallContext context, String tenant, Str * @param taskId the ID of the task * @return the HTTP response containing the created configuration */ - public HTTPRestResponse createTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String body, String taskId) { + public HTTPRestResponse createTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String body, @Nullable String taskId) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - throw new PushNotificationNotSupportedError(); - } + EffectiveAgentCard effectiveCard = preflight(tenant, context, PUSH_NOTIFICATIONS); org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder builder = org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder(); parseRequestBody(body, builder); String taskIdFromBody = builder.getTaskId(); - if (!taskIdFromBody.isEmpty() && !taskIdFromBody.equals(taskId)) { - throw new InvalidParamsError("Task ID in request body (" + taskIdFromBody + ") does not match task ID in URL path (" + taskId + ")."); + if (!taskIdFromBody.isEmpty() && !taskIdFromBody.equals(requiredTaskId)) { + throw new InvalidParamsError("Task ID in request body (" + taskIdFromBody + ") does not match task ID in URL path (" + requiredTaskId + ")."); } - builder.setTenant(tenant); - builder.setTaskId(taskId); + builder.setTenant(Utils.defaultIfNull(effectiveCard.tenant(), "")); + builder.setTaskId(requiredTaskId); TaskPushNotificationConfig result = requestHandler.onCreateTaskPushNotificationConfig(ProtoUtils.FromProto.createTaskPushNotificationConfig(builder), context); return createSuccessResponse(201, org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils.ToProto.taskPushNotificationConfig(result))); } catch (A2AError e) { @@ -433,13 +439,19 @@ public HTTPRestResponse createTaskPushNotificationConfiguration(ServerCallContex * @see RequestHandler#onSubscribeToTask(TaskIdParams, ServerCallContext) * @see #sendStreamingMessage(ServerCallContext, String, String) */ - public HTTPRestResponse subscribeToTask(ServerCallContext context, String tenant, String taskId) { + public HTTPRestResponse subscribeToTask(ServerCallContext context, String tenant, @Nullable String taskId) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().streaming()) { - return createErrorResponse(new UnsupportedOperationError(null, "Streaming is not supported by the agent", null)); + EffectiveAgentCard effectiveCard; + try { + effectiveCard = preflight(tenant, context, STREAMING); + } catch (UnsupportedOperationError e) { + return createErrorResponse(e); } - TaskIdParams params = TaskIdParams.builder().id(taskId).tenant(tenant).build(); + TaskIdParams params = TaskIdParams.builder().id(requiredTaskId).tenant(effectiveCard.tenant()).build(); try { requestHandler.authorizeTaskAccess(params.id(), context, TaskOperation.SUBSCRIBE_TO_TASK); } catch (A2AError e) { @@ -463,10 +475,22 @@ public HTTPRestResponse subscribeToTask(ServerCallContext context, String tenant * @param historyLength the maximum number of history entries to include * @return the HTTP response containing the task */ - public HTTPRestResponse getTask(ServerCallContext context, String tenant, String taskId, @Nullable Integer historyLength) { + public HTTPRestResponse getTask(ServerCallContext context, String tenant, @Nullable String taskId, @Nullable String historyLength) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } + Integer parsedHistoryLength = null; + try { + if (historyLength != null) { + parsedHistoryLength = Integer.valueOf(historyLength); + } + } catch (NumberFormatException e) { + return createErrorResponse(new InvalidParamsError("bad historyLength")); + } try { - validateVersionAndExtensions(tenant, context); - TaskQueryParams params = new TaskQueryParams(taskId, historyLength, tenant); + EffectiveAgentCard effectiveCard = preflight(tenant, context, AgentCardOperation.none()); + TaskQueryParams params = new TaskQueryParams(requiredTaskId, parsedHistoryLength, effectiveCard.tenant()); Task task = requestHandler.onGetTask(params, context); if (task != null) { return createSuccessResponse(200, org.a2aproject.sdk.grpc.Task.newBuilder(ProtoUtils.ToProto.task(task))); @@ -523,11 +547,32 @@ public HTTPRestResponse getTask(ServerCallContext context, String tenant, String */ public HTTPRestResponse listTasks(ServerCallContext context, String tenant, @Nullable String contextId, @Nullable String status, - @Nullable Integer pageSize, @Nullable String pageToken, - @Nullable Integer historyLength, @Nullable String statusTimestampAfter, - @Nullable Boolean includeArtifacts) { + @Nullable String pageSize, @Nullable String pageToken, + @Nullable String historyLength, @Nullable String statusTimestampAfter, + @Nullable String includeArtifacts) { + Integer parsedPageSize = null; + Integer parsedHistoryLength = null; + Boolean parsedIncludeArtifacts = null; + try { + if (status != null && !status.isEmpty()) { + status = status.toUpperCase(); + } + if (pageSize != null && !pageSize.isEmpty()) { + parsedPageSize = Integer.valueOf(pageSize); + } + if (historyLength != null && !historyLength.isEmpty()) { + parsedHistoryLength = Integer.valueOf(historyLength); + } + if (includeArtifacts != null && !includeArtifacts.isEmpty()) { + parsedIncludeArtifacts = Boolean.valueOf(includeArtifacts); + } + } catch (NumberFormatException e) { + return createErrorResponse(new InvalidParamsError("Invalid number format in parameters")); + } catch (IllegalArgumentException e) { + return createErrorResponse(new InvalidParamsError("Invalid parameter value: " + e.getMessage())); + } try { - validateVersionAndExtensions(tenant, context); + EffectiveAgentCard effectiveCard = preflight(tenant, context, AgentCardOperation.none()); // Build params ListTasksParams.Builder paramsBuilder = ListTasksParams.builder(); if (contextId != null) { @@ -550,16 +595,16 @@ public HTTPRestResponse listTasks(ServerCallContext context, String tenant, paramsBuilder.status(taskState); } - if (pageSize != null) { - paramsBuilder.pageSize(pageSize); + if (parsedPageSize != null) { + paramsBuilder.pageSize(parsedPageSize); } if (pageToken != null) { paramsBuilder.pageToken(pageToken); } - if (historyLength != null) { - paramsBuilder.historyLength(historyLength); + if (parsedHistoryLength != null) { + paramsBuilder.historyLength(parsedHistoryLength); } - paramsBuilder.tenant(tenant); + paramsBuilder.tenant(effectiveCard.tenant()); if (statusTimestampAfter != null) { try { paramsBuilder.statusTimestampAfter(Instant.parse(statusTimestampAfter)); @@ -570,8 +615,8 @@ public HTTPRestResponse listTasks(ServerCallContext context, String tenant, throw new InvalidParamsError(null, "Invalid params", errorData); } } - if (includeArtifacts != null) { - paramsBuilder.includeArtifacts(includeArtifacts); + if (parsedIncludeArtifacts != null) { + paramsBuilder.includeArtifacts(parsedIncludeArtifacts); } ListTasksParams params = paramsBuilder.build(); @@ -593,13 +638,18 @@ public HTTPRestResponse listTasks(ServerCallContext context, String tenant, * @param configId the ID of the configuration to retrieve * @return the HTTP response containing the configuration */ - public HTTPRestResponse getTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String taskId, String configId) { + public HTTPRestResponse getTaskPushNotificationConfiguration(ServerCallContext context, String tenant, @Nullable String taskId, @Nullable String configId) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } + String requiredConfigId = requireNonBlankParam(configId); + if (requiredConfigId == null) { + return createErrorResponse(new InvalidParamsError("bad configuration id")); + } try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - throw new PushNotificationNotSupportedError(); - } - GetTaskPushNotificationConfigParams params = new GetTaskPushNotificationConfigParams(taskId, configId, tenant); + EffectiveAgentCard effectiveCard = preflight(tenant, context, PUSH_NOTIFICATIONS); + GetTaskPushNotificationConfigParams params = new GetTaskPushNotificationConfigParams(requiredTaskId, requiredConfigId, effectiveCard.tenant()); TaskPushNotificationConfig config = requestHandler.onGetTaskPushNotificationConfig(params, context); return createSuccessResponse(200, org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils.ToProto.taskPushNotificationConfig(config))); } catch (A2AError e) { @@ -619,13 +669,23 @@ public HTTPRestResponse getTaskPushNotificationConfiguration(ServerCallContext c * @param pageToken the token for pagination * @return the HTTP response containing the list of configurations */ - public HTTPRestResponse listTaskPushNotificationConfigurations(ServerCallContext context, String tenant, String taskId, int pageSize, String pageToken) { + public HTTPRestResponse listTaskPushNotificationConfigurations(ServerCallContext context, String tenant, @Nullable String taskId, @Nullable String pageSize, @Nullable String pageToken) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } + int parsedPageSize = 0; try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - throw new PushNotificationNotSupportedError(); + if (pageSize != null) { + parsedPageSize = Integer.parseInt(pageSize); } - ListTaskPushNotificationConfigsParams params = new ListTaskPushNotificationConfigsParams(taskId, pageSize, pageToken, tenant); + } catch (NumberFormatException e) { + return createErrorResponse(new InvalidParamsError("bad pageSize")); + } + String normalizedPageToken = Utils.defaultIfNull(pageToken, ""); + try { + EffectiveAgentCard effectiveCard = preflight(tenant, context, PUSH_NOTIFICATIONS); + ListTaskPushNotificationConfigsParams params = new ListTaskPushNotificationConfigsParams(requiredTaskId, parsedPageSize, normalizedPageToken, effectiveCard.tenant()); ListTaskPushNotificationConfigsResult result = requestHandler.onListTaskPushNotificationConfigs(params, context); return createSuccessResponse(200, org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsResponse.newBuilder(ProtoUtils.ToProto.listTaskPushNotificationConfigsResponse(result))); } catch (A2AError e) { @@ -644,13 +704,18 @@ public HTTPRestResponse listTaskPushNotificationConfigurations(ServerCallContext * @param configId the ID of the configuration to delete * @return the HTTP response with no content on success */ - public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContext context, String tenant, String taskId, String configId) { + public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContext context, String tenant, @Nullable String taskId, @Nullable String configId) { + String requiredTaskId = requireNonBlankParam(taskId); + if (requiredTaskId == null) { + return createErrorResponse(new InvalidParamsError("bad task id")); + } + String requiredConfigId = requireNonBlankParam(configId); + if (requiredConfigId == null) { + return createErrorResponse(new InvalidParamsError("bad config id")); + } try { - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().pushNotifications()) { - throw new PushNotificationNotSupportedError(); - } - DeleteTaskPushNotificationConfigParams params = new DeleteTaskPushNotificationConfigParams(taskId, configId, tenant); + EffectiveAgentCard effectiveCard = preflight(tenant, context, PUSH_NOTIFICATIONS); + DeleteTaskPushNotificationConfigParams params = new DeleteTaskPushNotificationConfigParams(requiredTaskId, requiredConfigId, effectiveCard.tenant()); requestHandler.onDeleteTaskPushNotificationConfig(params, context); return new HTTPRestResponse(204, APPLICATION_JSON, ""); } catch (A2AError e) { @@ -661,24 +726,32 @@ public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContex } /** - * Validates the requested protocol version and extensions against the agent card. + * Resolves the canonical tenant and validates transport configuration, version, extensions and capability. * *

Section 3.6.2 of the specification requires this on every request, so every operation * calls it before reaching the request handler. * + * @param tenant the raw path tenant * @param context the server call context carrying the requested version and extensions - * @throws A2AError if the requested version or a required extension is not supported + * @param operation the operation's capability requirement + * @return the selected card and canonical tenant + * @throws A2AError if request preflight fails */ - private void validateVersionAndExtensions(@Nullable String tenant, ServerCallContext context) throws A2AError { + private EffectiveAgentCard preflight(@Nullable String tenant, ServerCallContext context, AgentCardOperation operation) { try { - AgentCard agentCard = resolveAgentCard(tenant); - A2AVersionValidator.validateProtocolVersion(agentCard, context); - A2AExtensions.validateRequiredExtensions(agentCard, context); + return agentCardPolicy.preflight(tenant, context, transportValidator, operation); } catch (TenantNotFoundException ex) { throw new InvalidParamsError(ex.getResponseMessage()); } } + private static @Nullable String requireNonBlankParam(@Nullable String value) { + if (Assert.isNullOrEmpty(value)) { + return null; + } + return value; + } + private void parseRequestBody(String body, com.google.protobuf.Message.Builder builder) throws A2AError { try { if (body == null || body.trim().isEmpty()) { @@ -852,18 +925,9 @@ private static int mapErrorToHttpStatus(A2AError error) { public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, @Nullable String tenant) { try { Utils.validateTenant(tenant); - validateVersionAndExtensions(tenant, context); - if (!resolveAgentCard(tenant).capabilities().extendedAgentCard()) { - throw new UnsupportedOperationError(); - } - if (tenant != null && !tenant.isBlank() && agentCardRouter != null) { - AgentCard card = agentCardRouter.resolveExtendedCard(tenant); - if (card == null) { - throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); - } - return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card)); - } - AgentCard extCard = CdiUtils.resolveDefault(extendedAgentCard); + EffectiveAgentCard effectiveCard = preflight(tenant, context, + EXTENDED_CARD); + AgentCard extCard = agentCardResolver.resolveExtendedDiscoveryCard(effectiveCard.tenant()); if (extCard == null) { throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); } @@ -910,15 +974,6 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, @Nullabl * @see AgentCard * @see #getExtendedAgentCard(ServerCallContext, String) */ - private AgentCard resolveAgentCard(@Nullable String tenant) { - try { - return AgentCardValidator.resolveWithFallback(agentCardInstance, extendedAgentCard, - agentCardRouter, tenant, validatedCards); - } catch (TenantNotFoundException ex) { - throw new InvalidParamsError(ex.getResponseMessage()); - } - } - public HTTPRestResponse getAgentCard() { return getAgentCard(null); } @@ -937,29 +992,13 @@ public HTTPRestResponse getAgentCard() { public HTTPRestResponse getAgentCard(@Nullable String tenant) { try { Utils.validateTenant(tenant); - if (tenant != null && !tenant.isBlank()) { - if (agentCardRouter != null) { - AgentCard card = agentCardRouter.resolvePublicCard(tenant); - if (card != null) { - return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(card), - cacheMetadata.getHttpHeadersMap()); - } - LOGGER.fine(() -> "Tenant '" + tenant + "' not found in AgentCardRouter — returning 404"); - throw new TenantNotFoundException(tenant); - } - LOGGER.fine(() -> "No AgentCardRouter configured; serving default public card for tenant '" + tenant + "'"); - } - AgentCard card = CdiUtils.resolveDefault(agentCardInstance); - if (card == null && agentCardRouter != null) { - card = agentCardRouter.resolvePublicCard(tenant); - } + AgentCard card = agentCardResolver.resolvePublicDiscoveryCard(tenant); if (card == null) { return new HTTPRestResponse(404, "text/plain", "Public agent card not configured"); } - AgentCard validatedCard = card; + transportValidator.validate(card); return new HTTPRestResponse(200, APPLICATION_JSON, - JsonUtil.toJson(AgentCardValidator.resolveAndValidateOnce(() -> validatedCard, validatedCards, - AgentCardValidator::validateTransportConfiguration)), + JsonUtil.toJson(card), cacheMetadata.getHttpHeadersMap()); } catch (TenantNotFoundException e) { return new HTTPRestResponse(404, "text/plain", e.getResponseMessage()); diff --git a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java index c33273ee9..6569b9fa5 100644 --- a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java @@ -7,21 +7,24 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; import jakarta.enterprise.inject.Instance; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.protobuf.InvalidProtocolBufferException; - +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; import org.a2aproject.sdk.server.AgentCardCacheMetadata; -import org.a2aproject.sdk.server.TestInstances; +import org.a2aproject.sdk.server.AgentCardValidator; import org.a2aproject.sdk.server.FixedInstance; import org.a2aproject.sdk.server.ServerCallContext; +import org.a2aproject.sdk.server.TestInstances; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; import org.a2aproject.sdk.server.multitenancy.AgentCardRouter; @@ -32,10 +35,20 @@ import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentExtension; import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import org.mockito.Mockito; @Timeout(value = 1, unit = TimeUnit.MINUTES) @@ -56,13 +69,13 @@ public void testGetTaskSuccess() { RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), 0); + RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), "0"); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); Assertions.assertTrue(response.getBody().contains(MINIMAL_TASK.id())); - response = handler.getTask(callContext, "", MINIMAL_TASK.id(), 2); + response = handler.getTask(callContext, "", MINIMAL_TASK.id(), "2"); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); @@ -73,7 +86,7 @@ public void testGetTaskSuccess() { public void testGetTaskNotFound() { RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", 0); + RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", "0"); assertProblemDetail(response, 404, "TASK_NOT_FOUND", "Task not found"); @@ -83,10 +96,15 @@ public void testGetTaskNotFound() { public void testGetTaskNegativeHistoryLengthReturns422() { RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); - RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), -1); + RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), "-1"); assertProblemDetail(response, 422, "INVALID_PARAMS", "Invalid history length"); + Assertions.assertEquals(Map.of(), response.getHeaders()); + Assertions.assertFalse(response instanceof RestHandler.HTTPRestStreamingResponse); + JsonObject error = JsonParser.parseString(response.getBody()).getAsJsonObject().getAsJsonObject("error"); + Assertions.assertEquals("INVALID_ARGUMENT", error.get("status").getAsString()); + Assertions.assertEquals(new JsonObject(), error.getAsJsonArray("details").get(0).getAsJsonObject().get("metadata")); } @Test @@ -111,6 +129,14 @@ public void testListTasksInvalidStatus() { assertProblemDetail(response, 422, "INVALID_PARAMS", "Invalid params"); + Assertions.assertEquals(Map.of(), response.getHeaders()); + Assertions.assertFalse(response instanceof RestHandler.HTTPRestStreamingResponse); + JsonObject error = JsonParser.parseString(response.getBody()).getAsJsonObject().getAsJsonObject("error"); + Assertions.assertEquals("INVALID_ARGUMENT", error.get("status").getAsString()); + JsonObject metadata = error.getAsJsonArray("details").get(0).getAsJsonObject().getAsJsonObject("metadata"); + Assertions.assertEquals("status", metadata.get("parameter").getAsString()); + Assertions.assertEquals("Must be one of: " + java.util.Arrays.stream(TaskState.values()) + .map(Enum::name).collect(java.util.stream.Collectors.joining(", ")), metadata.get("reason").getAsString()); } @Test @@ -451,7 +477,7 @@ public void testListPushNotificationConfigs() { RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); - RestHandler.HTTPRestResponse response = handler.listTaskPushNotificationConfigurations(callContext, "", MINIMAL_TASK.id(), 0, ""); + RestHandler.HTTPRestResponse response = handler.listTaskPushNotificationConfigurations(callContext, "", MINIMAL_TASK.id(), "0", ""); Assertions.assertEquals(200, response.getStatusCode()); Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); @@ -494,7 +520,7 @@ public void testHttpStatusCodeMapping() { Assertions.assertEquals(400, response.getStatusCode()); // Test 404 for not found - response = handler.getTask(callContext, "", "nonexistent", 0); + response = handler.getTask(callContext, "", "nonexistent", "0"); Assertions.assertEquals(404, response.getStatusCode()); } @@ -1226,7 +1252,7 @@ public void testVersionNotSupportedErrorOnListTaskPushNotificationConfigurations taskStore.save(MINIMAL_TASK, false); assertVersionRejected(handler.listTaskPushNotificationConfigurations( - incompatibleVersionContext(), "", MINIMAL_TASK.id(), 10, "")); + incompatibleVersionContext(), "", MINIMAL_TASK.id(), "10", "")); } @Test @@ -1488,4 +1514,327 @@ public void testGetAgentCardWithTenantReturns404WhenNoPublicCard() { RestHandler.HTTPRestResponse response = handler.getAgentCard("some-tenant"); Assertions.assertEquals(404, response.getStatusCode()); } + @Test + public void testUnknownOperationTenantIsInvalidParams() { + AgentCardRouter router = tenant -> null; + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, createCacheMetadata(), + requestHandler, internalExecutor, new FixedInstance<>(router)); + + assertProblemDetail(handler.sendMessage(callContext, "unknown", "{broken"), 422, + "INVALID_PARAMS", "No public agent card registered for tenant: unknown"); + } + + @Test + public void testInvalidTenantPrecedesVersionAndStreamingBody() { + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); + String message = "Tenant contains invalid characters. Only a-zA-Z0-9_-. are allowed"; + assertProblemDetail(handler.sendMessage(incompatibleVersionContext(), "bad/tenant", "{broken"), + 422, "INVALID_PARAMS", message); + RestHandler.HTTPRestStreamingResponse response = Assertions.assertInstanceOf( + RestHandler.HTTPRestStreamingResponse.class, + handler.sendStreamingMessage(callContext, "bad/tenant", "{broken")); + assertSingleStreamProblem(response, 422, "INVALID_PARAMS", message); + } + + @Test + public void testDisabledStreamingPrecedesMalformedBody() { + AgentCard card = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().build()).build(); + RestHandler handler = new RestHandler(card, createCacheMetadata(card), requestHandler, internalExecutor); + assertProblemDetail(handler.sendStreamingMessage(callContext, "", "{broken"), 400, + "UNSUPPORTED_OPERATION", "Streaming is not supported by the agent"); + } + + private static void assertSingleStreamProblem(RestHandler.HTTPRestStreamingResponse response, + int status, String reason, String message) { + CompletableFuture event = new CompletableFuture<>(); + response.getPublisher().subscribe(new Flow.Subscriber<>() { + @Override public void onSubscribe(Flow.Subscription subscription) { subscription.request(Long.MAX_VALUE); } + @Override public void onNext(String item) { event.complete(item); } + @Override public void onError(Throwable failure) { event.completeExceptionally(failure); } + @Override public void onComplete() { } + }); + assertProblemDetail(new RestHandler.HTTPRestResponse(status, APPLICATION_JSON, event.join()), + status, reason, message); + } + + @Test + public void testBoundarySlashesCanonicalizedForRoutingAndDispatch() { + AgentCardRouter router = Mockito.mock(AgentCardRouter.class); + Mockito.when(router.resolvePublicCard("acme")).thenReturn(CARD); + RequestHandler delegate = Mockito.mock(RequestHandler.class); + Mockito.when(delegate.onGetTask(Mockito.any(), Mockito.any())).thenReturn(MINIMAL_TASK); + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, createCacheMetadata(), + delegate, internalExecutor, new FixedInstance<>(router)); + + Assertions.assertEquals(200, handler.getTask(callContext, "/acme/", "task", null).getStatusCode()); + ArgumentCaptor params = ArgumentCaptor.forClass(TaskQueryParams.class); + Mockito.verify(delegate).onGetTask(params.capture(), Mockito.same(callContext)); + Assertions.assertEquals("acme", params.getValue().tenant()); + Assertions.assertEquals("acme", callContext.getEffectiveAgentCard().tenant()); + Mockito.verify(router).resolvePublicCard("acme"); + Mockito.verifyNoMoreInteractions(router); + } + + @Test + public void testPublicDiscoveryValidatesDefaultAndRouterCardsOnce() { + AgentCard routed = AgentCard.builder(CARD).name("routed").build(); + AgentCardRouter router = new AgentCardRouter() { + @Override public AgentCard resolvePublicCard(String tenant) { return routed; } + @Override public AgentCard resolveExtendedCard(String tenant) { return null; } + }; + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, createCacheMetadata(), + requestHandler, internalExecutor, new FixedInstance<>(router)); + try (MockedStatic validator = Mockito.mockStatic(AgentCardValidator.class, Mockito.CALLS_REAL_METHODS)) { + for (int i = 0; i < 2; i++) { + Assertions.assertEquals(200, handler.getAgentCard().getStatusCode()); + RestHandler.HTTPRestResponse response = handler.getAgentCard("acme"); + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("routed", JsonParser.parseString(response.getBody()).getAsJsonObject().get("name").getAsString()); + } + validator.verify(() -> AgentCardValidator.validateTransportConfiguration(CARD), Mockito.times(1)); + validator.verify(() -> AgentCardValidator.validateTransportConfiguration(routed), Mockito.times(1)); + } + } + + @Test + public void testDiscoveryValidationFailureCanRetry() { + AgentCardRouter router = new AgentCardRouter() { + @Override public AgentCard resolvePublicCard(String tenant) { return CARD; } + @Override public AgentCard resolveExtendedCard(String tenant) { return null; } + }; + RestHandler handler = new RestHandler(new FixedInstance<>(CARD), null, createCacheMetadata(), + requestHandler, internalExecutor, new FixedInstance<>(router)); + try (MockedStatic validator = Mockito.mockStatic(AgentCardValidator.class, Mockito.CALLS_REAL_METHODS)) { + validator.when(() -> AgentCardValidator.validateTransportConfiguration(CARD)) + .thenThrow(new IllegalArgumentException("invalid transport")).thenCallRealMethod(); + validator.clearInvocations(); + assertProblemDetail(handler.getAgentCard("acme"), 500, "INTERNAL", "Internal error"); + Assertions.assertEquals(200, handler.getAgentCard("acme").getStatusCode()); + Assertions.assertEquals(200, handler.getAgentCard("acme").getStatusCode()); + validator.verify(() -> AgentCardValidator.validateTransportConfiguration(CARD), Mockito.times(2)); + } + } + + @Test + public void testPublicDiscoveryNeverExposesOnlyExtendedCard() { + RestHandler handler = new RestHandler(FixedInstance.empty(), new FixedInstance<>(CARD), createCacheMetadata(), + requestHandler, internalExecutor, null); + RestHandler.HTTPRestResponse response = handler.getAgentCard(); + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals("text/plain", response.getContentType()); + Assertions.assertEquals("Public agent card not configured", response.getBody()); + } + + @Test + public void testDiscoveryInvalidTenantRetainsSanitized500() { + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); + assertProblemDetail(handler.getAgentCard("bad/tenant"), 500, "INTERNAL", "Internal error"); + assertProblemDetail(handler.getExtendedAgentCard(callContext, "bad/tenant"), 500, "INTERNAL", "Internal error"); + } + + @Test + public void testExtendedDiscoveryDistinguishesCapabilityAndMissingCard() { + AgentCard disabled = AgentCard.builder(CARD).capabilities(AgentCapabilities.builder().build()).build(); + RestHandler handler = new RestHandler(disabled, createCacheMetadata(disabled), requestHandler, internalExecutor); + assertProblemDetail(handler.getExtendedAgentCard(callContext, ""), 400, + "UNSUPPORTED_OPERATION", "This operation is not supported"); + AgentCard enabled = AgentCard.builder(disabled) + .capabilities(AgentCapabilities.builder().extendedAgentCard(true).build()).build(); + handler = new RestHandler(enabled, createCacheMetadata(enabled), requestHandler, internalExecutor); + assertProblemDetail(handler.getExtendedAgentCard(callContext, ""), 400, + "EXTENDED_AGENT_CARD_NOT_CONFIGURED", "Extended Card not configured"); + } + + private RequestHandler validationHandler; + private RestHandler validationRestHandler; + private ServerCallContext validationContext; + + private void validationFixture(int conflict) { + validationHandler = Mockito.mock(RequestHandler.class); + AgentCard card = AgentCard.builder(CARD) + .capabilities(AgentCapabilities.builder().streaming(conflict != 3).pushNotifications(conflict != 3) + .extensions(conflict == 2 ? List.of(AgentExtension.builder().uri("urn:test:required") + .required(true).build()) : List.of()).build()).build(); + validationRestHandler = new RestHandler(card, createCacheMetadata(card), validationHandler, Runnable::run); + validationContext = new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of(), new HashSet<>(), + conflict == 1 ? "2.0" : "1.0"); + } + + private void assertRawInputError(RestHandler.HTTPRestResponse response, String message) { + assertRawInputError(response, 422, "INVALID_PARAMS", message); + } + + private void assertRawVersionError(RestHandler.HTTPRestResponse response) { + assertRawInputError(response, 400, "VERSION_NOT_SUPPORTED", + "Protocol version '2.0' is not supported. Supported versions: [1.0]"); + } + + private void assertRawInputError(RestHandler.HTTPRestResponse response, int status, String reason, String message) { + Assertions.assertEquals(status, response.getStatusCode()); + Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); + Assertions.assertFalse(response instanceof RestHandler.HTTPRestStreamingResponse); + Assertions.assertEquals(Map.of(), response.getHeaders()); + JsonObject expected = JsonParser.parseString(""" + {"error":{"code":422,"status":"INVALID_ARGUMENT","message":"", "details":[{ + "@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"INVALID_PARAMS", + "domain":"a2a-protocol.org","metadata":{}}]}} + """).getAsJsonObject(); + JsonObject error = expected.getAsJsonObject("error"); + error.addProperty("code", status); + error.addProperty("status", reason.equals("VERSION_NOT_SUPPORTED") ? "UNIMPLEMENTED" : "INVALID_ARGUMENT"); + error.addProperty("message", message); + error.getAsJsonArray("details").get(0).getAsJsonObject().addProperty("reason", reason); + Assertions.assertEquals(expected, JsonParser.parseString(response.getBody())); + Mockito.verifyNoInteractions(validationHandler); + } + + private void assertBadTaskPath(String taskId, + Function operation) { + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict); + assertRawInputError(operation.apply(taskId), "bad task id"); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testGetTaskRawInputValidationOrder(String taskId) { + assertBadTaskPath(taskId, id -> validationRestHandler.getTask(validationContext, "", id, "bad")); + for (String history : List.of("", "bad", "2147483648")) { + for (int conflict = 0; conflict < 3; conflict++) { + validationFixture(conflict); + assertRawInputError(validationRestHandler.getTask(validationContext, "", "task", history), "bad historyLength"); + } + } + validationFixture(1); + assertRawVersionError(validationRestHandler.getTask(validationContext, "", "task", "-1")); + Mockito.verifyNoInteractions(validationHandler); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testCancelTaskPathValidationOrder(String taskId) { + assertBadTaskPath(taskId, id -> validationRestHandler.cancelTask(validationContext, "", "{broken", id)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testSubscribeToTaskPathValidationOrder(String taskId) { + assertBadTaskPath(taskId, id -> validationRestHandler.subscribeToTask(validationContext, "", id)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testCreatePushConfigPathValidationOrder(String taskId) { + assertBadTaskPath(taskId, + id -> validationRestHandler.createTaskPushNotificationConfiguration(validationContext, "", "{broken", id)); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testGetPushConfigPathValidationOrder(String id) { + assertBadTaskPath(id, + taskId -> validationRestHandler.getTaskPushNotificationConfiguration(validationContext, "", taskId, id)); + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict); + assertRawInputError(validationRestHandler.getTaskPushNotificationConfiguration(validationContext, "", "task", id), + "bad configuration id"); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testDeletePushConfigPathValidationOrder(String id) { + assertBadTaskPath(id, + taskId -> validationRestHandler.deleteTaskPushNotificationConfiguration(validationContext, "", taskId, id)); + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict); + assertRawInputError(validationRestHandler.deleteTaskPushNotificationConfiguration(validationContext, "", "task", id), + "bad config id"); + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = " ") + public void testListPushConfigsRawInputValidationOrder(String taskId) { + assertBadTaskPath(taskId, + id -> validationRestHandler.listTaskPushNotificationConfigurations(validationContext, "", id, "bad", null)); + for (String page : List.of("", "bad", "2147483648")) { + for (int conflict = 0; conflict < 4; conflict++) { + validationFixture(conflict); + assertRawInputError(validationRestHandler.listTaskPushNotificationConfigurations( + validationContext, "", "task", page, null), "bad pageSize"); + } + } + } + + @Test + public void testListTasksRawQueryValidationOrder() { + for (String field : List.of("pageSize", "historyLength")) { + for (int conflict = 0; conflict < 3; conflict++) { + validationFixture(conflict); + assertRawInputError(validationRestHandler.listTasks(validationContext, "", null, "invalid", + field.equals("pageSize") ? "bad" : null, null, "also-bad", "invalid", null), + "Invalid number format in parameters"); + } + validationFixture(1); + assertRawVersionError(validationRestHandler.listTasks(validationContext, "", null, null, + field.equals("pageSize") ? "-1" : null, null, + field.equals("historyLength") ? "-1" : null, null, null)); + Mockito.verifyNoInteractions(validationHandler); + } + for (String number : new String[] {null, "", "2"}) { + for (String raw : new String[] {null, "", "true", "TrUe", "false", "not-true", "1"}) { + validationFixture(0); + Mockito.when(validationHandler.onListTasks(Mockito.any(), Mockito.any())) + .thenReturn(new ListTasksResult(List.of())); + RestHandler.HTTPRestResponse response = validationRestHandler.listTasks(validationContext, "", null, + "task_state_submitted", number, null, number, null, raw); + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(APPLICATION_JSON, response.getContentType()); + ArgumentCaptor params = + ArgumentCaptor.forClass(ListTasksParams.class); + Mockito.verify(validationHandler).onListTasks(params.capture(), Mockito.any()); + Assertions.assertEquals(TaskState.TASK_STATE_SUBMITTED, params.getValue().status()); + Assertions.assertEquals(number == null || number.isEmpty() ? null : Integer.valueOf(number), params.getValue().pageSize()); + Assertions.assertEquals(number == null || number.isEmpty() ? null : Integer.valueOf(number), params.getValue().historyLength()); + Assertions.assertEquals(raw == null || raw.isEmpty() ? null : Boolean.valueOf(raw), params.getValue().includeArtifacts()); + } + } + } + + @Test + public void testRawQueryDefaultsAndSuccessfulConversions() { + for (String history : new String[] {null, "0", "2"}) { + validationFixture(0); + Mockito.when(validationHandler.onGetTask(Mockito.any(), Mockito.any())).thenReturn(MINIMAL_TASK); + Assertions.assertEquals(200, validationRestHandler.getTask(validationContext, "", "task", history).getStatusCode()); + ArgumentCaptor params = + ArgumentCaptor.forClass(TaskQueryParams.class); + Mockito.verify(validationHandler).onGetTask(params.capture(), Mockito.any()); + Assertions.assertEquals(history == null ? null : Integer.valueOf(history), params.getValue().historyLength()); + } + for (String page : new String[] {null, "0", "2"}) { + for (String token : new String[] {null, "", "next"}) { + validationFixture(0); + Mockito.when(validationHandler.onListTaskPushNotificationConfigs(Mockito.any(), Mockito.any())) + .thenReturn(new ListTaskPushNotificationConfigsResult(List.of())); + Assertions.assertEquals(200, validationRestHandler.listTaskPushNotificationConfigurations( + validationContext, "", "task", page, token).getStatusCode()); + ArgumentCaptor params = + ArgumentCaptor.forClass(ListTaskPushNotificationConfigsParams.class); + Mockito.verify(validationHandler).onListTaskPushNotificationConfigs(params.capture(), Mockito.any()); + Assertions.assertEquals(page == null ? 0 : Integer.parseInt(page), params.getValue().pageSize()); + Assertions.assertEquals(token == null ? "" : token, params.getValue().pageToken()); + } + } + } + }