From 31a2beb71f322d59d6c38ee855aa9623ecdaa0c2 Mon Sep 17 00:00:00 2001 From: Emmanuel Hugonnet Date: Tue, 10 Mar 2026 11:58:04 +0100 Subject: [PATCH] fix: Setting the proper content type for errors. The expected content type is application/problem+json when there is an error and not application/json Signed-off-by: Emmanuel Hugonnet --- .../main/java/io/a2a/common/MediaType.java | 7 +++ .../server/apps/quarkus/A2AServerRoutes.java | 5 +- .../apps/quarkus/A2AServerRoutesTest.java | 58 ++++++++++++++++--- .../transport/rest/handler/RestHandler.java | 15 +++-- .../rest/handler/RestHandlerTest.java | 18 +++--- 5 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 common/src/main/java/io/a2a/common/MediaType.java diff --git a/common/src/main/java/io/a2a/common/MediaType.java b/common/src/main/java/io/a2a/common/MediaType.java new file mode 100644 index 000000000..9c7027f60 --- /dev/null +++ b/common/src/main/java/io/a2a/common/MediaType.java @@ -0,0 +1,7 @@ +package io.a2a.common; + +public interface MediaType { + + public static final String APPLICATION_JSON = "application/json"; + public static final String APPLICATION_PROBLEM_JSON = "application/problem+json"; +} diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index ceded1fc4..d5fcb8c64 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -1,5 +1,6 @@ package io.a2a.server.apps.quarkus; +import static io.a2a.common.MediaType.APPLICATION_PROBLEM_JSON; import static io.a2a.server.ServerCallContext.TRANSPORT_KEY; import static io.a2a.transport.jsonrpc.context.JSONRPCContextKeys.HEADERS_KEY; import static io.a2a.transport.jsonrpc.context.JSONRPCContextKeys.METHOD_NAME_KEY; @@ -134,7 +135,7 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) { if (error != null) { rc.response() .setStatusCode(200) - .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .putHeader(CONTENT_TYPE, APPLICATION_PROBLEM_JSON) .end(serializeResponse(error)); } else if (streaming) { final Multi> finalStreamingResponse = streamingResponse; @@ -150,7 +151,7 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) { } else { rc.response() .setStatusCode(200) - .putHeader(CONTENT_TYPE, APPLICATION_JSON) + .putHeader(CONTENT_TYPE, nonStreamingResponse.getError() != null ? APPLICATION_PROBLEM_JSON : APPLICATION_JSON) .end(serializeResponse(nonStreamingResponse)); } } diff --git a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/A2AServerRoutesTest.java b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/A2AServerRoutesTest.java index 2e6d3562a..1b99213e6 100644 --- a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/A2AServerRoutesTest.java +++ b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/A2AServerRoutesTest.java @@ -1,11 +1,21 @@ package io.a2a.server.apps.quarkus; +import static io.a2a.common.MediaType.APPLICATION_PROBLEM_JSON; +import static io.a2a.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static io.a2a.spec.A2AMethods.GET_TASK_METHOD; +import static io.a2a.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static io.a2a.spec.A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static io.a2a.spec.A2AMethods.SEND_MESSAGE_METHOD; +import static io.a2a.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; +import static io.a2a.spec.A2AMethods.SUBSCRIBE_TO_TASK_METHOD; import static io.a2a.spec.A2AMethods.CANCEL_TASK_METHOD; import static io.a2a.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; import static io.a2a.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; import static io.a2a.transport.jsonrpc.context.JSONRPCContextKeys.METHOD_NAME_KEY; import static io.a2a.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.assertNotNull; import static org.mockito.ArgumentMatchers.any; @@ -59,14 +69,6 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import static io.a2a.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; -import static io.a2a.spec.A2AMethods.GET_TASK_METHOD; -import static io.a2a.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; -import static io.a2a.spec.A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; -import static io.a2a.spec.A2AMethods.SEND_MESSAGE_METHOD; -import static io.a2a.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; -import static io.a2a.spec.A2AMethods.SUBSCRIBE_TO_TASK_METHOD; - /** * Unit test for JSON-RPC A2AServerRoutes that verifies the method names are properly set * in the ServerCallContext for all request types. @@ -166,6 +168,7 @@ public void testSendMessage_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SEND_MESSAGE_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -250,6 +253,7 @@ public void testGetTask_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(GET_TASK_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -286,6 +290,7 @@ public void testCancelTask_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(CANCEL_TASK_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -363,6 +368,7 @@ public void testCreateTaskPushNotificationConfig_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -401,6 +407,7 @@ public void testGetTaskPushNotificationConfig_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -440,6 +447,7 @@ public void testListTaskPushNotificationConfig_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -473,6 +481,7 @@ public void testDeleteTaskPushNotificationConfig_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -509,6 +518,7 @@ public void testGetExtendedCard_MethodNameSetInContext() { ServerCallContext capturedContext = contextCaptor.getValue(); assertNotNull(capturedContext); assertEquals(GET_EXTENDED_AGENT_CARD_METHOD, capturedContext.getState().get(METHOD_NAME_KEY)); + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON); } @Test @@ -707,6 +717,38 @@ public void testTenantExtraction_StreamingRequest() { assertEquals("myTenant/api", capturedContext.getState().get(TENANT_KEY)); } + @Test + public void testJsonParseError_ContentTypeIsProblemJson() { + // Arrange - invalid JSON + String invalidJson = "not valid json {{{"; + when(mockRequestBody.asString()).thenReturn(invalidJson); + + // Act + routes.invokeJSONRPCHandler(invalidJson, mockRoutingContext); + + // Assert + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_PROBLEM_JSON); + } + + @Test + public void testMethodNotFound_ContentTypeIsProblemJson() { + // Arrange - unknown method + String jsonRpcRequest = """ + { + "jsonrpc": "2.0", + "id": "cd4c76de-d54c-436c-8b9f-4c2703648d64", + "method": "UnknownMethod", + "params": {} + }"""; + when(mockRequestBody.asString()).thenReturn(jsonRpcRequest); + + // Act + routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext); + + // Assert + verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_PROBLEM_JSON); + } + /** * Helper method to set a field via reflection for testing purposes. */ diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index 3e38d00a3..3e3a29d1a 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -1,5 +1,7 @@ package io.a2a.transport.rest.handler; +import static io.a2a.common.MediaType.APPLICATION_JSON; +import static io.a2a.common.MediaType.APPLICATION_PROBLEM_JSON; import static io.a2a.server.util.async.AsyncUtils.createTubeConfig; import static io.a2a.spec.A2AErrorCodes.JSON_PARSE_ERROR_CODE; @@ -72,6 +74,7 @@ public class RestHandler { private static final Logger log = 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 @@ -311,7 +314,7 @@ public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContex } DeleteTaskPushNotificationConfigParams params = new DeleteTaskPushNotificationConfigParams(taskId, configId, tenant); requestHandler.onDeleteTaskPushNotificationConfig(params, context); - return new HTTPRestResponse(204, "application/json", ""); + return new HTTPRestResponse(204, APPLICATION_JSON, ""); } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { @@ -344,8 +347,8 @@ private void validate(String json) { private HTTPRestResponse createSuccessResponse(int statusCode, com.google.protobuf.Message.Builder builder) { try { // Include default value fields to ensure empty arrays, zeros, etc. are present in JSON - String jsonBody = JsonFormat.printer().includingDefaultValueFields().print(builder); - return new HTTPRestResponse(statusCode, "application/json", jsonBody); + String jsonBody = JsonFormat.printer().alwaysPrintFieldsWithNoPresence().print(builder); + return new HTTPRestResponse(statusCode, APPLICATION_JSON, jsonBody); } catch (InvalidProtocolBufferException e) { return createErrorResponse(new InternalError("Failed to serialize response: " + e.getMessage())); } @@ -358,7 +361,7 @@ public HTTPRestResponse createErrorResponse(A2AError error) { private HTTPRestResponse createErrorResponse(int statusCode, A2AError error) { String jsonBody = new HTTPRestErrorResponse(error).toJson(); - return new HTTPRestResponse(statusCode, "application/json", jsonBody); + return new HTTPRestResponse(statusCode, APPLICATION_PROBLEM_JSON, jsonBody); } private HTTPRestStreamingResponse createStreamingResponse(Flow.Publisher publisher) { @@ -467,7 +470,7 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String t if (!agentCard.capabilities().extendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { throw new ExtendedAgentCardNotConfiguredError(null, "Extended Card not configured", null); } - return new HTTPRestResponse(200, "application/json", JsonUtil.toJson(extendedAgentCard.get())); + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(extendedAgentCard.get())); } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable t) { @@ -477,7 +480,7 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String t public HTTPRestResponse getAgentCard() { try { - return new HTTPRestResponse(200, "application/json", JsonUtil.toJson(agentCard)); + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard)); } catch (Throwable t) { return createErrorResponse(500, new InternalError(t.getMessage())); } diff --git a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java index bb1f3fe37..f896f0af2 100644 --- a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java @@ -54,7 +54,7 @@ public void testGetTaskNotFound() { RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", 0); Assertions.assertEquals(404, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("TaskNotFoundError")); } @@ -79,7 +79,7 @@ public void testListTasksInvalidStatus() { null, null, null); Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("InvalidParamsError")); } @@ -122,7 +122,7 @@ public void testSendMessageInvalidBody() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", invalidBody); Assertions.assertEquals(400, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("JSONParseError"),response.getBody()); } @@ -146,7 +146,7 @@ public void testSendMessageWrongValueBody() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("InvalidParamsError")); } @@ -157,7 +157,7 @@ public void testSendMessageEmptyBody() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); Assertions.assertEquals(400, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("InvalidRequestError")); } @@ -190,7 +190,7 @@ public void testCancelTaskNotFound() { RestHandler.HTTPRestResponse response = handler.cancelTask(callContext, "", requestBody, "nonexistent"); Assertions.assertEquals(404, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("TaskNotFoundError")); } @@ -564,7 +564,7 @@ public void testExtensionSupportRequiredErrorOnSendMessage() { RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); Assertions.assertEquals(400, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("ExtensionSupportRequiredError")); Assertions.assertTrue(response.getBody().contains("https://example.com/test-extension")); } @@ -764,7 +764,7 @@ public void testVersionNotSupportedErrorOnSendMessage() { RestHandler.HTTPRestResponse response = handler.sendMessage(contextWithVersion, "", requestBody); Assertions.assertEquals(501, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("VersionNotSupportedError")); Assertions.assertTrue(response.getBody().contains("2.0")); } @@ -969,7 +969,7 @@ public void testListTasksNegativeTimestampReturns422() { null, "-1", null); Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertEquals("application/problem+json", response.getContentType()); Assertions.assertTrue(response.getBody().contains("InvalidParamsError")); }