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 @@ -174,7 +174,13 @@ public static String toJsonStreamingEvent(StreamingEventKind data) throws JsonPr
public static void writeJsonRpcId(JsonWriter out, @Nullable Object id) throws java.io.IOException {
out.name("id");
if (id == null) {
// JSON-RPC 2.0 section 5: "id" is required in a response and must be null when the
// request id could not be determined. A JsonWriter that does not serialize nulls would
// otherwise drop the member.
boolean serializeNulls = out.getSerializeNulls();
out.setSerializeNulls(true);
out.nullValue();
out.setSerializeNulls(serializeNulls);
} else if (id instanceof Number n) {
if (id instanceof Long || id instanceof Integer || id instanceof Short || id instanceof Byte) {
out.value(n.longValue());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.StringWriter;
import java.util.Map;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonWriter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

public class JsonUtilTest {

Expand Down Expand Up @@ -58,4 +62,27 @@ public void testReadMetadataStringConsistentWithJsonObjectOverload() throws Exce

assertEquals(fromJsonObject, fromString);
}

// writeJsonRpcId tests

@ParameterizedTest
@ValueSource(booleans = {true, false})
public void testWriteJsonRpcIdWritesNullIdAndRestoresSerializeNulls(boolean serializeNulls) throws Exception {
StringWriter result = new StringWriter();
JsonWriter out = new JsonWriter(result);
out.setSerializeNulls(serializeNulls);

out.beginObject();
JsonUtil.writeJsonRpcId(out, null);
assertEquals(serializeNulls, out.getSerializeNulls());
out.name("other").nullValue();
out.endObject();
out.close();

// "id": null is always written; other null members follow the writer's own setting
JsonObject json = JsonParser.parseString(result.toString()).getAsJsonObject();
assertTrue(json.has("id"));
assertTrue(json.get("id").isJsonNull());
assertEquals(serializeNulls, json.has("other"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
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;
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.anyString;
import static org.mockito.Mockito.mock;
Expand All @@ -29,6 +31,9 @@
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 org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskRequest;
Expand All @@ -43,6 +48,9 @@
import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTaskPushNotificationConfigsRequest;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTaskPushNotificationConfigsResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksRequest;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult;
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest;
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.SendStreamingMessageRequest;
Expand All @@ -69,6 +77,8 @@
import io.vertx.ext.web.RoutingContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;

/**
Expand Down Expand Up @@ -728,6 +738,79 @@ public void testMethodNotFound_ContentTypeIsApplicationJson() {
verify(mockHttpResponse).putHeader(CONTENT_TYPE, APPLICATION_JSON);
}

@Test
public void testOmittedParams_ReturnsInvalidParamsError() {
// Arrange - "params" is omitted, which JSON-RPC 2.0 allows
String jsonRpcRequest = """
{
"jsonrpc": "2.0",
"id": 1,
"method": "SendMessage"
}""";
when(mockRequestBody.asString()).thenReturn(jsonRpcRequest);

// Act
routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext);

// Assert - missing message is reported as Invalid params, not as an internal error,
// and the response keeps the request id
JsonObject response = captureResponse();
assertEquals(-32602, response.getAsJsonObject("error").get("code").getAsInt());
assertEquals(1, response.get("id").getAsInt());
}

@Test
public void testOmittedParams_ListTasksIsDispatched() {
// Arrange - "params" is omitted and every ListTasks field is optional
String jsonRpcRequest = """
{
"jsonrpc": "2.0",
"id": 1,
"method": "ListTasks"
}""";
when(mockRequestBody.asString()).thenReturn(jsonRpcRequest);
when(mockJsonRpcHandler.onListTasks(any(ListTasksRequest.class), any(ServerCallContext.class)))
.thenReturn(new ListTasksResponse(1, new ListTasksResult(Collections.emptyList())));

// Act
routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext);

// Assert - the request reaches the handler and the response carries no error
verify(mockJsonRpcHandler).onListTasks(any(ListTasksRequest.class), any(ServerCallContext.class));
JsonObject response = captureResponse();
assertFalse(response.has("error"));
assertEquals(1, response.get("id").getAsInt());
}

@ParameterizedTest
@ValueSource(strings = {
"[]",
"[{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"GetTask\", \"params\": {\"id\": \"task-1\"}}]",
"\"SendMessage\"",
"1",
"true",
"null"
})
public void testNonObjectBody_ReturnsInvalidRequestError(String jsonRpcRequest) {
// Arrange - valid JSON, but not a JSON-RPC request object
when(mockRequestBody.asString()).thenReturn(jsonRpcRequest);

// Act
routes.invokeJSONRPCHandler(jsonRpcRequest, mockRoutingContext);

// Assert - Invalid Request, and "id": null because no request id can be read
JsonObject response = captureResponse();
assertEquals(-32600, response.getAsJsonObject("error").get("code").getAsInt());
assertTrue(response.has("id"));
assertTrue(response.get("id").isJsonNull());
}

private JsonObject captureResponse() {
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(mockHttpResponse).end(bodyCaptor.capture());
return JsonParser.parseString(bodyCaptor.getValue()).getAsJsonObject();
}

@Test
public void testGetAgentCardReturnsNullWhenNoPublicCard() throws Exception {
when(mockJsonRpcHandler.getAgentCard()).thenReturn(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ public class JSONRPCUtils {

public static A2ARequest<?> parseRequestBody(String body, @Nullable String tenant) throws JsonMappingException, JsonProcessingException {
JsonElement jelement = JsonParser.parseString(body);
if (!jelement.isJsonObject()) {
throw new JsonMappingException(null, "Invalid JSON-RPC request: the request must be a JSON object.");
}
JsonObject jsonRpc = jelement.getAsJsonObject();
if (!jsonRpc.has("method")) {
throw new IdJsonMappingException(
Expand All @@ -204,53 +207,55 @@ private static void setTenantIfAbsent(Supplier<String> existingTenantGetter, Con
}
}

private static A2ARequest<?> parseMethodRequest(String version, Object id, String method, JsonElement paramsNode, @Nullable String tenant) throws InvalidParamsError, MethodNotFoundJsonMappingException, JsonProcessingException {
private static A2ARequest<?> parseMethodRequest(String version, Object id, String method, @Nullable JsonElement paramsNode, @Nullable String tenant) throws InvalidParamsError, MethodNotFoundJsonMappingException, JsonProcessingException {
// JSON-RPC 2.0 allows "params" to be omitted: parse it like an empty params object.
JsonElement params = paramsNode == null ? new JsonObject() : paramsNode;
switch (method) {
case GET_TASK_METHOD -> {
org.a2aproject.sdk.grpc.GetTaskRequest.Builder builder = org.a2aproject.sdk.grpc.GetTaskRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new GetTaskRequest(version, id, ProtoUtils.FromProto.taskQueryParams(builder));
}
case CANCEL_TASK_METHOD -> {
org.a2aproject.sdk.grpc.CancelTaskRequest.Builder builder = org.a2aproject.sdk.grpc.CancelTaskRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new CancelTaskRequest(version, id, ProtoUtils.FromProto.cancelTaskParams(builder));
}
case LIST_TASK_METHOD -> {
org.a2aproject.sdk.grpc.ListTasksRequest.Builder builder = org.a2aproject.sdk.grpc.ListTasksRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new ListTasksRequest(version, id, ProtoUtils.FromProto.listTasksParams(builder));
}
case SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> {
org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder builder = org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new CreateTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.createTaskPushNotificationConfig(builder));
}
case GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> {
org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new GetTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.getTaskPushNotificationConfigParams(builder));
}
case SEND_MESSAGE_METHOD -> {
org.a2aproject.sdk.grpc.SendMessageRequest.Builder builder = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new SendMessageRequest(version, id, ProtoUtils.FromProto.messageSendParams(builder));
}
case LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> {
org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.Builder builder = org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new ListTaskPushNotificationConfigsRequest(version, id, ProtoUtils.FromProto.listTaskPushNotificationConfigsParams(builder));
}
case DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD -> {
org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new DeleteTaskPushNotificationConfigRequest(version, id, ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams(builder));
}
Expand All @@ -268,13 +273,13 @@ private static A2ARequest<?> parseMethodRequest(String version, Object id, Strin
}
case SEND_STREAMING_MESSAGE_METHOD -> {
org.a2aproject.sdk.grpc.SendMessageRequest.Builder builder = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new SendStreamingMessageRequest(version, id, ProtoUtils.FromProto.messageSendParams(builder));
}
case SUBSCRIBE_TO_TASK_METHOD -> {
org.a2aproject.sdk.grpc.SubscribeToTaskRequest.Builder builder = org.a2aproject.sdk.grpc.SubscribeToTaskRequest.newBuilder();
parseRequestBody(paramsNode, builder, id);
parseRequestBody(params, builder, id);
setTenantIfAbsent(builder::getTenant, builder::setTenant, tenant);
return new SubscribeToTaskRequest(version, id, ProtoUtils.FromProto.taskIdParams(builder));
}
Expand Down
Loading
Loading