diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/AbstractJsonRpcResponse.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/AbstractJsonRpcResponse.java new file mode 100644 index 00000000000..f7b1344947d --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/AbstractJsonRpcResponse.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static java.util.Objects.requireNonNull; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * The base for JsonRpcResponse. + */ +@UnstableApi +public abstract class AbstractJsonRpcResponse implements JsonRpcResponse { + @Nullable + private final Object result; + + @Nullable + private final JsonRpcError error; + + /** + * Creates a new instance with result. + */ + protected AbstractJsonRpcResponse(Object result) { + this.result = requireNonNull(result, "result"); + error = null; + } + + /** + * Creates a new instance with error. + */ + protected AbstractJsonRpcResponse(JsonRpcError error) { + result = null; + this.error = requireNonNull(error, "error"); + } + + @Override + @JsonProperty + public final @Nullable Object result() { + return result; + } + + @Override + @JsonProperty + public final @Nullable JsonRpcError error() { + return error; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/DefaultJsonRpcRequest.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/DefaultJsonRpcRequest.java new file mode 100644 index 00000000000..cdbea6f4c78 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/DefaultJsonRpcRequest.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.JacksonUtil; + +/** + * Default {@link JsonRpcRequest} implementation. + */ +final class DefaultJsonRpcRequest implements JsonRpcRequest { + static final ObjectMapper objectMapper = JacksonUtil.newDefaultObjectMapper(); + + @Nullable + private final Object id; + private final String method; + private final JsonRpcParameter params; + private final JsonRpcVersion version; + + DefaultJsonRpcRequest(@Nullable Object id, String method, Iterable params) { + this(id, method, copyParams(params), JsonRpcVersion.JSON_RPC_2_0.getVersion()); + } + + DefaultJsonRpcRequest(@Nullable Object id, String method, Object... params) { + this(id, method, copyParams(params), JsonRpcVersion.JSON_RPC_2_0.getVersion()); + } + + DefaultJsonRpcRequest(@Nullable Object id, String method, Map params) { + this(id, method, copyParams(params), JsonRpcVersion.JSON_RPC_2_0.getVersion()); + } + + private DefaultJsonRpcRequest( + @Nullable Object id, + String method, + Object params, + String version) { + checkArgument(JsonRpcVersion.JSON_RPC_2_0.getVersion().equals(version), + "jsonrpc: %s (expected: 2.0)", version); + checkArgument(id == null || id instanceof Number || id instanceof String, + "id type: %s (expected: Null or Number or String)", + id != null ? id.getClass().getName() : "null"); + checkArgument(params instanceof List || params instanceof Map, + "params type: %s (expected: List or Map)", + params != null ? params.getClass().getName() : "null"); + + @SuppressWarnings("unchecked") + final JsonRpcParameter rpcParams = + params instanceof List ? JsonRpcParameter.of((List) params) + : JsonRpcParameter.of((Map) params); + + this.id = id; + this.method = requireNonNull(method, "method"); + this.params = rpcParams; + this.version = JsonRpcVersion.JSON_RPC_2_0; + } + + @JsonCreator + private static DefaultJsonRpcRequest fromJson( + @JsonProperty("id") @Nullable Object id, + @JsonProperty("method") String method, + @JsonProperty("params") @Nullable Object params, + @JsonProperty("jsonrpc") String version) { + + if (params == null) { + return new DefaultJsonRpcRequest(id, method, ImmutableList.of()); + } + + if (params instanceof Iterable) { + return new DefaultJsonRpcRequest(id, method, (Iterable) params); + } + + return new DefaultJsonRpcRequest(id, method, params, version); + } + + private static List copyParams(Iterable params) { + requireNonNull(params, "params"); + if (params instanceof ImmutableList) { + //noinspection unchecked + return (List) params; + } + + // Note we do not use ImmutableList.copyOf() here, + // because it does not allow a null element and we should allow a null argument. + final List copy; + if (params instanceof Collection) { + copy = new ArrayList<>(((Collection) params).size()); + } else { + copy = new ArrayList<>(8); + } + + for (Object p : params) { + copy.add(p); + } + + return Collections.unmodifiableList(copy); + } + + private static List copyParams(Object... params) { + if (params.length == 0) { + return ImmutableList.of(); + } + + final List copy = new ArrayList<>(params.length); + Collections.addAll(copy, params); + return Collections.unmodifiableList(copy); + } + + private static Map copyParams(Map params) { + requireNonNull(params, "params"); + if (params.isEmpty()) { + return Collections.emptyMap(); + } + + return Collections.unmodifiableMap(new HashMap<>(params)); + } + + @Override + @JsonProperty + public @Nullable Object id() { + return id; + } + + @Override + @JsonProperty + public String method() { + return method; + } + + @Override + @JsonProperty + public JsonRpcParameter params() { + return params; + } + + @Override + @JsonProperty("jsonrpc") + public JsonRpcVersion version() { + return version; + } + + @Override + public int hashCode() { + return Objects.hash(id, method, params, version); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + + if (!(obj instanceof JsonRpcRequest)) { + return false; + } + + final JsonRpcRequest that = (JsonRpcRequest) obj; + return Objects.equals(id, that.id()) && + Objects.equals(method, that.method()) && + Objects.equals(params, that.params()) && + Objects.equals(version, that.version()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id()) + .add("method", method()) + .add("params", params()) + .add("jsonrpc", version()).toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcError.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcError.java new file mode 100644 index 00000000000..eb95d218244 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcError.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static java.util.Objects.requireNonNull; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A JSON-RPC 2.0 error object. + */ +@UnstableApi +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class JsonRpcError { + + /** + * Invalid Request (-32600). + * The JSON sent is not a valid Request object. + */ + public static final JsonRpcError INVALID_REQUEST = new JsonRpcError(-32600, "Invalid Request"); + + /** + * Method not found (-32601). + * The method does not exist / is not available. + */ + public static final JsonRpcError METHOD_NOT_FOUND = new JsonRpcError(-32601, "Method not found"); + + /** + * Invalid params (-32602). + * Invalid method parameter(s). + */ + public static final JsonRpcError INVALID_PARAMS = new JsonRpcError(-32602, "Invalid params"); + + /** + * Internal error (-32603). + * Internal JSON-RPC error. + */ + public static final JsonRpcError INTERNAL_ERROR = new JsonRpcError(-32603, "Internal error"); + + /** + * Parse error (-32700). + * Invalid JSON was received by the server. + * An error occurred on the server while parsing the JSON text. + */ + public static final JsonRpcError PARSE_ERROR = new JsonRpcError(-32700, "Parse error"); + + private final int code; + private final String message; + @Nullable + private final Object data; + + /** + * Creates a new instance with the specified code, message, and optional data. + */ + @JsonCreator + public JsonRpcError(@JsonProperty("code") int code, + @JsonProperty("message") String message, + @JsonProperty("data") @Nullable Object data) { + this.code = code; + this.message = requireNonNull(message, "message"); + this.data = data; + } + + /** + * Creates a new instance with the specified code and message, and no additional data. + */ + public JsonRpcError(int code, String message) { + this(code, requireNonNull(message, "message"), null); + } + + /** + * Creates a new {@link JsonRpcError} instance with the same code and message as this instance. + */ + public JsonRpcError withData(@Nullable Object data) { + if (Objects.equals(data, this.data)) { + return this; + } + + return new JsonRpcError(code, message, data); + } + + /** + * Returns the error code. + */ + @JsonProperty + public int code() { + return code; + } + + /** + * Returns the error message. + */ + @JsonProperty + public String message() { + return message; + } + + /** + * Returns the optional, application-defined data. + */ + @JsonProperty + @Nullable + public Object data() { + return data; + } + + @Override + public int hashCode() { + return Objects.hash(code, message, data); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + + if (!(obj instanceof JsonRpcError)) { + return false; + } + + final JsonRpcError that = (JsonRpcError) obj; + return Objects.equals(code, that.code()) && + Objects.equals(message, that.message()) && + Objects.equals(data, that.data()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("code", code()) + .add("message", message()) + .add("data", data()).toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcParameter.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcParameter.java new file mode 100644 index 00000000000..b1bef8b412a --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcParameter.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A wrapper for + * JSON-RPC 2.0 parameters. + * The parameters can be either positional (a {@link List}) or named (a {@link Map}). + */ +@UnstableApi +public final class JsonRpcParameter { + @Nullable + private final List positional; + + @Nullable + private final Map named; + + private JsonRpcParameter(List positional) { + this.positional = positional; + this.named = null; + } + + private JsonRpcParameter(Map named) { + this.positional = null; + this.named = named; + } + + /** + * Creates a new {@link JsonRpcParameter} instance from positional parameters. + */ + public static JsonRpcParameter of(List positionalParams) { + return new JsonRpcParameter(requireNonNull(positionalParams, "positionalParams")); + } + + /** + * Creates a new {@link JsonRpcParameter} instance from named parameters. + */ + public static JsonRpcParameter of(Map namedParams) { + return new JsonRpcParameter(requireNonNull(namedParams, "namedParams")); + } + + /** + * Returns {@code true} if this parameter is a positional. + */ + public boolean isPositional() { + return positional != null; + } + + /** + * Returns {@code true} if this parameter is a named. + */ + public boolean isNamed() { + return named != null; + } + + /** + * Returns the parameters as a {@link List}. + */ + public List asList() { + if (positional == null) { + throw new IllegalStateException("Not positional parameters."); + } + return positional; + } + + /** + * Returns the parameters as a {@link Map}. + */ + public Map asMap() { + if (named == null) { + throw new IllegalStateException("Not named parameters."); + } + return named; + } + + /** + * Jackson uses this method to serialize the object. + * It returns the non-null parameter (either positional List or named Map). + */ + @JsonValue + private Object value() { + if (positional != null) { + return positional; + } else { + return Objects.requireNonNull(named); + } + } + + @Override + public int hashCode() { + return Objects.hash(positional, named); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + + if (!(obj instanceof JsonRpcParameter)) { + return false; + } + + final JsonRpcParameter that = (JsonRpcParameter) obj; + return Objects.equals(positional, that.positional) && + Objects.equals(named, that.named); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("positional", positional) + .add("named", named).toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequest.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequest.java new file mode 100644 index 00000000000..b986e80a326 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A JSON-RPC request. + */ +@UnstableApi +public interface JsonRpcRequest { + /** + * Creates a new instance with no parameter. + */ + static JsonRpcRequest of(@Nullable Object id, String method) { + return new DefaultJsonRpcRequest(id, requireNonNull(method, "method"), ImmutableList.of()); + } + + /** + * Creates a new instance with a single parameter. + */ + static JsonRpcRequest of(@Nullable Object id, String method, @Nullable Object parameter) { + final List parameters = parameter == null ? ImmutableList.of() + : ImmutableList.of(parameter); + return new DefaultJsonRpcRequest(id, requireNonNull(method, "method"), parameters); + } + + /** + * Creates a new instance with the specified parameters. + */ + static JsonRpcRequest of(@Nullable Object id, String method, Iterable params) { + return new DefaultJsonRpcRequest(id, + requireNonNull(method, "method"), + requireNonNull(params, "params")); + } + + /** + * Creates a new instance with the specified parameters. + */ + static JsonRpcRequest of(@Nullable Object id, String method, Object... params) { + return new DefaultJsonRpcRequest(id, + requireNonNull(method, "method"), + requireNonNull(params, "params")); + } + + /** + * Creates a new instance with the named parameter. + */ + static JsonRpcRequest of(@Nullable Object id, String method, Map parameter) { + return new DefaultJsonRpcRequest(id, + requireNonNull(method, "method"), + requireNonNull(parameter, "parameter")); + } + + /** + * Creates a new instance with a JsonNode. + */ + static JsonRpcRequest of(JsonNode node) throws JsonProcessingException { + requireNonNull(node, "node"); + checkArgument(node.isObject(), "node.isObject(): %s (expected: true)", node.isObject()); + return DefaultJsonRpcRequest.objectMapper.treeToValue(node, DefaultJsonRpcRequest.class); + } + + /** + * Returns {@code true} if this request is a notification. + */ + @JsonIgnore + default boolean isNotification() { + return id() == null; + } + + /** + * Returns the ID of the JSON-RPC request. + * The type must be {@link Number} or {@link String}. + */ + @Nullable + Object id(); + + /** + * Returns the JSON-RPC method name. + */ + String method(); + + /** + * Returns the parameters for the JSON-RPC method. + */ + JsonRpcParameter params(); + + /** + * Returns the JSON-RPC version. + */ + JsonRpcVersion version(); +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponse.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponse.java new file mode 100644 index 00000000000..ed2f1eb059b --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponse.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A JSON-RPC response. + */ +@UnstableApi +public interface JsonRpcResponse { + /** + * Creates a new instance with result. + */ + static JsonRpcResponse of(Object result) { + requireNonNull(result, "result"); + checkArgument(!(result instanceof JsonRpcError), + "result.class: %s (expected: not JsonRpcError)", result.getClass()); + + return new SimpleJsonRpcResponse(result); + } + + /** + * Creates a new instance with error. + */ + static JsonRpcResponse ofError(JsonRpcError error) { + requireNonNull(error); + return new SimpleJsonRpcResponse(error); + } + + /** + * Returns the JSON-RPC result. + */ + @Nullable + Object result(); + + /** + * Returns the JSON-RPC error. + */ + @Nullable + JsonRpcError error(); +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcVersion.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcVersion.java new file mode 100644 index 00000000000..2db178aab02 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/JsonRpcVersion.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import com.fasterxml.jackson.annotation.JsonValue; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * The version of the JSON-RPC specification. + * @see JSON-RPC 2.0 Specification + */ +@UnstableApi +public enum JsonRpcVersion { + JSON_RPC_2_0("2.0"); + + private final String version; + + JsonRpcVersion(String version) { + this.version = version; + } + + /** + * Returns the string representation of the JSON-RPC version. + */ + @JsonValue + public String getVersion() { + return version; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/SimpleJsonRpcResponse.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/SimpleJsonRpcResponse.java new file mode 100644 index 00000000000..0910820eb5a --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/SimpleJsonRpcResponse.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import java.util.Objects; + +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class SimpleJsonRpcResponse extends AbstractJsonRpcResponse { + SimpleJsonRpcResponse(Object result) { + super(result); + } + + SimpleJsonRpcResponse(JsonRpcError error) { + super(error); + } + + @Override + public int hashCode() { + return Objects.hash(result(), error()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + + if (!(obj instanceof SimpleJsonRpcResponse)) { + return false; + } + + final SimpleJsonRpcResponse that = (SimpleJsonRpcResponse) obj; + return Objects.equals(result(), that.result()) && + Objects.equals(error(), that.error()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("result", result()) + .add("error", error()).toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/jsonrpc/package-info.java b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/package-info.java new file mode 100644 index 00000000000..461e00ec1a7 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/jsonrpc/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** +* Common classes for the JSON-RPC. +*/ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.common.jsonrpc; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/core/src/main/java/com/linecorp/armeria/server/jsonrpc/DefaultJsonRpcResponse.java b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/DefaultJsonRpcResponse.java new file mode 100644 index 00000000000..f6707b2ca9e --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/DefaultJsonRpcResponse.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.jsonrpc; + +import static java.util.Objects.requireNonNull; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.jsonrpc.AbstractJsonRpcResponse; +import com.linecorp.armeria.common.jsonrpc.JsonRpcError; +import com.linecorp.armeria.common.jsonrpc.JsonRpcVersion; + +@JsonInclude(JsonInclude.Include.NON_NULL) +final class DefaultJsonRpcResponse extends AbstractJsonRpcResponse { + @Nullable + private final Object id; + + DefaultJsonRpcResponse(Object id, Object result) { + super(result); + this.id = requireNonNull(id, "id"); + } + + DefaultJsonRpcResponse(@Nullable Object id, JsonRpcError error) { + super(error); + this.id = id; + } + + @JsonProperty + public @Nullable Object id() { + return id; + } + + @JsonProperty("jsonrpc") + public JsonRpcVersion version() { + return JsonRpcVersion.JSON_RPC_2_0; + } + + @Override + public int hashCode() { + return Objects.hash(id(), result(), error(), version()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + + if (!(obj instanceof DefaultJsonRpcResponse)) { + return false; + } + + final DefaultJsonRpcResponse that = (DefaultJsonRpcResponse) obj; + return Objects.equals(id(), that.id()) && + Objects.equals(result(), that.result()) && + Objects.equals(error(), that.error()) && + version() == that.version(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id()) + .add("result", result()) + .add("error", error()) + .add("version", version()).toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcHandler.java b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcHandler.java new file mode 100644 index 00000000000..feae2a0ff58 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcHandler.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.jsonrpc; + +import java.util.concurrent.CompletableFuture; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.jsonrpc.JsonRpcRequest; +import com.linecorp.armeria.common.jsonrpc.JsonRpcResponse; +import com.linecorp.armeria.server.ServiceRequestContext; + +/** + * Implement this interface to handle incoming {@link JsonRpcRequest}. + */ +@UnstableApi +@FunctionalInterface +public interface JsonRpcHandler { + /** + * Handles the incoming {@link JsonRpcRequest} and returns {@link JsonRpcResponse}. + */ + CompletableFuture handle(ServiceRequestContext ctx, JsonRpcRequest request); +} diff --git a/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcService.java b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcService.java new file mode 100644 index 00000000000..0bd980addda --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcService.java @@ -0,0 +1,250 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.jsonrpc; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; + +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.Flags; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.jsonrpc.JsonRpcError; +import com.linecorp.armeria.common.jsonrpc.JsonRpcRequest; +import com.linecorp.armeria.common.jsonrpc.JsonRpcResponse; +import com.linecorp.armeria.common.util.UnmodifiableFuture; +import com.linecorp.armeria.internal.common.JacksonUtil; +import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.ServiceRequestContext; + +/** + * A JSON-RPC {@link HttpService}. + * + *

Example: + *

{@code
+ * class EchoHandler implements JsonRpcHandler {
+ *  @Override
+ *  public CompletableFuture handle(ServiceRequestContext ctx, JsonRpcRequest request) {
+ *      return UnmodifiableFuture.completedFuture(JsonRpcResponse.of(request.params()));
+ *  }
+ * }
+ *
+ * JsonRpcService jsonRpcService = JsonRpcService.builder()
+ *                                               .addHandler("echo", new EchoHandler())
+ *                                               .build();
+ *
+ * ServerBuilder sb = Server.builder();
+ * sb.service("/json-rpc", jsonRpcService);
+ * }
+ */ +@UnstableApi +public final class JsonRpcService implements HttpService { + private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); + + private final Map methodHandlers; + + /** + * Returns a new {@link JsonRpcServiceBuilder}. + */ + public static JsonRpcServiceBuilder builder() { + return new JsonRpcServiceBuilder(); + } + + JsonRpcService(Map methodHandlers) { + this.methodHandlers = requireNonNull(methodHandlers, "methodHandlers"); + } + + @Override + public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) { + if (req.method() != HttpMethod.POST) { + return HttpResponse.of(HttpStatus.METHOD_NOT_ALLOWED); + } + + final MediaType contentType = req.contentType(); + if (contentType == null || !contentType.isJson()) { + return HttpResponse.of(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + } + + return HttpResponse.of( + req.aggregate() + .handle((aggregate, throwable) -> { + if (throwable != null) { + return HttpResponse.ofJson(HttpStatus.INTERNAL_SERVER_ERROR, + JsonRpcError.INTERNAL_ERROR.withData( + throwable.getMessage())); + } + + try { + final JsonNode parsedJson = parseRequestContentAsJson(aggregate); + return dispatchRequest(ctx, parsedJson); + } catch (Exception e) { + if (e instanceof IllegalArgumentException) { + return HttpResponse.ofJson(HttpStatus.BAD_REQUEST, + JsonRpcError.PARSE_ERROR); + } + return HttpResponse.ofJson(HttpStatus.INTERNAL_SERVER_ERROR, + JsonRpcError.INTERNAL_ERROR.withData(e.getMessage())); + } + })); + } + + @VisibleForTesting + static JsonNode parseRequestContentAsJson(AggregatedHttpRequest request) { + try { + return mapper.readTree(request.contentUtf8()); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } + + @VisibleForTesting + HttpResponse dispatchRequest(ServiceRequestContext ctx, JsonNode rawRequest) { + if (rawRequest.isObject()) { + return handleUnaryRequest(ctx, rawRequest); + } else { + return HttpResponse.ofJson(HttpStatus.BAD_REQUEST, + JsonRpcError.INVALID_REQUEST.withData("Batch requests are not supported by this server.")); + } + } + + private HttpResponse handleUnaryRequest(ServiceRequestContext ctx, JsonNode unary) { + return HttpResponse.of(executeRpcCall(ctx, unary).thenApply(JsonRpcService::toHttpResponse)); + } + + private static HttpResponse toHttpResponse(DefaultJsonRpcResponse response) { + if (response == null) { + return HttpResponse.of(ResponseHeaders.of(HttpStatus.ACCEPTED)); + } + + if (response.error() != null) { + if (response.error().code() == JsonRpcError.INTERNAL_ERROR.code()) { + return HttpResponse.ofJson(HttpStatus.INTERNAL_SERVER_ERROR, response); + } + + return HttpResponse.ofJson(HttpStatus.BAD_REQUEST, response); + } + + return HttpResponse.ofJson(response); + } + + @VisibleForTesting + CompletableFuture executeRpcCall(ServiceRequestContext ctx, JsonNode node) { + final JsonRpcRequest request; + try { + request = parseNodeAsRpcRequest(node); + maybeLogRequestContent(ctx, request, node); + } catch (IllegalArgumentException e) { + return UnmodifiableFuture.completedFuture( + new DefaultJsonRpcResponse(null, JsonRpcError.PARSE_ERROR.withData(e.getMessage()))); + } + + return invokeMethod(ctx, request) + .exceptionally(e -> { + return new DefaultJsonRpcResponse(request.id(), + JsonRpcError.INTERNAL_ERROR.withData(e.getMessage())); + }); + } + + private static void maybeLogRequestContent(ServiceRequestContext ctx, JsonRpcRequest request, + JsonNode node) { + // Introduce another flag or add a property to the builder instead of using + // the annotatedServiceContentLogging flag. + if (!Flags.annotatedServiceContentLogging()) { + return; + } + + ctx.logBuilder().requestContent(request, node); + } + + @VisibleForTesting + static JsonRpcRequest parseNodeAsRpcRequest(JsonNode node) { + try { + return JsonRpcRequest.of(node); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } + + @VisibleForTesting + CompletableFuture invokeMethod(ServiceRequestContext ctx, JsonRpcRequest req) { + final JsonRpcHandler handler = methodHandlers.get(req.method()); + + // Notification + if (req.id() == null) { + if (handler != null) { + return handler.handle(ctx, req) + .thenApply(res -> null); + } + return UnmodifiableFuture.completedFuture(null); + } + + if (handler == null) { + return UnmodifiableFuture.completedFuture( + new DefaultJsonRpcResponse(req.id(), JsonRpcError.METHOD_NOT_FOUND)); + } + return handler.handle(ctx, req) + .thenApply(res -> { + final DefaultJsonRpcResponse finalResponse = buildFinalResponse(req, res); + maybeLogResponseContent(ctx, finalResponse, res); + return finalResponse; + }); + } + + private static void maybeLogResponseContent(ServiceRequestContext ctx, + DefaultJsonRpcResponse response, + JsonRpcResponse originalResponse) { + if (!Flags.annotatedServiceContentLogging()) { + return; + } + + ctx.logBuilder().responseContent(response, originalResponse); + } + + @VisibleForTesting + DefaultJsonRpcResponse buildFinalResponse(JsonRpcRequest request, JsonRpcResponse response) { + if (response instanceof DefaultJsonRpcResponse) { + return (DefaultJsonRpcResponse) response; + } + + final Object id = request.id(); + final Object result = response.result(); + final JsonRpcError error = response.error(); + if (id != null && result != null && error == null) { + return new DefaultJsonRpcResponse(id, result); + } + if (error != null && result == null) { + return new DefaultJsonRpcResponse(id, error); + } + return new DefaultJsonRpcResponse( + id, + // Leave a warning message instead of sending this error response to the client + // because the server-side handler implementation is faulty. + JsonRpcError.INTERNAL_ERROR.withData( + "A response cannot have both or neither 'result' and 'error' fields.")); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceBuilder.java b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceBuilder.java new file mode 100644 index 00000000000..40eb06fcb5e --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceBuilder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.jsonrpc; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.util.Objects.requireNonNull; + +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Constructs a {@link JsonRpcService} to serve JSON-RPC services. + */ +@UnstableApi +public final class JsonRpcServiceBuilder { + + private final ImmutableMap.Builder methodHandlers = ImmutableMap.builder(); + + JsonRpcServiceBuilder() {} + + /** + * Adds a JSON-RPC {@link JsonRpcHandler} to this {@link JsonRpcServiceBuilder}. + */ + public JsonRpcServiceBuilder addHandler(String methodName, JsonRpcHandler handler) { + requireNonNull(methodName, "methodName"); + checkArgument(!methodName.isEmpty(), "methodName must not be empty"); + requireNonNull(handler, "handler"); + + methodHandlers.put(methodName, handler); + return this; + } + + /** + * Constructs a new {@link JsonRpcService}. + */ + public JsonRpcService build() { + final ImmutableMap handlers = methodHandlers.build(); + checkState(!handlers.isEmpty(), "no handlers were added"); + + return new JsonRpcService(handlers); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/jsonrpc/package-info.java b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/package-info.java new file mode 100644 index 00000000000..92ccb5dd8ae --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/jsonrpc/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** +* Server-side classes for the JSON-RPC. +*/ +@UnstableApi +@NonNullByDefault +package com.linecorp.armeria.server.jsonrpc; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequestTest.java b/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequestTest.java new file mode 100644 index 00000000000..ad3c9bfa391 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcRequestTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; + +/** + * A test for {@link JsonRpcRequest}. + */ +class JsonRpcRequestTest { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Test + void of_withNoParams() { + final JsonRpcRequest req = JsonRpcRequest.of("1", "subtract"); + + assertThat(req.id()).isEqualTo("1"); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).isEmpty(); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withSingleParam() { + final JsonRpcRequest req = JsonRpcRequest.of(2, "subtract", "param"); + + assertThat(req.id()).isEqualTo(2); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).containsExactly("param"); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withSingleNullParam() { + final JsonRpcRequest req = JsonRpcRequest.of(3, "subtract", (Object) null); + + assertThat(req.id()).isEqualTo(3); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).isEmpty(); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withIterableParams() { + final List params = Arrays.asList("foo", "bar", 5); + final JsonRpcRequest req = JsonRpcRequest.of("abc-123", "update", params); + + assertThat(req.id()).isEqualTo("abc-123"); + assertThat(req.method()).isEqualTo("update"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).isEqualTo(params); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withEmptyIterableParams() { + final JsonRpcRequest req = JsonRpcRequest.of(4, "get_data", Collections.emptyList()); + + assertThat(req.id()).isEqualTo(4); + assertThat(req.method()).isEqualTo("get_data"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).isEmpty(); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withVarargsParams() { + final JsonRpcRequest req = JsonRpcRequest.of(null, "sum", 1, 2, 4); + + assertThat(req.id()).isNull(); + assertThat(req.method()).isEqualTo("sum"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).containsExactly(1, 2, 4); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_withNamedParam() { + final Map params = ImmutableMap.of("subtrahend", 23); + final JsonRpcRequest req = JsonRpcRequest.of(1, "subtract", params); + + assertThat(req.id()).isEqualTo(1); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isNamed()); + assertThat(req.params().asMap()).isEqualTo(params); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_fromJsonNode() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"subtract\", \"params\": [42, 23], \"id\": 1}"; + final JsonNode node = mapper.readTree(json); + + final JsonRpcRequest req = JsonRpcRequest.of(node); + + assertThat(req.id()).isEqualTo(1); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).containsExactly(42, 23); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_fromJsonNode_withNullParams() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"subtract\", \"params\": null, \"id\": 1}"; + final JsonNode node = mapper.readTree(json); + + final JsonRpcRequest req = JsonRpcRequest.of(node); + + assertThat(req.id()).isEqualTo(1); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).isEmpty(); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_fromJsonNode_withNamedParams() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"subtract\", \"params\": {\"subtrahend\": 23}, \"id\": 3}"; + final JsonNode node = mapper.readTree(json); + final JsonRpcRequest req = JsonRpcRequest.of(node); + + assertThat(req.id()).isEqualTo(3); + assertThat(req.method()).isEqualTo("subtract"); + assertTrue(req.params().isNamed()); + assertThat(req.params().asMap()).containsEntry("subtrahend", 23); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_fromJsonNode_notification() throws JsonProcessingException { + final String json = "{\"jsonrpc\": \"2.0\", \"method\": \"update\", \"params\": [1,2,3,4,5]}"; + final JsonNode node = mapper.readTree(json); + + final JsonRpcRequest req = JsonRpcRequest.of(node); + + assertThat(req.id()).isNull(); + assertThat(req.method()).isEqualTo("update"); + assertTrue(req.params().isPositional()); + assertThat(req.params().asList()).containsExactly(1, 2, 3, 4, 5); + assertThat(req.version()).isEqualTo(JsonRpcVersion.JSON_RPC_2_0); + } + + @Test + void of_fromJsonNode_notAnObject() throws JsonProcessingException { + final JsonNode node = mapper.readTree("[]"); + + assertThatThrownBy(() -> JsonRpcRequest.of(node)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("node.isObject(): false (expected: true)"); + } + + @Test + void of_fromJsonNode_missingRequiredField() throws JsonProcessingException { + final String json = "{\"jsonrpc\": \"2.0\", \"id\": 1}"; + final JsonNode node = mapper.readTree(json); + + assertThatThrownBy(() -> JsonRpcRequest.of(node)) + .isInstanceOf(JsonProcessingException.class); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponseTest.java b/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponseTest.java new file mode 100644 index 00000000000..246d212830c --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/jsonrpc/JsonRpcResponseTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.common.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +public class JsonRpcResponseTest { + @Test + void of_withObject() { + final Object result = "hello"; + final JsonRpcResponse res = JsonRpcResponse.of(result); + + assertThat(res.result()).isEqualTo(result); + assertThat(res.error()).isNull(); + } + + @Test + void of_withJsonRpcError() { + assertThatThrownBy(() -> JsonRpcResponse.of(JsonRpcError.INVALID_PARAMS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + String.format("result.class: %s (expected: not JsonRpcError)",JsonRpcError.class)); + } + + @Test + void ofError() { + final JsonRpcError error = JsonRpcError.INTERNAL_ERROR; + final JsonRpcResponse res = JsonRpcResponse.ofError(error); + + assertThat(res.result()).isNull(); + assertThat(res.error()).isEqualTo(error); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceTest.java b/core/src/test/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceTest.java new file mode 100644 index 00000000000..c4942e430e4 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/jsonrpc/JsonRpcServiceTest.java @@ -0,0 +1,475 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package com.linecorp.armeria.server.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.jsonrpc.JsonRpcError; +import com.linecorp.armeria.common.jsonrpc.JsonRpcParameter; +import com.linecorp.armeria.common.jsonrpc.JsonRpcRequest; +import com.linecorp.armeria.common.jsonrpc.JsonRpcResponse; +import com.linecorp.armeria.common.util.UnmodifiableFuture; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class JsonRpcServiceTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static final class EchoHandler implements JsonRpcHandler { + @Override + public CompletableFuture handle(ServiceRequestContext ctx, JsonRpcRequest request) { + return UnmodifiableFuture.completedFuture(JsonRpcResponse.of(request.params())); + } + } + + private static final class FailingHandler implements JsonRpcHandler { + @Override + public CompletableFuture handle(ServiceRequestContext ctx, JsonRpcRequest request) { + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new RuntimeException("error")); + return future; + } + } + + private static final class ExceptionHandler implements JsonRpcHandler { + @Override + public CompletableFuture handle(ServiceRequestContext ctx, JsonRpcRequest request) { + throw new RuntimeException("error"); + } + } + + private static final JsonRpcService jsonRpcService = JsonRpcService.builder() + .addHandler("echo", new EchoHandler()) + .addHandler("fail", new FailingHandler()) + .addHandler("exception", new ExceptionHandler()) + .build(); + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.service("/json-rpc", jsonRpcService); + sb.requestTimeoutMillis(0); + } + }; + + private WebClient client() { + return WebClient.builder(server.httpUri()) + .responseTimeoutMillis(0) + .build(); + } + + @Test + void parseRequestContentAsJson_whenInvalidJson_thenThrowsException() { + final String invalidJsonRequest = "{\"jsonrpc\": \"2.0\", \"method\": \"echo\"]"; + final AggregatedHttpRequest httpRequest = + AggregatedHttpRequest.of(HttpMethod.POST, + "/json-rpc", + MediaType.JSON_UTF_8, + HttpData.ofUtf8(invalidJsonRequest)); + + assertThrows(IllegalArgumentException.class, () -> { + JsonRpcService.parseRequestContentAsJson(httpRequest); + }); + } + + @Test + void parseRequestContentAsJson_whenUnaryRequest_thenSucceeds() throws JsonProcessingException { + final JsonRpcRequest jsonRpcUnaryRequest = JsonRpcRequest.of(1, "echo", "hello"); + final String jsonRpcRequestString = objectMapper.writeValueAsString(jsonRpcUnaryRequest); + final AggregatedHttpRequest aggregatedHttpRequest = + AggregatedHttpRequest.of(HttpMethod.POST, + "/json-rpc", + MediaType.JSON_UTF_8, + HttpData.ofUtf8(jsonRpcRequestString)); + + final JsonNode actualJsonNode = JsonRpcService.parseRequestContentAsJson(aggregatedHttpRequest); + final JsonNode expectedJsonNode = objectMapper.readTree(jsonRpcRequestString); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } + + @Test + void parseRequestContentAsJson_whenBatchRequest_thenSucceeds() throws JsonProcessingException { + final List jsonRpcBatchRequest = Arrays.asList( + JsonRpcRequest.of(1, "echo", "hello"), + JsonRpcRequest.of(2, "echo", "world"), + JsonRpcRequest.of(3, "echo", "armeria")); + final String jsonRpcRequestString = objectMapper.writeValueAsString(jsonRpcBatchRequest); + final AggregatedHttpRequest aggregatedHttpRequest = + AggregatedHttpRequest.of(HttpMethod.POST, + "/json-rpc", + MediaType.JSON_UTF_8, + HttpData.ofUtf8(jsonRpcRequestString)); + + final JsonNode actualJsonNode = JsonRpcService.parseRequestContentAsJson(aggregatedHttpRequest); + final JsonNode expectedJsonNode = objectMapper.readTree(jsonRpcRequestString); + + assertThat(actualJsonNode).isEqualTo(expectedJsonNode); + } + + @Test + void parseNodeAsRpcRequest_whenMissingMethod_thenThrowsException() throws JsonProcessingException { + final String invalidJsonString = "{\"jsonrpc\": \"2.0\", \"id\": 1}"; + final JsonNode jsonNode = objectMapper.readTree(invalidJsonString); + + assertThrows(IllegalArgumentException.class, () -> { + JsonRpcService.parseNodeAsRpcRequest(jsonNode); + }); + } + + @Test + void parseNodeAsRpcRequest_whenValidNode_thenSucceeds() throws JsonProcessingException { + final JsonRpcRequest jsonRpcRequest = JsonRpcRequest.of(1, "hello"); + final JsonNode jsonNode = objectMapper.valueToTree(jsonRpcRequest); + final JsonRpcRequest actualRequest = JsonRpcService.parseNodeAsRpcRequest(jsonNode); + + assertThat(actualRequest.id()).isEqualTo(jsonRpcRequest.id()); + assertThat(actualRequest.method()).isEqualTo(jsonRpcRequest.method()); + assertThat(actualRequest.params().asList()).isEmpty(); + assertThat(actualRequest.version()).isEqualTo(jsonRpcRequest.version()); + } + + @Test + void buildFinalResponse_whenResultResponse_thenSucceeds() { + final JsonRpcRequest jsonRpcRequest = JsonRpcRequest.of(1, "hello"); + final JsonRpcResponse jsonRpcResponse = JsonRpcResponse.of("world"); + + final DefaultJsonRpcResponse actualJsonRpcResponse = + jsonRpcService.buildFinalResponse(jsonRpcRequest, jsonRpcResponse); + final DefaultJsonRpcResponse expectedJsonRpcResponse = + new DefaultJsonRpcResponse(1, "world"); + + assertThat(actualJsonRpcResponse).isEqualTo(expectedJsonRpcResponse); + } + + @Test + void buildFinalResponse_whenErrorResponse_thenSucceeds() { + final JsonRpcRequest jsonRpcRequest = JsonRpcRequest.of(1, "hello"); + final JsonRpcResponse jsonRpcResponse = JsonRpcResponse.ofError(JsonRpcError.INVALID_PARAMS); + + final DefaultJsonRpcResponse actualJsonRpcResponse = + jsonRpcService.buildFinalResponse(jsonRpcRequest, jsonRpcResponse); + final DefaultJsonRpcResponse expectedJsonRpcResponse = + new DefaultJsonRpcResponse(1, JsonRpcError.INVALID_PARAMS); + + assertThat(actualJsonRpcResponse).isEqualTo(expectedJsonRpcResponse); + } + + @Test + void buildFinalResponse_whenInvalidResult_thenReturnsInternalError() { + final JsonRpcRequest request = JsonRpcRequest.of(1, "hello"); + final JsonRpcResponse invalidResponse = new JsonRpcResponse() { + @Override + public Object result() { + return "result"; + } + + @Override + public JsonRpcError error() { + return JsonRpcError.INTERNAL_ERROR; + } + }; + + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.buildFinalResponse(request, invalidResponse); + final DefaultJsonRpcResponse expectedResponse = + new DefaultJsonRpcResponse(request.id(), JsonRpcError.INTERNAL_ERROR + .withData("A response cannot have both or neither 'result' and 'error' fields.")); + + assertThat(actualResponse).isEqualTo(expectedResponse); + } + + @Test + void invokeMethod_whenNotificationRequest_thenReturnsNull() { + final JsonRpcRequest notificationRequest = JsonRpcRequest.of(null, "echo", "hello"); + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.invokeMethod(null, notificationRequest).join(); + + assertThat(actualResponse).isNull(); + } + + @Test + void invokeMethod_whenHandlerNotFound_thenReturnsMethodNotFoundError() { + final JsonRpcRequest requestWithUnknownMethod = JsonRpcRequest.of(1, "Unknown", "hello"); + + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.invokeMethod(null, requestWithUnknownMethod).join(); + final DefaultJsonRpcResponse expectedResponse = + new DefaultJsonRpcResponse(1, JsonRpcError.METHOD_NOT_FOUND); + + assertThat(actualResponse).isEqualTo(expectedResponse); + } + + @Test + void invokeMethod_whenPosParams_thenSucceeds() { + final JsonRpcRequest validRequest = JsonRpcRequest.of(1, "echo", 1, 2, 3, 4); + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.invokeMethod(newCtx(), validRequest).join(); + + assertThat(actualResponse.id()).isEqualTo(validRequest.id()); + assertThat(actualResponse.result()).isInstanceOf(JsonRpcParameter.class); + assertTrue(((JsonRpcParameter) actualResponse.result()).isPositional()); + assertThat(((JsonRpcParameter) actualResponse.result()).asList()).containsExactly(1, 2, 3, 4); + assertThat(actualResponse.version()).isEqualTo(validRequest.version()); + } + + @Test + void invokeMethod_whenNamedParams_thenSucceeds() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"echo\", \"params\": {\"subtrahend\": 23}, \"id\": 3}"; + final JsonNode node = objectMapper.readTree(json); + final JsonRpcRequest validRequest = JsonRpcRequest.of(node); + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.invokeMethod(newCtx(), validRequest).join(); + + assertThat(actualResponse.id()).isEqualTo(validRequest.id()); + assertThat(actualResponse.result()).isInstanceOf(JsonRpcParameter.class); + assertTrue(((JsonRpcParameter) actualResponse.result()).isNamed()); + assertThat(((JsonRpcParameter) actualResponse.result()).asMap()).containsEntry("subtrahend", 23); + assertThat(actualResponse.version()).isEqualTo(validRequest.version()); + } + + @Test + void invokeMethod_whenHandlerThrowsException_thenThrowsException() { + final JsonRpcRequest requestToFailingHandler = JsonRpcRequest.of(1, "fail"); + + assertThrows(RuntimeException.class, () -> { + jsonRpcService.invokeMethod(null, requestToFailingHandler).join(); + }); + } + + @Test + void executeRpcCall_whenParseError_thenReturnsParseError() throws JsonProcessingException { + final String jsonWithMissingMethod = "{\"jsonrpc\": \"2.0\", \"id\": 1}"; + final JsonNode jsonNode = objectMapper.readTree(jsonWithMissingMethod); + + final DefaultJsonRpcResponse actualResponse = jsonRpcService.executeRpcCall(null, jsonNode).join(); + + assertThat(actualResponse.id()).isNull(); + assertThat(actualResponse.error().code()).isEqualTo(JsonRpcError.PARSE_ERROR.code()); + assertThat(actualResponse.error().message()).isEqualTo(JsonRpcError.PARSE_ERROR.message()); + } + + @Test + void executeRpcCall_whenHandlerThrowsException_thenReturnsInternalError() { + final JsonNode requestNode = objectMapper.valueToTree(JsonRpcRequest.of(1, "fail")); + + final DefaultJsonRpcResponse actualResponse = + jsonRpcService.executeRpcCall(newCtx(), requestNode).join(); + + assertThat(actualResponse.id()).isEqualTo(1); + assertThat(actualResponse.error().code()).isEqualTo(JsonRpcError.INTERNAL_ERROR.code()); + assertThat(actualResponse.error().message()).isEqualTo(JsonRpcError.INTERNAL_ERROR.message()); + assertThat(actualResponse.error().data().toString()).contains("error"); + } + + @Test + void dispatchRequest_whenUnaryPosRequest_thenHandlesSuccessfully() throws JsonProcessingException { + final JsonNode requestNode = objectMapper.valueToTree(JsonRpcRequest.of(1, "echo", 1, 2, 3, 4)); + + final AggregatedHttpResponse aggregatedHttpResponse = + jsonRpcService.dispatchRequest(newCtx(), requestNode) + .aggregate().join(); + + final JsonNode actualNode = objectMapper.readTree(aggregatedHttpResponse.contentUtf8()); + final JsonNode expectedNode = + objectMapper.valueToTree(new DefaultJsonRpcResponse(1, ImmutableList.of(1, 2, 3, 4))); + + assertThat(actualNode).isEqualTo(expectedNode); + } + + @Test + void dispatchRequest_whenUnaryNamedRequest_thenHandlesSuccessfully() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"echo\", \"params\": {\"subtrahend\": 23}, \"id\": 1}"; + final JsonNode requestNode = objectMapper.readTree(json); + + final AggregatedHttpResponse aggregatedHttpResponse = + jsonRpcService.dispatchRequest(newCtx(), requestNode) + .aggregate().join(); + + final JsonNode actualNode = objectMapper.readTree(aggregatedHttpResponse.contentUtf8()); + final JsonNode expectedNode = + objectMapper.valueToTree(new DefaultJsonRpcResponse(1, ImmutableMap.of("subtrahend", 23))); + + assertThat(actualNode).isEqualTo(expectedNode); + } + + @Test + void dispatchRequest_whenNotificationRequest_thenReturnsNoContent() throws JsonProcessingException { + final JsonNode requestNode = objectMapper.valueToTree(JsonRpcRequest.of(null, "echo", "hello")); + + final AggregatedHttpResponse aggregatedHttpResponse = + jsonRpcService.dispatchRequest(newCtx(), requestNode) + .aggregate().join(); + + assertThat(aggregatedHttpResponse.status()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(aggregatedHttpResponse.contentUtf8()).isEmpty(); + } + + @Test + void dispatchRequest_whenInvalidNode_thenReturnsBadRequest() { + final JsonNode invalidJsonNode = objectMapper.createObjectNode().put("test", 1) + .get("test"); + + final AggregatedHttpResponse aggregatedHttpResponse = + jsonRpcService.dispatchRequest(null, invalidJsonNode) + .aggregate().join(); + + assertThat(aggregatedHttpResponse.status()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void serve_whenInvalidJsonFormat_thenReturnsBadRequest() { + final String invalidJson = "{\"jsonrpc\": \"2.0\", \"method\": \"echo\"]"; + final AggregatedHttpResponse aggregatedHttpResponse = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.JSON_UTF_8, invalidJson) + .build()) + .aggregate().join(); + + assertThat(aggregatedHttpResponse.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(aggregatedHttpResponse.content().toStringUtf8()).contains( + objectMapper.valueToTree(JsonRpcError.PARSE_ERROR).toString()); + } + + @Test + void serve_whenUnaryPosRequest_thenHandlesSuccessfully() throws JsonProcessingException { + final JsonRpcRequest request = JsonRpcRequest.of(1, "echo", 1, 2, 3, 4); + final AggregatedHttpResponse aggregatedHttpResponse = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.JSON_UTF_8, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(aggregatedHttpResponse.status()).isEqualTo(HttpStatus.OK); + + final JsonNode actualNode = objectMapper.readTree(aggregatedHttpResponse.contentUtf8()); + final JsonNode expectedNode = + objectMapper.valueToTree(new DefaultJsonRpcResponse(1, ImmutableList.of(1, 2, 3, 4))); + + assertThat(actualNode).isEqualTo(expectedNode); + } + + @Test + void serve_whenUnaryNamedRequest_thenHandlesSuccessfully() throws JsonProcessingException { + final String json = + "{\"jsonrpc\": \"2.0\", \"method\": \"echo\", \"params\": {\"subtrahend\": 23}, \"id\": 1}"; + final JsonNode node = objectMapper.readTree(json); + final JsonRpcRequest request = JsonRpcRequest.of(node); + + final AggregatedHttpResponse aggregatedHttpResponse = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.JSON_UTF_8, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(aggregatedHttpResponse.status()).isEqualTo(HttpStatus.OK); + + final JsonNode actualNode = objectMapper.readTree(aggregatedHttpResponse.contentUtf8()); + final JsonNode expectedNode = + objectMapper.valueToTree(new DefaultJsonRpcResponse(1, ImmutableMap.of("subtrahend", 23))); + + assertThat(actualNode).isEqualTo(expectedNode); + } + + @Test + void serve_whenUnaryNotificationRequest_thenReturnsEmptyContent() throws JsonProcessingException { + final JsonRpcRequest request = JsonRpcRequest.of(null, "echo", "hello"); + final AggregatedHttpResponse response = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.JSON_UTF_8, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(response.status()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.contentUtf8()).isEmpty(); + } + + @Test + void serve_whenGetRequest_thenReturnsMethodNotAllowed() throws JsonProcessingException { + final JsonRpcRequest request = JsonRpcRequest.of(null, "echo", "hello"); + final AggregatedHttpResponse response = client().execute( + HttpRequest.builder() + .get("/json-rpc") + .content(MediaType.JSON_UTF_8, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(response.status()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED); + } + + @Test + void serve_whenUnsupportedMediaType_thenReturnsUnsupportedMediaType() throws JsonProcessingException { + final JsonRpcRequest request = JsonRpcRequest.of(null, "echo", "hello"); + final AggregatedHttpResponse response = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.PLAIN_TEXT, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(response.status()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + } + + @Test + void serve_whenRequestTriggersException_thenReturnsInternalServerError() throws JsonProcessingException { + final JsonRpcRequest request = JsonRpcRequest.of(1, "exception"); + final AggregatedHttpResponse response = client().execute( + HttpRequest.builder() + .post("/json-rpc") + .content(MediaType.JSON_UTF_8, objectMapper.writeValueAsString(request)) + .build()) + .aggregate().join(); + + assertThat(response.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.contentUtf8()).contains("error"); + } + + private static ServiceRequestContext newCtx() { + return ServiceRequestContext.builder(HttpRequest.of(HttpMethod.GET, "/")) + .build(); + } +}