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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,63 @@ public static A2AClientException mapRestError(String body, int code) {
try {
if (body != null && !body.isBlank()) {
JsonObject node = JsonUtil.fromJson(body, JsonObject.class);
// Support RFC 7807 Problem Details format (type, title, details, status)
if (node.has("type")) {
String type = node.get("type").getAsString();
String errorMessage = node.has("title") ? node.get("title").getAsString() : "";
return mapRestErrorByType(type, errorMessage, code);
}
// Legacy format (error, message)
String className = node.has("error") ? node.get("error").getAsString() : "";
String errorMessage = node.has("message") ? node.get("message").getAsString() : "";
return mapRestError(className, errorMessage, code);
return mapRestErrorByClassName(className, errorMessage, code);
}
return mapRestError("", "", code);
return mapRestErrorByClassName("", "", code);
} catch (JsonProcessingException ex) {
Logger.getLogger(RestErrorMapper.class.getName()).log(Level.SEVERE, null, ex);
return new A2AClientException("Failed to parse error response: " + ex.getMessage());
}
}

public static A2AClientException mapRestError(String className, String errorMessage, int code) {
return mapRestErrorByClassName(className, errorMessage, code);
}

/**
* Maps RFC 7807 Problem Details error type URIs to A2A exceptions.
* <p>
* Note: Error constructors receive null for code and data parameters because:
* <ul>
* <li>Error codes are defaulted by each error class (e.g., -32007 for ExtendedAgentCardNotConfiguredError)</li>
* <li>The message comes from the RFC 7807 "title" field</li>
* <li>The data field is optional and not included in basic RFC 7807 responses</li>
* </ul>
*
* @param type the RFC 7807 error type URI (e.g., "https://a2a-protocol.org/errors/task-not-found")
* @param errorMessage the error message from the "title" field
* @param code the HTTP status code (currently unused, kept for consistency)
* @return an A2AClientException wrapping the appropriate A2A error
*/
private static A2AClientException mapRestErrorByType(String type, String errorMessage, int code) {
return switch (type) {
case "https://a2a-protocol.org/errors/task-not-found" -> new A2AClientException(errorMessage, new TaskNotFoundError());
case "https://a2a-protocol.org/errors/extended-agent-card-not-configured" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null));
case "https://a2a-protocol.org/errors/content-type-not-supported" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, errorMessage, null));
case "https://a2a-protocol.org/errors/internal-error" -> new A2AClientException(errorMessage, new InternalError(errorMessage));
case "https://a2a-protocol.org/errors/invalid-agent-response" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, errorMessage, null));
case "https://a2a-protocol.org/errors/invalid-params" -> new A2AClientException(errorMessage, new InvalidParamsError());
case "https://a2a-protocol.org/errors/invalid-request" -> new A2AClientException(errorMessage, new InvalidRequestError());
case "https://a2a-protocol.org/errors/method-not-found" -> new A2AClientException(errorMessage, new MethodNotFoundError());
case "https://a2a-protocol.org/errors/push-notification-not-supported" -> new A2AClientException(errorMessage, new PushNotificationNotSupportedError());
case "https://a2a-protocol.org/errors/task-not-cancelable" -> new A2AClientException(errorMessage, new TaskNotCancelableError());
case "https://a2a-protocol.org/errors/unsupported-operation" -> new A2AClientException(errorMessage, new UnsupportedOperationError());
case "https://a2a-protocol.org/errors/extension-support-required" -> new A2AClientException(errorMessage, new ExtensionSupportRequiredError(null, errorMessage, null));
case "https://a2a-protocol.org/errors/version-not-supported" -> new A2AClientException(errorMessage, new VersionNotSupportedError(null, errorMessage, null));
default -> new A2AClientException(errorMessage);
};
}

