diff --git a/docs/asciidoc/modules/json-rpc.adoc b/docs/asciidoc/modules/json-rpc.adoc index 0dcf695c8a..c1f3800b28 100644 --- a/docs/asciidoc/modules/json-rpc.adoc +++ b/docs/asciidoc/modules/json-rpc.adoc @@ -105,6 +105,90 @@ Supported engines include: No additional configuration is required. The generated dispatcher automatically hooks into the installed engine using the `JsonRpcParser` and `JsonRpcDecoder` interfaces, ensuring primitive types are strictly validated and parsed. +=== Middleware Pipeline + +Jooby provides a dedicated middleware architecture for JSON-RPC using the `JsonRpcInvoker` and `JsonRpcChain` APIs. This allows you to intercept RPC calls to apply cross-cutting concerns like logging, security, metrics, or tracing. + +To create an interceptor, implement the `JsonRpcInvoker` interface. + +.JSON-RPC +[source,java,role="primary"] +---- +import io.jooby.jsonrpc.*; +import java.util.Optional; + +public class LoggingInvoker implements JsonRpcInvoker { + + @Override + public Optional invoke(Context ctx, JsonRpcRequest request, JsonRpcChain next) { + long start = System.currentTimeMillis(); + + // Proceed down the chain + Optional response = next.proceed(ctx, request); + + long took = System.currentTimeMillis() - start; + + // Inspect the response + response.ifPresent(res -> { + if (res.getError() != null) { + ctx.getLog().warn("RPC {} failed in {}ms", request.getMethod(), took); + } else { + ctx.getLog().info("RPC {} succeeded in {}ms", request.getMethod(), took); + } + }); + + return response; + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.jsonrpc.* +import java.util.Optional + +class LoggingInvoker : JsonRpcInvoker { + + override fun invoke(ctx: Context, request: JsonRpcRequest, next: JsonRpcChain): Optional { + val start = System.currentTimeMillis() + + // Proceed down the chain + val response = next.proceed(ctx, request) + + val took = System.currentTimeMillis() - start + + // Inspect the response + response.ifPresent { res -> + if (res.error != null) { + ctx.log.warn("RPC {} failed in {}ms", request.method, took) + } else { + ctx.log.info("RPC {} succeeded in {}ms", request.method, took) + } + } + + return response + } +} +---- + +You register invokers fluently when installing the `JsonRpcModule`. You can chain multiple invokers together, and they will execute in the order they are added. + +[source,java] +---- +install(new JsonRpcModule(new MovieServiceRpc_()) + .invoker(new SecurityInvoker()) + .invoker(new LoggingInvoker())); +---- + +==== Safe Exception Handling +Notice that you **do not** need to wrap `next.proceed()` in a `try-catch` block. The final executor in the JSON-RPC pipeline acts as an ultimate safety net. It catches all unhandled exceptions, protocol failures (like Parse Errors), and routing failures, safely transforming them into a standard `JsonRpcResponse` containing an `ErrorDetail`. + +To react to failures in your middleware, simply inspect `response.get().getError() != null`. + +==== Notifications and Optional Responses +The invocation pipeline returns an `Optional`. This is because the JSON-RPC 2.0 specification explicitly dictates that **Notifications** (requests sent without an `id` member) must not receive a response. For these requests, the chain will safely execute the target method but return `Optional.empty()`. + === Error Mapping Jooby seamlessly bridges standard Java application exceptions and HTTP status codes into the JSON-RPC 2.0 format using the `JsonRpcErrorCode` mapping. You do not need to throw custom protocol exceptions for standard failures. diff --git a/docs/asciidoc/modules/opentelemetry.adoc b/docs/asciidoc/modules/opentelemetry.adoc index 52828f5def..8b8820be2d 100644 --- a/docs/asciidoc/modules/opentelemetry.adoc +++ b/docs/asciidoc/modules/opentelemetry.adoc @@ -284,6 +284,46 @@ import io.jooby.opentelemetry.instrumentation.OtelHikari } ---- +==== JSON-RPC + +Provides automatic tracing for your JSON-RPC 2.0 endpoints. By adding the `OtelJsonRcpTracing` middleware to your JSON-RPC pipeline, it generates a dedicated OpenTelemetry span for every RPC invocation. + +It automatically records standard semantic attributes (such as `rpc.system`, `rpc.method`, and `rpc.jsonrpc.request_id`). Furthermore, because it hooks directly into the `JsonRpcChain`, it accurately records protocol errors and application failures by inspecting the `JsonRpcResponse` envelope, without relying on thrown exceptions. + +.JSON-RPC Integration +[source, java, role = "primary"] +---- +import io.jooby.jsonrpc.JsonRpcModule; +import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing; +import io.opentelemetry.api.OpenTelemetry; + +{ + install(new OtelModule()); + + // Register the JSON-RPC module and attach the tracing middleware + install(new JsonRpcModule(new MovieServiceRpc_()) + .invoker(new OtelJsonRcpTracing(require(OpenTelemetry.class))) + ); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.jsonrpc.JsonRpcModule +import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing +import io.opentelemetry.api.OpenTelemetry + +{ + install(OtelModule()) + + // Register the JSON-RPC module and attach the tracing middleware + install(JsonRpcModule(MovieServiceRpc_()) + .invoker(OtelJsonRcpTracing(require(OpenTelemetry::class.java))) + ) +} +---- + ==== Log4j2 Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender. diff --git a/modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java b/modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java index e37d9f2ff8..7238db5734 100644 --- a/modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java +++ b/modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java @@ -34,6 +34,7 @@ public JsonRpcRequest fromJson(JsonReader reader) { JsonRpcRequest invalid = new JsonRpcRequest(); invalid.setMethod(null); invalid.setBatch(false); + invalid.setJsonrpc(null); return invalid; } @@ -67,10 +68,11 @@ private JsonRpcRequest parseSingle(Object node) { // 2. Validate JSON-RPC version Object versionVal = map.get("jsonrpc"); - if (!"2.0".equals(versionVal)) { + if (!JsonRpcRequest.JSONRPC.equals(versionVal)) { req.setMethod(null); return req; } + req.setJsonrpc(JsonRpcRequest.JSONRPC); // 3. Extract Method Object methodVal = map.get("method"); diff --git a/modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java b/modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java index 4d6e38282f..0459c70545 100644 --- a/modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java +++ b/modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java @@ -66,10 +66,13 @@ private JsonRpcRequest parseSingle(JsonNode node) { // 2. Validate JSON-RPC version JsonNode versionNode = node.get("jsonrpc"); - if (versionNode == null || !versionNode.isTextual() || !"2.0".equals(versionNode.asText())) { + if (versionNode == null + || !versionNode.isTextual() + || !JsonRpcRequest.JSONRPC.equals(versionNode.asText())) { req.setMethod(null); // Triggers -32600 Invalid Request return req; } + req.setJsonrpc(JsonRpcRequest.JSONRPC); // 3. Extract Method JsonNode methodNode = node.get("method"); diff --git a/modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java b/modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java index 8095c1307f..078870eaed 100644 --- a/modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java +++ b/modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java @@ -30,6 +30,7 @@ public JsonRpcRequest deserialize(JsonParser p, DeserializationContext ctxt) { JsonRpcRequest invalid = new JsonRpcRequest(); invalid.setMethod(null); // Acts as a flag for Invalid Request invalid.setBatch(false); // Force single return shape + invalid.setJsonrpc(null); return invalid; } @@ -63,10 +64,13 @@ private JsonRpcRequest parseSingle(JsonNode node) { // 2. Validate JSON-RPC version JsonNode versionNode = node.get("jsonrpc"); - if (versionNode == null || !versionNode.isString() || !"2.0".equals(versionNode.asString())) { + if (versionNode == null + || !versionNode.isString() + || !JsonRpcRequest.JSONRPC.equals(versionNode.asString())) { req.setMethod(null); // Triggers -32600 Invalid Request return req; } + req.setJsonrpc(JsonRpcRequest.JSONRPC); // 3. Extract Method JsonNode methodNode = node.get("method"); diff --git a/modules/jooby-jsonrpc/pom.xml b/modules/jooby-jsonrpc/pom.xml index 2447b6803c..9262825d9c 100644 --- a/modules/jooby-jsonrpc/pom.xml +++ b/modules/jooby-jsonrpc/pom.xml @@ -19,5 +19,11 @@ ${jooby.version} + + io.opentelemetry + opentelemetry-api + ${opentelemetry.version} + true + diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java new file mode 100644 index 0000000000..bc5d144fce --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java @@ -0,0 +1,183 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jsonrpc; + +import java.util.Map; +import java.util.Optional; + +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; + +import io.jooby.Context; +import io.jooby.Reified; +import io.jooby.SneakyThrows; +import io.jooby.jsonrpc.*; + +/** + * The internal execution engine and "final invoker" for JSON-RPC requests. + * + *

This class acts as the terminal end of the {@link JsonRpcChain}. It is responsible for the + * final stages of the JSON-RPC lifecycle: + * + *

    + *
  • Validating the parsed request envelope. + *
  • Routing the request to the appropriate {@link JsonRpcService}. + *
  • Executing the target method. + *
  • Acting as the ultimate safety net by catching all exceptions and translating them into + * compliant {@link JsonRpcResponse} objects. + *
+ */ +public class JsonRpcExecutor implements JsonRpcChain { + private final Map services; + private final Map, Logger> loggers; + private final Exception parseError; + + /** + * Constructs a new executor for a single JSON-RPC request. + * + * @param services A map of registered JSON-RPC services keyed by method name. + * @param loggers A map of service loggers keyed by service class. + * @param parseError Any exception that occurred during the initial JSON parsing phase. + */ + public JsonRpcExecutor( + Map services, Map, Logger> loggers, Exception parseError) { + this.services = services; + this.loggers = loggers; + this.parseError = parseError; + } + + /** + * Executes the JSON-RPC request and returns an optional response. + * + *

This method adheres strictly to the JSON-RPC 2.0 specification regarding error handling and + * response generation. It will return {@link Optional#empty()} for Notifications, unless a + * fundamental Parse Error or Invalid Request error occurs, which always require a response. + * + * @param ctx The current HTTP context passed down the chain. + * @param request The incoming JSON-RPC request passed down the chain. + * @return An Optional containing the JSON-RPC response, or empty if the request was a valid + * Notification. + */ + @Override + public @NonNull Optional proceed( + @NonNull Context ctx, @NonNull JsonRpcRequest request) { + var log = loggers.get(JsonRpcService.class); + try { + if (parseError != null) { + throw new JsonRpcException(JsonRpcErrorCode.PARSE_ERROR, parseError); + } + if (!request.isValid()) { + throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "Invalid JSON-RPC request"); + } + var fullMethod = request.getMethod(); + var targetService = services.get(fullMethod); + if (targetService != null) { + log = loggers.get(targetService.getClass()); + var result = targetService.execute(ctx, request); + return request.getId() != null + ? Optional.of(JsonRpcResponse.success(request.getId(), result)) + : Optional.empty(); + } + if (request.getId() == null) { + return Optional.empty(); + } + throw new JsonRpcException( + JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod); + } catch (Throwable cause) { + return toRpcResponse(ctx, log, request, cause); + } + } + + private Optional toRpcResponse( + Context ctx, Logger log, JsonRpcRequest request, Throwable ex) { + var code = toErrorCode(ctx, ex); + log(log, request, code, ex); + + if (SneakyThrows.isFatal(ex)) { + throw SneakyThrows.propagate(ex); + } else if (ex.getCause() != null && SneakyThrows.isFatal(ex.getCause())) { + throw SneakyThrows.propagate(ex.getCause()); + } + + if (request.getId() != null) { + return Optional.of(JsonRpcResponse.error(request.getId(), code, ex)); + } else if (code == JsonRpcErrorCode.PARSE_ERROR || code == JsonRpcErrorCode.INVALID_REQUEST) { + // must return a valid response even if the request is invalid + return Optional.of(JsonRpcResponse.error(null, code, ex)); + } + return Optional.empty(); + } + + /** + * Logs JSON-RPC errors adaptively based on the error code. + * + *

Internal server errors are logged as standard errors. Authorization and routing errors are + * logged at debug level to prevent log flooding. Other application errors are logged as warnings. + * + * @param log The logger instance to use. + * @param request The request that triggered the error. + * @param code The error code. + * @param cause The underlying exception. + */ + private void log(Logger log, JsonRpcRequest request, JsonRpcErrorCode code, Throwable cause) { + var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client"; + var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})"; + switch (code) { + case INTERNAL_ERROR -> + log.error( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR -> + log.debug( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + default -> { + if (cause instanceof JsonRpcException) { + log.warn( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId()); + } else { + log.warn( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + } + } + } + } + + public JsonRpcErrorCode toErrorCode(Context ctx, Throwable cause) { + if (cause instanceof JsonRpcException rpcException) { + return rpcException.getCode(); + } + // Attempt to look up any user-defined exception mappings from the registry + Map, JsonRpcErrorCode> customErrorMapping = + ctx.require(Reified.map(Class.class, JsonRpcErrorCode.class)); + return customErrorMapping.entrySet().stream() + .filter(entry -> entry.getKey().isInstance(cause)) + .findFirst() + .map(Map.Entry::getValue) + .orElseGet(() -> JsonRpcErrorCode.of(ctx.getRouter().errorCode(cause))); + } +} diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcHandler.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcHandler.java new file mode 100644 index 0000000000..6310fef1b4 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcHandler.java @@ -0,0 +1,89 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jsonrpc; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.Context; +import io.jooby.Route; +import io.jooby.StatusCode; +import io.jooby.annotation.Generated; +import io.jooby.jsonrpc.JsonRpcInvoker; +import io.jooby.jsonrpc.JsonRpcRequest; +import io.jooby.jsonrpc.JsonRpcResponse; +import io.jooby.jsonrpc.JsonRpcService; + +public class JsonRpcHandler implements Route.Handler { + private final Map services; + private final JsonRpcInvoker invoker; + private final Map, Logger> loggers = new HashMap<>(); + + public JsonRpcHandler(Map services, JsonRpcInvoker invoker) { + this.services = services; + loggers.put(JsonRpcService.class, LoggerFactory.getLogger(JsonRpcService.class)); + services + .values() + .forEach( + service -> { + var generated = service.getClass().getAnnotation(Generated.class); + loggers.put(service.getClass(), LoggerFactory.getLogger(generated.value())); + }); + this.invoker = invoker; + } + + /** + * Main handler for the JSON-RPC protocol. * + * + *

This method implements the flattened iteration logic. Because {@link JsonRpcRequest} + * implements {@code Iterable}, this handler treats single requests and batch requests identically + * during processing. + * + * @param ctx The current Jooby context. + * @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty + * string for notifications. + */ + @Override + public @NonNull Object apply(@NonNull Context ctx) throws Exception { + JsonRpcRequest input; + Exception parseError = null; + try { + input = ctx.body(JsonRpcRequest.class); + } catch (Exception cause) { + // still execute the handler/pipeline so we can log the error properly + input = JsonRpcRequest.BAD_REQUEST; + parseError = cause; + } + + var responses = new ArrayList(); + var executor = new JsonRpcExecutor(services, loggers, parseError); + + // Look up all generated *Rpc classes registered in the service registry + for (var request : input) { + var response = + invoker == null ? executor.proceed(ctx, request) : invoker.invoke(ctx, request, executor); + response.ifPresent(responses::add); + } + + // Handle the case where all requests in a batch were notifications + if (responses.isEmpty()) { + return ctx.send(StatusCode.NO_CONTENT); + } + + // Spec: Return an array only if the original request was a batch + return input.isBatch() ? responses : responses.getFirst(); + } + + @Override + public void setRoute(Route route) { + route.setAttribute("jsonrpc", true); + } +} diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcChain.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcChain.java new file mode 100644 index 0000000000..2dcfbdee8f --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcChain.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.util.Optional; + +import io.jooby.Context; + +/** + * Represents the remaining execution pipeline for a JSON-RPC request. + * + *

This interface is a core component of the JSON-RPC middleware architecture (Chain of + * Responsibility). When a {@link JsonRpcInvoker} executes, it is provided an instance of this + * chain, which represents all subsequent middleware components and the final execution target. + * + *

A typical middleware implementation will: + * + *

    + *
  1. Perform pre-processing (e.g., start a timer, evaluate security constraints). + *
  2. Call {@link #proceed(Context, JsonRpcRequest)} to delegate execution to the next link in + * the chain. + *
  3. Inspect or log the returned {@link JsonRpcResponse}. + *
  4. Return the response back up the chain. + *
+ * + *

The terminal implementation of this chain is the internal execution engine, which guarantees + * that exceptions are caught and safely translated into standard JSON-RPC error responses. + * Therefore, callers of {@code proceed} generally do not need to wrap the call in a try-catch block + * for application logic. + */ +public interface JsonRpcChain { + + /** + * Passes control to the next element in the pipeline, or executes the target JSON-RPC method if + * this is the end of the chain. + * + *

Because the request and context are passed explicitly, a middleware component is free to + * modify, wrap, or sanitize them before passing them down the line. + * + * @param ctx The current HTTP context. + * @param request The parsed JSON-RPC request envelope. + * @return An {@link Optional} containing the final JSON-RPC response. This will be {@link + * Optional#empty()} if the request was a valid Notification (which explicitly forbids a + * response). + */ + Optional proceed(Context ctx, JsonRpcRequest request); +} diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java index 31c8c7279b..06e3d56e1e 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java @@ -5,6 +5,10 @@ */ package io.jooby.jsonrpc; +import java.util.Optional; + +import org.jspecify.annotations.Nullable; + /** * Exception thrown when a JSON-RPC error occurs during routing, parsing, or execution. * @@ -14,7 +18,7 @@ public class JsonRpcException extends RuntimeException { private final JsonRpcErrorCode code; - private final Object data; + private final @Nullable Object data; /** * Constructs a new JSON-RPC exception. @@ -28,6 +32,18 @@ public JsonRpcException(JsonRpcErrorCode code, String message) { this.data = null; } + /** + * Constructs a new JSON-RPC exception. + * + * @param code The integer error code (preferably one of the standard constants). + * @param cause The underlying cause of the error. + */ + public JsonRpcException(JsonRpcErrorCode code, Throwable cause) { + super(code.getMessage(), cause); + this.code = code; + this.data = null; + } + /** * Constructs a new JSON-RPC exception. * @@ -68,7 +84,12 @@ public JsonRpcErrorCode getCode() { * * @return Additional data regarding the error, or null if none was provided. */ - public Object getData() { + public @Nullable Object getData() { return data; } + + public JsonRpcResponse.ErrorDetail toErrorDetail() { + return new JsonRpcResponse.ErrorDetail( + code, getMessage(), Optional.ofNullable(data).orElse(getCause())); + } } diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java new file mode 100644 index 0000000000..f67673bb61 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.util.Objects; +import java.util.Optional; + +import io.jooby.Context; + +/** + * Interceptor or middleware for processing JSON-RPC requests. + * + *

This interface allows you to wrap the execution of a JSON-RPC method call to apply + * cross-cutting concerns such as logging, security, validation, or metrics. + * + *

Exception Handling

+ * + * Implementations of this chain must never throw exceptions. According to the + * JSON-RPC 2.0 specification, application and transport errors should be handled gracefully and + * returned to the client as part of the response object within the {@code error} attribute. + * + *

Within the execution pipeline, the final invoker in the chain (the core + * framework executor) automatically handles this for you. It serves as the safety net, catching + * unhandled exceptions from the target method call, as well as protocol-level failures (such as + * Parse Error or Invalid Request), and safely transforms them into an {@link Optional} containing a + * {@link JsonRpcResponse} populated with the appropriate error details. + * + *

Response Type (Optional)

+ * + * The execution returns an {@code Optional} because the JSON-RPC 2.0 protocol + * defines two distinct types of client messages: + * + *
    + *
  • Method Calls: Requests that include an {@code id} member. The server MUST + * reply to these with a present {@link JsonRpcResponse} (containing either a {@code result} + * or an {@code error}). + *
  • Notifications: Requests that intentionally omit the {@code id} member. The + * client is not expecting and cannot map a response. The server MUST NOT reply to a + * notification, even if an error occurs. For these requests, the chain must return {@link + * Optional#empty()}. + *
+ */ +public interface JsonRpcInvoker { + + /** + * Invokes the JSON-RPC request, passing control to the next element in the chain or to the final + * target method. + * + *

Because the final invoker automatically catches exceptions and converts them into error + * responses, you do not need to wrap the call to {@code next.proceed()} in a try-catch block. + * Instead, if your middleware needs to react to a failure (e.g., to record an error metric), you + * can proceed with the chain and check for an error by evaluating {@code + * response.get().getError() != null}. + * + * @param ctx The current HTTP context. + * @param request The incoming JSON-RPC request. + * @param next The remaining execution pipeline, ending in the final method execution. + * @return An {@link Optional} containing the response for a standard method call, or {@link + * Optional#empty()} if the incoming request was a notification. + */ + Optional invoke(Context ctx, JsonRpcRequest request, JsonRpcChain next); + + /** + * Chains this invoker with another one to form a middleware pipeline. + * + * @param next The next invoker to execute in the chain. + * @return A composed {@link JsonRpcInvoker} that first executes this invoker, and delegates to + * the next. + * @throws NullPointerException if the next invoker is null. + */ + default JsonRpcInvoker then(JsonRpcInvoker next) { + Objects.requireNonNull(next, "next invoker is required"); + return (ctx, request, chain) -> + JsonRpcInvoker.this.invoke(ctx, request, (c, r) -> next.invoke(c, r, chain)); + } +} diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java index 6572d771ba..d0363ab137 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java @@ -7,60 +7,106 @@ import java.util.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.jspecify.annotations.Nullable; import io.jooby.*; import io.jooby.exception.MissingValueException; import io.jooby.exception.TypeMismatchException; +import io.jooby.internal.jsonrpc.JsonRpcHandler; +import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing; /** - * Global Tier 1 Dispatcher for JSON-RPC 2.0 requests. + * Jooby Extension module for integrating JSON-RPC 2.0 capabilities. * - *

This dispatcher acts as the central entry point for all JSON-RPC traffic. It manages the - * lifecycle of a request by: + *

This module acts as the central configuration point for setting up a JSON-RPC endpoint. It + * registers the target {@link JsonRpcService} instances, configures the route path, maps standard + * framework exceptions to JSON-RPC error codes, and installs the underlying request handler into + * the Jooby application. + * + *

Middleware Pipeline (Invoker / Chain API)

+ * + *

This module allows you to configure a pipeline of interceptors using the {@link + * JsonRpcInvoker} API. By adding invokers, you create a {@link JsonRpcChain} that wraps the final + * method execution. This is the standard way to apply cross-cutting concerns to your RPC endpoints, + * such as: * *

    - *
  • Parsing the incoming body into a {@link JsonRpcRequest} (supporting both single and batch - * shapes). - *
  • Iterating through registered {@link JsonRpcService} instances to find a matching namespace. - *
  • Handling Notifications (requests without an {@code id}) by suppressing - * responses. - *
  • Unifying batch results into a single JSON array or a single object response as per the - * spec. + *
  • Logging request payloads and execution times. + *
  • Enforcing security and authorization rules. + *
  • Gathering metrics and OpenTelemetry tracing. *
* - *

* - * - *

Usage: + *

Usage:

* *
{@code
+ * {
  * install(new Jackson3Module());
- *
  * install(new JsonRpcJackson3Module());
- *
- * install(new JsonRpcModule(new MyServiceRpc_()));
- *
+ * * install(new JsonRpcModule(new MyServiceRpc_())
+ * .invoker(new MyJsonRpcMiddleware()));
+ * }
  * }
* * @author Edgar Espina * @since 4.0.17 */ public class JsonRpcModule implements Extension { - private final Logger log = LoggerFactory.getLogger(JsonRpcService.class); private final Map services = new HashMap<>(); private final String path; + private @Nullable JsonRpcInvoker invoker; + private @Nullable OtelJsonRcpTracing head; + /** + * Creates a new JSON-RPC module at a custom HTTP path. + * + * @param path The HTTP path where the JSON-RPC endpoint will be mounted (e.g., {@code + * "/api/rpc"}). + * @param service The primary {@link JsonRpcService} containing the RPC methods to expose. + * @param services Additional {@link JsonRpcService} instances to expose on the same endpoint. + */ public JsonRpcModule(String path, JsonRpcService service, JsonRpcService... services) { this.path = path; registry(service); Arrays.stream(services).forEach(this::registry); } + /** + * Creates a new JSON-RPC module mounted at the default {@code "/rpc"} HTTP path. + * + * @param service The primary {@link JsonRpcService} containing the RPC methods to expose. + * @param services Additional {@link JsonRpcService} instances to expose on the same endpoint. + */ public JsonRpcModule(JsonRpcService service, JsonRpcService... services) { this("/rpc", service, services); } + /** + * Adds a {@link JsonRpcInvoker} middleware to the execution pipeline. + * + *

Middlewares are composed together to form a {@link JsonRpcChain}. When multiple invokers are + * registered, they wrap around each other, meaning the first added invoker will execute first. + * + *

Tracing Priority: If the provided invoker is an instance of {@link + * OtelJsonRcpTracing}, it is automatically promoted to the absolute head of the pipeline. This + * guarantees that OpenTelemetry spans encompass all other middlewares and the final execution. + * + * @param invoker The middleware interceptor to add to the pipeline. + * @return This module instance for fluent configuration chaining. + */ + public JsonRpcModule invoker(JsonRpcInvoker invoker) { + if (invoker instanceof OtelJsonRcpTracing otel) { + // otel goes first: + this.head = otel; + } else { + if (this.invoker != null) { + this.invoker = invoker.then(this.invoker); + } else { + this.invoker = invoker; + } + } + return this; + } + private void registry(JsonRpcService service) { for (var method : service.getMethods()) { this.services.put(method, service); @@ -68,14 +114,22 @@ private void registry(JsonRpcService service) { } /** - * Installs the JSON-RPC handler at the default {@code /rpc} endpoint. + * Installs the JSON-RPC handler into the Jooby application. + * + *

This method is invoked automatically by Jooby during application startup. It resolves the + * final middleware chain, registers the HTTP POST route at the configured path, and sets up + * default exception mappings for standard Jooby routing errors (like missing or mismatched + * parameters). * * @param app The Jooby application instance. - * @throws Exception If registration fails. + * @throws Exception If route registration or configuration fails. */ @Override public void install(Jooby app) throws Exception { - app.post(path, this::handle); + if (head != null) { + invoker = invoker == null ? head : head.then(invoker); + } + app.post(path, new JsonRpcHandler(services, invoker)); // Initialize the custom exception mapping registry app.getServices() @@ -83,164 +137,4 @@ public void install(Jooby app) throws Exception { .put(MissingValueException.class, JsonRpcErrorCode.INVALID_PARAMS) .put(TypeMismatchException.class, JsonRpcErrorCode.INVALID_PARAMS); } - - /** - * Main handler for the JSON-RPC protocol. * - * - *

This method implements the flattened iteration logic. Because {@link JsonRpcRequest} - * implements {@code Iterable}, this handler treats single requests and batch requests identically - * during processing. - * - * @param ctx The current Jooby context. - * @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty - * string for notifications. - */ - private Object handle(Context ctx) { - JsonRpcRequest input; - try { - input = ctx.body(JsonRpcRequest.class); - } catch (Exception e) { - // Spec: -32700 Parse error if the JSON is physically malformed. - return JsonRpcResponse.error(null, JsonRpcErrorCode.PARSE_ERROR, e); - } - - List responses = new ArrayList<>(); - - // Look up all generated *Rpc classes registered in the service registry - - for (var request : input) { - var fullMethod = request.getMethod(); - - // Spec: -32600 Invalid Request if the method member is missing or null - if (fullMethod == null) { - responses.add( - JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null)); - continue; - } - - try { - var targetService = services.get(fullMethod); - if (targetService != null) { - var result = targetService.execute(ctx, request); - // Spec: If the "id" is missing, it is a notification and no response is returned. - if (request.getId() != null) { - responses.add(JsonRpcResponse.success(request.getId(), result)); - } - } else { - // Spec: -32601 Method not found - if (request.getId() != null) { - responses.add( - JsonRpcResponse.error( - request.getId(), - JsonRpcErrorCode.METHOD_NOT_FOUND, - "Method not found: " + fullMethod)); - } - } - } catch (JsonRpcException cause) { - log(ctx, request, cause); - // Domain-specific or protocol-level exceptions (e.g., -32602 Invalid Params) - if (request.getId() != null) { - responses.add(JsonRpcResponse.error(request.getId(), cause.getCode(), cause.getCause())); - } - } catch (Exception cause) { - log(ctx, request, cause); - // Spec: -32603 Internal error for unhandled application exceptions - if (request.getId() != null) { - responses.add( - JsonRpcResponse.error(request.getId(), computeErrorCode(ctx, cause), cause)); - } - } - } - - // Handle the case where all requests in a batch were notifications - if (responses.isEmpty()) { - ctx.setResponseCode(StatusCode.NO_CONTENT); - return ""; - } - - // Spec: Return an array only if the original request was a batch - return input.isBatch() ? responses : responses.getFirst(); - } - - private void log(Context ctx, JsonRpcRequest request, Throwable cause) { - JsonRpcErrorCode code; - boolean hasCause = true; - if (cause instanceof JsonRpcException rpcException) { - code = rpcException.getCode(); - hasCause = false; - } else { - code = computeErrorCode(ctx, cause); - } - var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client"; - var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})"; - switch (code) { - case INTERNAL_ERROR -> - log.error( - message, - type, - code.getCode(), - code.getMessage(), - request.getMethod(), - request.getId(), - cause); - case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR -> - log.debug( - message, - type, - code.getCode(), - code.getMessage(), - request.getMethod(), - request.getId(), - cause); - default -> { - if (hasCause) { - log.warn( - message, - type, - code.getCode(), - code.getMessage(), - request.getMethod(), - request.getId(), - cause); - } else { - log.debug( - message, - type, - code.getCode(), - code.getMessage(), - request.getMethod(), - request.getId()); - } - } - } - } - - private JsonRpcErrorCode computeErrorCode(Context ctx, Throwable cause) { - JsonRpcErrorCode code; - // Attempt to look up any user-defined exception mappings from the registry - Map, JsonRpcErrorCode> customMapping = - ctx.require(Reified.map(Class.class, JsonRpcErrorCode.class)); - code = - errorCode(customMapping, cause) - .orElseGet(() -> JsonRpcErrorCode.of(ctx.getRouter().errorCode(cause))); - return code; - } - - /** - * Evaluates the given exception against the registered custom exception mappings. - * - * @param mappings A map of Exception classes to specific tRPC error codes. - * @param x The exception to evaluate. - * @return An {@code Optional} containing the matched {@code TrpcErrorCode}, or empty if no match - * is found. - */ - private Optional errorCode( - Map, JsonRpcErrorCode> mappings, Throwable x) { - for (var mapping : mappings.entrySet()) { - if (mapping.getKey().isInstance(x)) { - return Optional.of(mapping.getValue()); - } - } - return Optional.empty(); - } } diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java index 9560a3eebb..9906ca6e34 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java @@ -10,6 +10,8 @@ import java.util.Iterator; import java.util.List; +import org.jspecify.annotations.Nullable; + /** * Represents a JSON-RPC 2.0 Request object, and simultaneously acts as an iterable container for * batch requests. @@ -31,12 +33,14 @@ * generic structure (e.g., a List or a Map) and populating the batch state. */ public class JsonRpcRequest implements Iterable { + public static final JsonRpcRequest BAD_REQUEST = new JsonRpcRequest(); + public static final String JSONRPC = "2.0"; /** A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". */ - private String jsonrpc = "2.0"; + private @Nullable String jsonrpc; /** A String containing the name of the method to be invoked. */ - private String method; + private @Nullable String method; /** * A Structured value that holds the parameter values to be used during the invocation of the @@ -48,27 +52,35 @@ public class JsonRpcRequest implements Iterable { * An identifier established by the Client that MUST contain a String, Number, or NULL value if * included. If it is not included it is assumed to be a notification. */ - private Object id; + private @Nullable Object id; // --- Batch State --- private boolean batch; - private List requests; + private @Nullable List requests; public JsonRpcRequest() {} - public String getJsonrpc() { + public boolean isValid() { + return JSONRPC.equals(jsonrpc) && !isNullOrEmpty(method); + } + + private boolean isNullOrEmpty(@Nullable String value) { + return value == null || value.trim().isEmpty(); + } + + public @Nullable String getJsonrpc() { return jsonrpc; } - public void setJsonrpc(String jsonrpc) { + public void setJsonrpc(@Nullable String jsonrpc) { this.jsonrpc = jsonrpc; } - public String getMethod() { + public @Nullable String getMethod() { return method; } - public void setMethod(String method) { + public void setMethod(@Nullable String method) { this.method = method; } @@ -80,11 +92,11 @@ public void setParams(Object params) { this.params = params; } - public Object getId() { + public @Nullable Object getId() { return id; } - public void setId(Object id) { + public void setId(@Nullable Object id) { this.id = id; } diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java index c71a20e959..3a260ef77e 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java @@ -5,6 +5,8 @@ */ package io.jooby.jsonrpc; +import java.util.Optional; + import org.jspecify.annotations.Nullable; /** @@ -12,17 +14,29 @@ * *

When an RPC call is made, the Server MUST reply with a Response, except in the case of * Notifications. The Response is expressed as a single JSON Object. + * + *

Exception and Error Handling

+ * + *

When an error or exception occurs during the processing of a JSON-RPC request, it is captured + * and wrapped within the {@link ErrorDetail} of this response object. + * + *

Important: Not all exceptions are converted into an errored response sent to + * the client. Specifically, if an exception occurs while processing a Notification + * (a request intentionally omitting the {@code id} member), the server MUST NOT reply. + * + *

In all cases, whether the request is a standard Method Call or a Notification, the exception + * will always be logged by the server infrastructure. However, it is only + * serialized and transmitted back to the client if the original request required a response. */ public class JsonRpcResponse { private String jsonrpc = "2.0"; - private Object result; - private ErrorDetail error; - private Object id; - - public JsonRpcResponse() {} + private @Nullable Object result; + private @Nullable ErrorDetail error; + private @Nullable Object id; - private JsonRpcResponse(Object id, Object result, ErrorDetail error) { + private JsonRpcResponse( + @Nullable Object id, @Nullable Object result, @Nullable ErrorDetail error) { this.id = id; this.result = result; this.error = error; @@ -33,30 +47,39 @@ private JsonRpcResponse(Object id, Object result, ErrorDetail error) { * * @param id The id from the corresponding request. * @param result The result of the invoked method. - * @return A populated JsonRpcResponse. + * @return A populated JsonRpcResponse containing the result. */ public static JsonRpcResponse success(Object id, Object result) { return new JsonRpcResponse(id, result, null); } /** - * Creates an error JSON-RPC response. + * Creates an error JSON-RPC response holding generic data or a Throwable. * - * @param id The id from the corresponding request. - * @param code The error code. - * @param data Additional data about the error. - * @return A populated JsonRpcResponse. + * @param id The id from the corresponding request (or null if a Parse Error / Invalid Request). + * @param code The JSON-RPC error code. + * @param data Additional data about the error. If this is a Throwable, it delegates to the + * Throwable handler. + * @return A populated JsonRpcResponse containing the error details. */ - public static JsonRpcResponse error(Object id, JsonRpcErrorCode code, Object data) { - return new JsonRpcResponse( - id, null, new ErrorDetail(code.getCode(), code.getMessage(), data(data))); + public static JsonRpcResponse error(@Nullable Object id, JsonRpcErrorCode code, Object data) { + if (data instanceof Throwable) { + return error(id, code, (Throwable) data); + } + return new JsonRpcResponse(id, null, new ErrorDetail(code, data)); } - private static Object data(Object data) { - if (data instanceof Throwable cause) { - return cause.getMessage(); - } - return data; + /** + * Creates an error JSON-RPC response originating from a Throwable. + * + * @param id The id from the corresponding request (or null if a Parse Error / Invalid Request). + * @param code The JSON-RPC error code. + * @param cause The underlying exception that caused the error. + * @return A populated JsonRpcResponse containing the error details. + */ + public static JsonRpcResponse error( + @Nullable Object id, JsonRpcErrorCode code, @Nullable Throwable cause) { + return new JsonRpcResponse(id, null, new ErrorDetail(code, cause)); } public String getJsonrpc() { @@ -67,11 +90,11 @@ public void setJsonrpc(String jsonrpc) { this.jsonrpc = jsonrpc; } - public Object getResult() { + public @Nullable Object getResult() { return result; } - public void setResult(Object result) { + public void setResult(@Nullable Object result) { this.result = result; } @@ -79,7 +102,7 @@ public void setResult(Object result) { return error; } - public void setError(ErrorDetail error) { + public void setError(@Nullable ErrorDetail error) { this.error = error; } @@ -87,46 +110,67 @@ public void setError(ErrorDetail error) { return id; } - public void setId(Object id) { + public void setId(@Nullable Object id) { this.id = id; } - /** Represents the error object inside a JSON-RPC response. */ + /** + * Represents the error object inside a JSON-RPC response. + * + *

If constructed with a {@link Throwable}, the throwable is retained for internal server + * logging via {@link #exception()}, but only its message is exposed to the client payload via + * {@link #getData()} to prevent leaking sensitive stack traces. + */ public static class ErrorDetail { - private int code; - private String message; - private Object data; - - public ErrorDetail() {} + private final int code; + private final String message; + private final @Nullable Object data; - public ErrorDetail(int code, String message, Object data) { - this.code = code; - this.message = message; + public ErrorDetail(JsonRpcErrorCode code, @Nullable String message, @Nullable Object data) { + this.code = code.getCode(); + this.message = Optional.ofNullable(message).orElse(code.getMessage()); this.data = data; } - public int getCode() { - return code; + public ErrorDetail(JsonRpcErrorCode code, @Nullable Object data) { + this(code, null, data); } - public void setCode(int code) { - this.code = code; + public ErrorDetail(JsonRpcErrorCode code) { + this(code, null, null); } - public String getMessage() { - return message; + public int getCode() { + return code; } - public void setMessage(String message) { - this.message = message; + public String getMessage() { + return message; } - public Object getData() { + /** + * Gets the additional error data. If the underlying data is an Exception, this safely returns + * only the exception message rather than the full stack trace object. + * + * @return The error data, or the exception message. + */ + public @Nullable Object getData() { + if (data instanceof Throwable cause) { + return cause.getMessage(); + } return data; } - public void setData(Object data) { - this.data = data; + /** + * Retrieves the raw exception for internal server logging, if one exists. + * + * @return The underlying Throwable, or null if the error wasn't caused by an exception. + */ + public @Nullable Throwable exception() { + if (data instanceof Throwable cause) { + return cause; + } + return null; } } } diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java new file mode 100644 index 0000000000..5805d664d6 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java @@ -0,0 +1,143 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc.instrumentation; + +import java.util.Objects; +import java.util.Optional; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import io.jooby.Context; +import io.jooby.SneakyThrows; +import io.jooby.jsonrpc.*; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +/** + * OpenTelemetry tracing middleware for JSON-RPC invocations. See rpc-spans. + * + *

This invoker wraps JSON-RPC requests to automatically generate OpenTelemetry spans following + * standard RPC semantic conventions. It records the RPC system ({@code jsonrpc}), the invoked + * method, and the request ID. + * + *

Error Tracking

+ * + *

Because the Jooby JSON-RPC pipeline catches application exceptions and transforms them into + * {@link JsonRpcResponse} objects, this tracing middleware does not rely on try-catch blocks to + * detect business logic failures. Instead, after the action executes, it inspects the resulting + * response for an {@link JsonRpcResponse.ErrorDetail}. + * + *

    + *
  • If no error is present, the span is marked with {@link StatusCode#OK}. + *
  • If an error is found, the span is marked with {@link StatusCode#ERROR}, the error code is + * recorded, and the underlying exception (if available) is attached to the span. + *
+ * + * @author edgar + * @since 4.5.0 + */ +public class OtelJsonRcpTracing implements JsonRpcInvoker { + + private final Tracer tracer; + + private SneakyThrows.@Nullable Consumer3 onStart; + + private SneakyThrows.@Nullable Consumer3 onEnd; + + /** + * Creates a new OpenTelemetry JSON-RPC tracing middleware. + * + * @param otel The OpenTelemetry instance used to obtain the tracer. + */ + public OtelJsonRcpTracing(OpenTelemetry otel) { + tracer = otel.getTracer("io.jooby.jsonrpc"); + } + + /** + * Registers a custom callback to be executed immediately after the span is started, but before + * the JSON-RPC action is invoked. This allows you to add custom attributes to the span based on + * the HTTP context. + * + * @param onStart The callback accepting the HTTP Context and the active Span. + * @return This invoker instance for chaining. + */ + public OtelJsonRcpTracing onStart(SneakyThrows.Consumer3 onStart) { + this.onStart = onStart; + return this; + } + + /** + * Registers a custom callback to be executed immediately before the span is ended, after the + * JSON-RPC action has completed. + * + * @param onEnd The callback accepting the HTTP Context and the active Span. + * @return This invoker instance for chaining. + */ + public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3 onEnd) { + this.onEnd = onEnd; + return this; + } + + /** + * Wraps the JSON-RPC execution in an OpenTelemetry span. + * + *

This method starts a span, executes the downstream action, and evaluates the resulting + * {@link JsonRpcResponse}. It handles span status updates by checking {@code rsp.getError() != + * null}, ensuring that gracefully handled JSON-RPC errors are properly recorded as span failures. + * + * @param ctx The current HTTP context. + * @param request The incoming JSON-RPC request. + * @param chain The next step in the invocation chain. + * @return An Optional containing the response (or empty for a Notification). + */ + @Override + public @NonNull Optional invoke( + @NonNull Context ctx, @NonNull JsonRpcRequest request, JsonRpcChain chain) { + var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method"); + var span = + tracer + .spanBuilder(method) + .setAttribute("rpc.system", "jsonrpc") + .setAttribute("rpc.method", method) + .setAttribute( + "rpc.jsonrpc.request_id", + Optional.ofNullable(request.getId()).map(Objects::toString).orElse(null)) + .startSpan(); + try (var scope = span.makeCurrent()) { + if (onStart != null) { + onStart.accept(ctx, request, span); + } + var result = chain.proceed(ctx, request); + if (result.isPresent()) { + var rsp = result.get(); + // we need to check for errored response, jsonrpc pipeline won't fire exception unless they + // are fatal where can only be propagated + var error = rsp.getError(); + if (error == null) { + span.setStatus(StatusCode.OK); + } else { + span.setStatus(StatusCode.ERROR, error.getMessage()); + span.setAttribute("rpc.response.status_code", error.getCode()); + var cause = error.exception(); + if (cause != null) { + span.setAttribute("error.type", cause.getClass().getName()); + span.recordException(cause); + } + } + } + return result; + } finally { + if (onEnd != null) { + onEnd.accept(ctx, request, span); + } + span.end(); + } + } +} diff --git a/modules/jooby-jsonrpc/src/main/java/module-info.java b/modules/jooby-jsonrpc/src/main/java/module-info.java index 615165bc7b..1c6d3ddf2e 100644 --- a/modules/jooby-jsonrpc/src/main/java/module-info.java +++ b/modules/jooby-jsonrpc/src/main/java/module-info.java @@ -38,4 +38,6 @@ requires static org.jspecify; requires typesafe.config; requires org.slf4j; + requires static io.opentelemetry.api; + requires static io.opentelemetry.context; } diff --git a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java index 8bc8bbb70a..608924522d 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/DefaultMcpInvoker.java @@ -5,6 +5,7 @@ */ package io.jooby.internal.mcp; +import org.jspecify.annotations.NonNull; import org.slf4j.LoggerFactory; import io.jooby.Jooby; @@ -24,7 +25,7 @@ public DefaultMcpInvoker(Jooby application) { @SuppressWarnings("unchecked") @Override - public R invoke(McpOperation operation, SneakyThrows.Supplier action) { + public R invoke(@NonNull McpOperation operation, SneakyThrows.@NonNull Supplier action) { try { return action.get(); } catch (McpError mcpError) { diff --git a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java index 9b8c56a61e..52fba64617 100644 --- a/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java +++ b/modules/jooby-mcp/src/main/java/io/jooby/mcp/McpInvoker.java @@ -5,6 +5,8 @@ */ package io.jooby.mcp; +import java.util.Objects; + import io.jooby.SneakyThrows; /** @@ -69,9 +71,7 @@ public interface McpInvoker { * @return A composed invoker. */ default McpInvoker then(McpInvoker next) { - if (next == null) { - return this; - } + Objects.requireNonNull(next, "next invoker is required"); return new McpInvoker() { @Override public R invoke(McpOperation operation, SneakyThrows.Supplier action) { diff --git a/modules/jooby-opentelemetry/pom.xml b/modules/jooby-opentelemetry/pom.xml index 277c8fc265..4f838da1b0 100644 --- a/modules/jooby-opentelemetry/pom.xml +++ b/modules/jooby-opentelemetry/pom.xml @@ -160,7 +160,7 @@ io.opentelemetry opentelemetry-bom - 1.60.1 + ${opentelemetry.version} pom import diff --git a/pom.xml b/pom.xml index 6b4a941dec..b18d1dbd7f 100644 --- a/pom.xml +++ b/pom.xml @@ -141,6 +141,7 @@ 1.12.797 4.18.1 1.9.3 + 1.60.1 2.21.0 diff --git a/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java b/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java index 941e07b078..bc87cb5676 100644 --- a/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java @@ -291,6 +291,21 @@ void shouldHandleInvalidParams(ServerTestRunner runner) { assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); }); + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.getByIdString", "params": {}, "id": 14} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + // 5. Omitted Params Object entirely http.postJson( "/rpc", diff --git a/tests/src/test/java/io/jooby/i3868/MovieServiceRpc.java b/tests/src/test/java/io/jooby/i3868/MovieServiceRpc.java index c6f4f5e786..065d6129c1 100644 --- a/tests/src/test/java/io/jooby/i3868/MovieServiceRpc.java +++ b/tests/src/test/java/io/jooby/i3868/MovieServiceRpc.java @@ -7,6 +7,8 @@ import java.util.List; +import org.jspecify.annotations.NonNull; + import io.jooby.annotation.*; import io.jooby.annotation.jsonrpc.JsonRpc; import io.jooby.exception.NotFoundException; @@ -33,6 +35,10 @@ public Movie getById(int id) { .orElseThrow(() -> new NotFoundException("Movie not found: " + id)); } + public Movie getByIdString(@NonNull String id) { + return getById(Integer.parseInt(id)); + } + public List search(String title, int year) { return database.stream().filter(m -> m.title().contains(title) && (m.year() == year)).toList(); }