private static A2AClientException mapRestErrorByClassName(String className, String errorMessage, int code) {
return switch (className) {
case "io.a2a.spec.TaskNotFoundError" -> new A2AClientException(errorMessage, new TaskNotFoundError());
case "io.a2a.spec.ExtendedCardNotConfiguredError" -> new A2AClientException(errorMessage, new ExtendedAgentCardNotConfiguredError(null, errorMessage, null));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import io.a2a.server.extensions.A2AExtensions;
import io.a2a.server.util.async.Internal;
import io.a2a.spec.A2AError;
import io.a2a.spec.ContentTypeNotSupportedError;
import io.a2a.spec.InternalError;
import io.a2a.spec.InvalidParamsError;
import io.a2a.spec.MethodNotFoundError;
Expand Down Expand Up @@ -165,8 +166,11 @@ public class A2AServerRoutes {
* @param body the JSON request body
* @param rc the Vert.x routing context
*/
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:send$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:send$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
public void sendMessage(@Body String body, RoutingContext rc) {
if(!validateContentType(rc)) {
return;
}
ServerCallContext context = createCallContext(rc, SEND_MESSAGE_METHOD);
HTTPRestResponse response = null;
try {
Expand Down Expand Up @@ -198,8 +202,11 @@ public void sendMessage(@Body String body, RoutingContext rc) {
* @param body the JSON request body
* @param rc the Vert.x routing context
*/
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:stream$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)message:stream$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
public void sendMessageStreaming(@Body String body, RoutingContext rc) {
if(!validateContentType(rc)) {
return;
}
ServerCallContext context = createCallContext(rc, SEND_STREAMING_MESSAGE_METHOD);
HTTPRestStreamingResponse streamingResponse = null;
HTTPRestResponse error = null;
Expand Down Expand Up @@ -339,6 +346,9 @@ public void getTask(RoutingContext rc) {
*/
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+):cancel$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
public void cancelTask(@Body String body, RoutingContext rc) {
if (!validateContentType(rc)) {
return;
}
String taskId = rc.pathParam("taskId");
ServerCallContext context = createCallContext(rc, CANCEL_TASK_METHOD);
HTTPRestResponse response = null;
Expand Down Expand Up @@ -443,8 +453,11 @@ public void subscribeToTask(RoutingContext rc) {
* @param body the JSON request body with notification configuration
* @param rc the Vert.x routing context (taskId extracted from path)
*/
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+)\\/pushNotificationConfigs$", order = 1, methods = {Route.HttpMethod.POST}, consumes = {APPLICATION_JSON}, type = Route.HandlerType.BLOCKING)
public void CreateTaskPushNotificationConfiguration(@Body String body, RoutingContext rc) {
@Route(regex = "^\\/(?<tenant>[^\\/]*\\/?)tasks\\/(?<taskId>[^/]+)\\/pushNotificationConfigs$", order = 1, methods = {Route.HttpMethod.POST}, type = Route.HandlerType.BLOCKING)
public void createTaskPushNotificationConfiguration(@Body String body, RoutingContext rc) {
if(!validateContentType(rc)) {
return;
}
String taskId = rc.pathParam("taskId");
ServerCallContext context = createCallContext(rc, SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD);
HTTPRestResponse response = null;
Expand Down Expand Up @@ -597,6 +610,20 @@ private String extractTenant(RoutingContext rc) {
return tenantPath;
}

/**
* Check if the request content type is application/json.
* @param rc
* @return true if the content type is application/json - false otherwise.
*/
private boolean validateContentType(RoutingContext rc) {
String contentType = rc.request().getHeader(CONTENT_TYPE);
if (contentType == null || !contentType.trim().startsWith(APPLICATION_JSON)) {
sendResponse(rc, jsonRestHandler.createErrorResponse(new ContentTypeNotSupportedError(null, null, null)));
return false;
}
return true;
}

/**
* Retrieves the public agent card for agent discovery.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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.when;

Expand All @@ -26,6 +27,7 @@
import jakarta.enterprise.inject.Instance;

import io.a2a.server.ServerCallContext;
import io.a2a.spec.ContentTypeNotSupportedError;
import io.a2a.transport.rest.handler.RestHandler;
import io.a2a.transport.rest.handler.RestHandler.HTTPRestResponse;
import io.vertx.core.Future;
Expand Down Expand Up @@ -80,6 +82,7 @@ public void setUp() {
when(mockRoutingContext.user()).thenReturn(null);
when(mockRequest.headers()).thenReturn(mockHeaders);
when(mockRequest.params()).thenReturn(mockParams);
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("application/json");
when(mockRoutingContext.body()).thenReturn(mockRequestBody);
when(mockRequestBody.asString()).thenReturn("{}");
when(mockResponse.setStatusCode(any(Integer.class))).thenReturn(mockResponse);
Expand Down Expand Up @@ -358,7 +361,7 @@ public void testCreateTaskPushNotificationConfiguration_MethodNameSetInContext()
ArgumentCaptor<ServerCallContext> contextCaptor = ArgumentCaptor.forClass(ServerCallContext.class);

// Act
routes.CreateTaskPushNotificationConfiguration("{}", mockRoutingContext);
routes.createTaskPushNotificationConfiguration("{}", mockRoutingContext);

// Assert
verify(mockRestHandler).createTaskPushNotificationConfiguration(contextCaptor.capture(), anyString(), eq("{}"), eq("task123"));
Expand Down Expand Up @@ -438,6 +441,61 @@ public void testDeleteTaskPushNotificationConfiguration_MethodNameSetInContext()
assertEquals(DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, capturedContext.getState().get(METHOD_NAME_KEY));
}

@Test
public void testSendMessage_UnsupportedContentType_ReturnsContentTypeNotSupportedError() {
// Arrange
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
when(mockErrorResponse.getStatusCode()).thenReturn(415);
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}");
when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse);
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain");

// Act
routes.sendMessage("{}", mockRoutingContext);

// Assert: createErrorResponse called with ContentTypeNotSupportedError, sendMessage NOT called
verify(mockRestHandler).createErrorResponse(any(ContentTypeNotSupportedError.class));
verify(mockRestHandler, never()).sendMessage(any(ServerCallContext.class), anyString(), anyString());
}

@Test
public void testSendMessageStreaming_UnsupportedContentType_ReturnsContentTypeNotSupportedError() {
// Arrange
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
when(mockErrorResponse.getStatusCode()).thenReturn(415);
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/content-type-not-supported\"}");
when(mockRestHandler.createErrorResponse(any(ContentTypeNotSupportedError.class))).thenReturn(mockErrorResponse);
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("text/plain");

// Act
routes.sendMessageStreaming("{}", mockRoutingContext);

// Assert: createErrorResponse called with ContentTypeNotSupportedError, sendStreamingMessage NOT called
verify(mockRestHandler).createErrorResponse(any(ContentTypeNotSupportedError.class));
verify(mockRestHandler, never()).sendStreamingMessage(any(ServerCallContext.class), anyString(), anyString());
}

@Test
public void testSendMessage_UnsupportedProtocolVersion_ReturnsVersionNotSupportedError() {
// Arrange: content type is OK, but RestHandler returns a VersionNotSupportedError response
HTTPRestResponse mockErrorResponse = mock(HTTPRestResponse.class);
when(mockErrorResponse.getStatusCode()).thenReturn(400);
when(mockErrorResponse.getContentType()).thenReturn("application/problem+json");
when(mockErrorResponse.getBody()).thenReturn("{\"type\":\"https://a2a-protocol.org/errors/version-not-supported\"}");
when(mockRequest.getHeader(any(CharSequence.class))).thenReturn("application/json");
when(mockRestHandler.sendMessage(any(ServerCallContext.class), anyString(), anyString()))
.thenReturn(mockErrorResponse);

// Act
routes.sendMessage("{}", mockRoutingContext);

// Assert: sendMessage was called and error response forwarded
verify(mockRestHandler).sendMessage(any(ServerCallContext.class), anyString(), eq("{}"));
verify(mockResponse).setStatusCode(400);
}

/**
* Helper method to set a field via reflection for testing purposes.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ protected String getTransportUrl() {
@Override
protected abstract void configureTransport(ClientBuilder builder);

@Test
public void testSendMessageWithUnsupportedContentType() throws Exception {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + serverPort + "/message:send"))
.POST(HttpRequest.BodyPublishers.ofString("test body"))
.header("Content-Type", "text/plain")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
Assertions.assertEquals(415, response.statusCode());
Assertions.assertTrue(response.body().contains("content-type-not-supported"),
"Expected content-type-not-supported in response body: " + response.body());
}

@Test
public void testSendMessageWithUnsupportedProtocolVersion() throws Exception {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + serverPort + "/message:send"))
.POST(HttpRequest.BodyPublishers.ofString("{}"))
.header("Content-Type", APPLICATION_JSON)
.header("A2A-Version", "0.4.0")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
Assertions.assertEquals(400, response.statusCode());
Assertions.assertTrue(response.body().contains("version-not-supported"),
"Expected version-not-supported in response body: " + response.body());
}

@Test
public void testMethodNotFound() throws Exception {
// Create the client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
*/
public class ContentTypeNotSupportedError extends A2AProtocolError {

public ContentTypeNotSupportedError() {
this(null, null, null);
}
/**
* Constructs a content type not supported error.
*
Expand Down
Loading
Loading