From dee2e39aeb7402cd9957e3df631c76cdb4d2d06a Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 18 Apr 2026 20:10:16 -0300 Subject: [PATCH 1/7] build:ci: remove external test report --- .github/workflows/full-build.yml | 69 +++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/.github/workflows/full-build.yml b/.github/workflows/full-build.yml index 0e8ad03e07..8963368226 100644 --- a/.github/workflows/full-build.yml +++ b/.github/workflows/full-build.yml @@ -33,9 +33,68 @@ jobs: run: mvn -B install -P gradlePlugin --no-transfer-progress env: BUILD_LOG_LEVEL: 'ERROR' - - name: Tests - uses: mikepenz/action-junit-report@v5 + - name: ๐Ÿ“Š Test Report if: always() - with: - check_name: Test ${{ matrix.os }} ${{ matrix.java-version }} - report_paths: '*/target/*/TEST-*.xml' + run: | + echo "## ๐Ÿงช Test Summary (${{ matrix.os }} - Java ${{ matrix.java-version }})" >> $GITHUB_STEP_SUMMARY + + # Use Python to safely parse the JUnit XML files and write to the summary + python3 -c ' + import xml.etree.ElementTree as ET + import glob, os + + # Find all JUnit XML reports + files = glob.glob("**/target/**/TEST-*.xml", recursive=True) + + tests, failures, errors, skipped = 0, 0, 0, 0 + failed_tests = set() + + for f in files: + try: + tree = ET.parse(f) + root = tree.getroot() + + # Handle both and root elements + suites = [root] if root.tag == "testsuite" else root.findall(".//testsuite") + + for suite in suites: + tests += int(suite.attrib.get("tests", 0)) + failures += int(suite.attrib.get("failures", 0)) + errors += int(suite.attrib.get("errors", 0)) + skipped += int(suite.attrib.get("skipped", 0)) + + # Collect names of failing tests + for case in suite.findall("testcase"): + if case.find("failure") is not None or case.find("error") is not None: + # Strip the package path from the classname for a cleaner display + cls = case.attrib.get("classname", "UnknownClass").split(".")[-1] + name = case.attrib.get("name", "UnknownMethod") + failed_tests.add(f"- `{cls}.{name}`") + except Exception as e: + print(f"Error parsing {f}: {e}") + + passed = tests - failures - errors - skipped + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + + with open(summary_file, "a") as f: + if not files: + f.write("โš ๏ธ **Could not find any `TEST-*.xml` files.**\n") + else: + # Draw the Markdown Table + f.write("| Result | Count |\n") + f.write("|--------|-------|\n") + f.write(f"| โœ… **Passed** | **{passed}** |\n") + f.write(f"| โŒ **Failed/Errors** | **{failures + errors}** |\n") + f.write(f"| โš ๏ธ **Skipped** | **{skipped}** |\n") + f.write(f"| ๐Ÿ“Š **Total Tests** | **{tests}** |\n\n") + + # Provide specific feedback for failures + if failures > 0 or errors > 0: + f.write("### ๐Ÿšจ Test Failures Detected!\n") + f.write("The following tests did not pass:\n") + for test in sorted(failed_tests): + f.write(f"{test}\n") + else: + f.write("### ๐ŸŽ‰ 100% Pass Rate!\n") + f.write(f"The build is green across all {tests} tests.\n") + ' From d133758d23802856267078fc2e390d3d1bf4fa6c Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 18 Apr 2026 21:19:15 -0300 Subject: [PATCH 2/7] WIP --- .../jsonrpc/DefaultJsonRpcInvoker.java | 27 +++++++++++++++ .../java/io/jooby/jsonrpc/JsonRpcInvoker.java | 29 ++++++++++++++++ .../java/io/jooby/jsonrpc/JsonRpcModule.java | 34 ++++++++++++++----- .../java/io/jooby/jsonrpc/JsonRpcRequest.java | 8 +++-- .../io/jooby/jsonrpc/JsonRpcResponse.java | 19 ++++++----- .../jooby/internal/mcp/DefaultMcpInvoker.java | 3 +- .../main/java/io/jooby/mcp/McpInvoker.java | 6 ++-- 7 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java new file mode 100644 index 0000000000..056ee95e89 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java @@ -0,0 +1,27 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jsonrpc; + +import org.jspecify.annotations.NonNull; + +import io.jooby.Context; +import io.jooby.SneakyThrows; +import io.jooby.jsonrpc.JsonRpcInvoker; +import io.jooby.jsonrpc.JsonRpcRequest; + +public class DefaultJsonRpcInvoker implements JsonRpcInvoker { + @Override + public R invoke( + @NonNull Context ctx, + @NonNull JsonRpcRequest request, + SneakyThrows.@NonNull Supplier action) { + try { + return action.get(); + } catch (Throwable cause) { + throw SneakyThrows.propagate(cause); + } + } +} 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..614cdec844 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java @@ -0,0 +1,29 @@ +/* + * 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 io.jooby.Context; +import io.jooby.SneakyThrows; + +public interface JsonRpcInvoker { + + Object invoke(Context ctx, JsonRpcRequest request, SneakyThrows.Supplier action) + throws Exception; + + default JsonRpcInvoker then(JsonRpcInvoker next) { + Objects.requireNonNull(next, "next invoker is required"); + return new JsonRpcInvoker() { + @Override + public Object invoke( + Context ctx, JsonRpcRequest request, SneakyThrows.Supplier action) + throws Exception { + return JsonRpcInvoker.this.invoke(ctx, request, () -> next.invoke(ctx, request, action)); + } + }; + } +} 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..57d7e8a3c6 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,6 +7,7 @@ import java.util.*; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,6 +51,7 @@ 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; public JsonRpcModule(String path, JsonRpcService service, JsonRpcService... services) { this.path = path; @@ -61,6 +63,15 @@ public JsonRpcModule(JsonRpcService service, JsonRpcService... services) { this("/rpc", service, services); } + public JsonRpcModule invoker(JsonRpcInvoker invoker) { + 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); @@ -121,20 +132,25 @@ private Object handle(Context ctx) { try { var targetService = services.get(fullMethod); if (targetService != null) { - var result = targetService.execute(ctx, request); + var result = + invoker == null + ? targetService.execute(ctx, request) + : invoker.invoke(ctx, request, () -> 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)); + if (result instanceof JsonRpcResponse jsonRpcResponse) { + responses.add(jsonRpcResponse); + } else { + 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)); - } + responses.add( + JsonRpcResponse.error( + request.getId(), + JsonRpcErrorCode.METHOD_NOT_FOUND, + "Method not found: " + fullMethod)); } } catch (JsonRpcException cause) { log(ctx, request, cause); 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..9dce19eb70 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. @@ -48,7 +50,7 @@ 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; @@ -80,11 +82,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..57dd387018 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 @@ -16,13 +16,14 @@ public class JsonRpcResponse { private String jsonrpc = "2.0"; - private Object result; - private ErrorDetail error; - private Object id; + private @Nullable Object result; + private @Nullable ErrorDetail error; + private @Nullable Object id; public JsonRpcResponse() {} - 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; @@ -47,7 +48,7 @@ public static JsonRpcResponse success(Object id, Object result) { * @param data Additional data about the error. * @return A populated JsonRpcResponse. */ - public static JsonRpcResponse error(Object id, JsonRpcErrorCode code, Object data) { + public static JsonRpcResponse error(@Nullable Object id, JsonRpcErrorCode code, Object data) { return new JsonRpcResponse( id, null, new ErrorDetail(code.getCode(), code.getMessage(), data(data))); } @@ -67,11 +68,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 +80,7 @@ public void setResult(Object result) { return error; } - public void setError(ErrorDetail error) { + public void setError(@Nullable ErrorDetail error) { this.error = error; } @@ -87,7 +88,7 @@ public void setError(ErrorDetail error) { return id; } - public void setId(Object id) { + public void setId(@Nullable Object id) { this.id = id; } 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) { From 143894d5aa289c08e0484518333769eb13d61b42 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Tue, 21 Apr 2026 16:07:52 -0300 Subject: [PATCH 3/7] otel: jsonrpc: implement instrumentation - fix lifecycle of request - add tracing --- modules/jooby-jsonrpc/pom.xml | 6 + .../jsonrpc/DefaultJsonRpcInvoker.java | 27 ----- .../jsonrpc/JsonRpcExceptionTranslator.java | 52 ++++++++ .../internal/jsonrpc/JsonRpcExecutor.java | 48 ++++++++ .../io/jooby/jsonrpc/JsonRpcException.java | 13 +- .../java/io/jooby/jsonrpc/JsonRpcInvoker.java | 14 +-- .../java/io/jooby/jsonrpc/JsonRpcModule.java | 111 ++++++------------ .../java/io/jooby/jsonrpc/JsonRpcRequest.java | 1 + .../io/jooby/jsonrpc/JsonRpcResponse.java | 68 ++++++----- .../instrumentation/OtelJsonRcpTracing.java | 76 ++++++++++++ .../src/main/java/module-info.java | 2 + modules/jooby-opentelemetry/pom.xml | 2 +- pom.xml | 1 + 13 files changed, 281 insertions(+), 140 deletions(-) delete mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java 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/DefaultJsonRpcInvoker.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java deleted file mode 100644 index 056ee95e89..0000000000 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/DefaultJsonRpcInvoker.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.internal.jsonrpc; - -import org.jspecify.annotations.NonNull; - -import io.jooby.Context; -import io.jooby.SneakyThrows; -import io.jooby.jsonrpc.JsonRpcInvoker; -import io.jooby.jsonrpc.JsonRpcRequest; - -public class DefaultJsonRpcInvoker implements JsonRpcInvoker { - @Override - public R invoke( - @NonNull Context ctx, - @NonNull JsonRpcRequest request, - SneakyThrows.@NonNull Supplier action) { - try { - return action.get(); - } catch (Throwable cause) { - throw SneakyThrows.propagate(cause); - } - } -} diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java new file mode 100644 index 0000000000..2a3cdfe139 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java @@ -0,0 +1,52 @@ +/* + * 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 io.jooby.Reified; +import io.jooby.Router; +import io.jooby.jsonrpc.JsonRpcErrorCode; +import io.jooby.jsonrpc.JsonRpcResponse; + +public class JsonRpcExceptionTranslator { + private final Router router; + + public JsonRpcExceptionTranslator(Router router) { + this.router = router; + } + + public JsonRpcErrorCode toErrorCode(Throwable cause) { + // Attempt to look up any user-defined exception mappings from the registry + Map, JsonRpcErrorCode> customMapping = + router.require(Reified.map(Class.class, JsonRpcErrorCode.class)); + return errorCode(customMapping, cause) + .orElseGet(() -> JsonRpcErrorCode.of(router.errorCode(cause))); + } + + public JsonRpcResponse.ErrorDetail toErrorDetail(Throwable cause) { + return new JsonRpcResponse.ErrorDetail(toErrorCode(cause), cause); + } + + /** + * Evaluates the given exception against the registered custom exception mappings. + * + * @param mappings A map of Exception classes to specific tRPC error codes. + * @param cause 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 cause) { + for (var mapping : mappings.entrySet()) { + if (mapping.getKey().isInstance(cause)) { + return Optional.of(mapping.getValue()); + } + } + return Optional.empty(); + } +} 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..5632c1c91e --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java @@ -0,0 +1,48 @@ +/* + * 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 io.jooby.Context; +import io.jooby.SneakyThrows; +import io.jooby.jsonrpc.JsonRpcErrorCode; +import io.jooby.jsonrpc.JsonRpcRequest; +import io.jooby.jsonrpc.JsonRpcResponse; +import io.jooby.jsonrpc.JsonRpcService; + +public class JsonRpcExecutor implements SneakyThrows.Supplier> { + private final Map services; + private final Context ctx; + private final JsonRpcRequest request; + + public JsonRpcExecutor( + Map services, Context ctx, JsonRpcRequest request) { + this.services = services; + this.ctx = ctx; + this.request = request; + } + + @Override + public Optional tryGet() throws Exception { + var fullMethod = request.getMethod(); + if (fullMethod == null) { + return Optional.of( + JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null)); + } + var targetService = services.get(fullMethod); + if (targetService != null) { + var result = targetService.execute(ctx, request); + return request.getId() != null + ? Optional.of(JsonRpcResponse.success(request.getId(), result)) + : Optional.empty(); + } + return Optional.of( + JsonRpcResponse.error( + request.getId(), JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod)); + } +} 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..bca65f05c1 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. @@ -68,7 +72,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 index 614cdec844..505f7506e2 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java @@ -6,24 +6,20 @@ package io.jooby.jsonrpc; import java.util.Objects; +import java.util.Optional; import io.jooby.Context; import io.jooby.SneakyThrows; public interface JsonRpcInvoker { - Object invoke(Context ctx, JsonRpcRequest request, SneakyThrows.Supplier action) + Optional invoke( + Context ctx, JsonRpcRequest request, SneakyThrows.Supplier> action) throws Exception; default JsonRpcInvoker then(JsonRpcInvoker next) { Objects.requireNonNull(next, "next invoker is required"); - return new JsonRpcInvoker() { - @Override - public Object invoke( - Context ctx, JsonRpcRequest request, SneakyThrows.Supplier action) - throws Exception { - return JsonRpcInvoker.this.invoke(ctx, request, () -> next.invoke(ctx, request, action)); - } - }; + return (ctx, request, action) -> + JsonRpcInvoker.this.invoke(ctx, request, () -> next.invoke(ctx, request, action)); } } 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 57d7e8a3c6..56f2a4fab6 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 @@ -14,6 +14,9 @@ import io.jooby.*; import io.jooby.exception.MissingValueException; import io.jooby.exception.TypeMismatchException; +import io.jooby.internal.jsonrpc.JsonRpcExceptionTranslator; +import io.jooby.internal.jsonrpc.JsonRpcExecutor; +import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing; /** * Global Tier 1 Dispatcher for JSON-RPC 2.0 requests. @@ -52,6 +55,8 @@ public class JsonRpcModule implements Extension { private final Map services = new HashMap<>(); private final String path; private @Nullable JsonRpcInvoker invoker; + private @Nullable OtelJsonRcpTracing head; + private JsonRpcExceptionTranslator exceptionTranslator; public JsonRpcModule(String path, JsonRpcService service, JsonRpcService... services) { this.path = path; @@ -64,10 +69,15 @@ public JsonRpcModule(JsonRpcService service, JsonRpcService... services) { } public JsonRpcModule invoker(JsonRpcInvoker invoker) { - if (this.invoker != null) { - this.invoker = invoker.then(this.invoker); + if (invoker instanceof OtelJsonRcpTracing otel) { + // otel goes first: + this.head = otel; } else { - this.invoker = invoker; + if (this.invoker != null) { + this.invoker = invoker.then(this.invoker); + } else { + this.invoker = invoker; + } } return this; } @@ -86,8 +96,13 @@ private void registry(JsonRpcService service) { */ @Override public void install(Jooby app) throws Exception { + if (head != null) { + invoker = invoker == null ? head : head.then(invoker); + } app.post(path, this::handle); + exceptionTranslator = new JsonRpcExceptionTranslator(app); + app.getServices().put(JsonRpcExceptionTranslator.class, exceptionTranslator); // Initialize the custom exception mapping registry app.getServices() .mapOf(Class.class, JsonRpcErrorCode.class) @@ -106,64 +121,43 @@ public void install(Jooby app) throws Exception { * @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty * string for notifications. */ - private Object handle(Context ctx) { + private Object handle(Context ctx) throws Exception { 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); + } catch (Exception cause) { + var badRequest = new JsonRpcRequest(); + badRequest.setMethod(JsonRpcRequest.UNKNOWN_METHOD); + var parseError = JsonRpcResponse.error(null, JsonRpcErrorCode.PARSE_ERROR, cause); + if (head != null) { + // Manually handle bad request for otel + return head.invoke(ctx, badRequest, () -> Optional.of(parseError)); + } + log(badRequest, cause); + return parseError; } 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 = - invoker == null - ? targetService.execute(ctx, request) - : invoker.invoke(ctx, request, () -> targetService.execute(ctx, request)); - // Spec: If the "id" is missing, it is a notification and no response is returned. - if (request.getId() != null) { - if (result instanceof JsonRpcResponse jsonRpcResponse) { - responses.add(jsonRpcResponse); - } else { - responses.add(JsonRpcResponse.success(request.getId(), result)); - } - } - } else { - // Spec: -32601 Method not found - responses.add( - JsonRpcResponse.error( - request.getId(), - JsonRpcErrorCode.METHOD_NOT_FOUND, - "Method not found: " + fullMethod)); - } + var target = new JsonRpcExecutor(services, ctx, request); + var response = invoker == null ? target.get() : invoker.invoke(ctx, request, target); + response.ifPresent(responses::add); } catch (JsonRpcException cause) { - log(ctx, request, cause); + log(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); + log(request, cause); // Spec: -32603 Internal error for unhandled application exceptions if (request.getId() != null) { responses.add( - JsonRpcResponse.error(request.getId(), computeErrorCode(ctx, cause), cause)); + JsonRpcResponse.error( + request.getId(), exceptionTranslator.toErrorCode(cause), cause)); } } } @@ -178,14 +172,14 @@ private Object handle(Context ctx) { return input.isBatch() ? responses : responses.getFirst(); } - private void log(Context ctx, JsonRpcRequest request, Throwable cause) { + private void log(JsonRpcRequest request, Throwable cause) { JsonRpcErrorCode code; boolean hasCause = true; if (cause instanceof JsonRpcException rpcException) { code = rpcException.getCode(); hasCause = false; } else { - code = computeErrorCode(ctx, cause); + code = exceptionTranslator.toErrorCode(cause); } var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client"; var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})"; @@ -230,33 +224,4 @@ private void log(Context ctx, JsonRpcRequest request, Throwable cause) { } } } - - 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 9dce19eb70..2f2259d191 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 @@ -33,6 +33,7 @@ * generic structure (e.g., a List or a Map) and populating the batch state. */ public class JsonRpcRequest implements Iterable { + public static final String UNKNOWN_METHOD = "unknown_method"; /** A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". */ private String jsonrpc = "2.0"; 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 57dd387018..6f03caa58f 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; /** @@ -20,8 +22,6 @@ public class JsonRpcResponse { private @Nullable ErrorDetail error; private @Nullable Object id; - public JsonRpcResponse() {} - private JsonRpcResponse( @Nullable Object id, @Nullable Object result, @Nullable ErrorDetail error) { this.id = id; @@ -49,15 +49,23 @@ public static JsonRpcResponse success(Object id, Object result) { * @return A populated JsonRpcResponse. */ public static JsonRpcResponse error(@Nullable Object id, JsonRpcErrorCode code, Object data) { - return new JsonRpcResponse( - id, null, new ErrorDetail(code.getCode(), code.getMessage(), data(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. + * + * @param id The id from the corresponding request. + * @param code The error code. + * @param cause Additional data about the error. + * @return A populated JsonRpcResponse. + */ + public static JsonRpcResponse error( + @Nullable Object id, JsonRpcErrorCode code, @Nullable Throwable cause) { + return new JsonRpcResponse(id, null, new ErrorDetail(code, cause)); } public String getJsonrpc() { @@ -94,40 +102,44 @@ public void setId(@Nullable Object id) { /** Represents the error object inside a JSON-RPC response. */ public static class ErrorDetail { - private int code; - private String message; - private Object data; + private final int code; + private final String message; + private final @Nullable Object data; - public ErrorDetail() {} - - 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() { + public @Nullable Object getData() { + if (data instanceof Throwable cause) { + return cause.getMessage(); + } return data; } - public void setData(Object data) { - this.data = data; + 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..78b1fa1728 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/instrumentation/OtelJsonRcpTracing.java @@ -0,0 +1,76 @@ +/* + * 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 io.jooby.Context; +import io.jooby.SneakyThrows; +import io.jooby.internal.jsonrpc.JsonRpcExceptionTranslator; +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; + +public class OtelJsonRcpTracing implements JsonRpcInvoker { + + private final Tracer tracer; + + public OtelJsonRcpTracing(OpenTelemetry otel) { + tracer = otel.getTracer("io.jooby.jsonrpc"); + } + + @Override + public @NonNull Optional invoke( + @NonNull Context ctx, + @NonNull JsonRpcRequest request, + SneakyThrows.@NonNull Supplier> action) + throws Exception { + var method = Optional.ofNullable(request.getMethod()).orElse(JsonRpcRequest.UNKNOWN_METHOD); + var span = + tracer + .spanBuilder(request.getMethod()) + .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()) { + var result = action.get(); + if (result.isPresent()) { + var rsp = result.get(); + var error = rsp.getError(); + if (error == null) { + span.setStatus(StatusCode.OK); + } else { + traceError(span, error.exception(), error); + } + } + return result; + } catch (JsonRpcException e) { + traceError(span, e, e.toErrorDetail()); + throw e; + } catch (Throwable e) { + traceError(span, e, ctx.require(JsonRpcExceptionTranslator.class).toErrorDetail(e)); + throw e; + } finally { + span.end(); + } + } + + private static void traceError(Span span, Throwable cause, JsonRpcResponse.ErrorDetail error) { + span.setStatus(StatusCode.ERROR, error.getMessage()); + if (cause != null) { + span.recordException(cause); + } + } +} 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-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 From 2abc32d68943d06b216ddb433792e3106976ebbd Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 23 Apr 2026 09:56:38 -0300 Subject: [PATCH 4/7] feat(jsonrpc): implement middleware pipeline and OpenTelemetry tracing Introduce a robust, JSON-RPC 2.0 compliant middleware pipeline and execution engine, alongside OpenTelemetry instrumentation. The new pipeline relies on a chain of `JsonRpcInvoker` instances. To strictly adhere to the JSON-RPC 2.0 spec, the pipeline suppresses raw exceptions, wrapping all application and protocol errors (e.g., Parse Error, Invalid Request) inside a `JsonRpcResponse`. It also leverages `Optional` to properly handle fire-and-forget Notifications (which require no response) versus standard Method Calls. Key additions: - `JsonRpcInvoker`: Middleware interface using `Optional` to support both standard calls and notifications. Enforces an exception- safe architecture by requiring errors to be returned in the response. - `JsonRpcExecutor`: The terminal invoker that routes requests to target services. Acts as the ultimate safety net, safely translating uncaught exceptions and protocol faults into valid JSON-RPC error objects. - `OtelJsonRcpTracing`: OpenTelemetry middleware that traces RPC spans. It integrates perfectly with the exception-less pipeline by inspecting the response envelope for `ErrorDetail` to accurately report span success or failure, logging semantic attributes like `rpc.method` and `rpc.jsonrpc.request_id`. --- .../jsonb/AvajeJsonRpcRequestAdapter.java | 4 +- .../JacksonJsonRpcRequestDeserializer.java | 5 +- .../JacksonJsonRpcRequestDeserializer.java | 6 +- .../jsonrpc/JsonRpcExceptionTranslator.java | 9 +- .../internal/jsonrpc/JsonRpcExecutor.java | 168 ++++++++++++++++-- .../internal/jsonrpc/JsonRpcHandler.java | 95 ++++++++++ .../io/jooby/jsonrpc/JsonRpcException.java | 12 ++ .../java/io/jooby/jsonrpc/JsonRpcInvoker.java | 59 +++++- .../java/io/jooby/jsonrpc/JsonRpcModule.java | 122 +------------ .../java/io/jooby/jsonrpc/JsonRpcRequest.java | 25 ++- .../io/jooby/jsonrpc/JsonRpcResponse.java | 55 ++++-- .../instrumentation/OtelJsonRcpTracing.java | 108 ++++++++--- .../i3868/AbstractJsonRpcProtocolTest.java | 15 ++ .../java/io/jooby/i3868/MovieServiceRpc.java | 6 + 14 files changed, 501 insertions(+), 188 deletions(-) create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcHandler.java 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/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java index 2a3cdfe139..39e43a9d88 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java @@ -11,7 +11,7 @@ import io.jooby.Reified; import io.jooby.Router; import io.jooby.jsonrpc.JsonRpcErrorCode; -import io.jooby.jsonrpc.JsonRpcResponse; +import io.jooby.jsonrpc.JsonRpcException; public class JsonRpcExceptionTranslator { private final Router router; @@ -21,6 +21,9 @@ public JsonRpcExceptionTranslator(Router router) { } public JsonRpcErrorCode toErrorCode(Throwable cause) { + if (cause instanceof JsonRpcException rpcException) { + return rpcException.getCode(); + } // Attempt to look up any user-defined exception mappings from the registry Map, JsonRpcErrorCode> customMapping = router.require(Reified.map(Class.class, JsonRpcErrorCode.class)); @@ -28,10 +31,6 @@ public JsonRpcErrorCode toErrorCode(Throwable cause) { .orElseGet(() -> JsonRpcErrorCode.of(router.errorCode(cause))); } - public JsonRpcResponse.ErrorDetail toErrorDetail(Throwable cause) { - return new JsonRpcResponse.ErrorDetail(toErrorCode(cause), cause); - } - /** * Evaluates the given exception against the registered custom exception mappings. * 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 index 5632c1c91e..e0ac3cbc6a 100644 --- 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 @@ -8,41 +8,173 @@ import java.util.Map; import java.util.Optional; +import org.slf4j.Logger; + import io.jooby.Context; import io.jooby.SneakyThrows; -import io.jooby.jsonrpc.JsonRpcErrorCode; -import io.jooby.jsonrpc.JsonRpcRequest; -import io.jooby.jsonrpc.JsonRpcResponse; -import io.jooby.jsonrpc.JsonRpcService; +import io.jooby.jsonrpc.*; +/** + * The internal execution engine and "final invoker" for JSON-RPC requests. + * + *

This class 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 SneakyThrows.Supplier> { private final Map services; private final Context ctx; private final JsonRpcRequest request; + private final Map, Logger> loggers; + private final JsonRpcExceptionTranslator exceptionTranslator; + private final Exception parseError; + /** + * Constructs a new executor for a single JSON-RPC request. + * + * @param loggers A map of loggers keyed by service class. + * @param services A map of registered JSON-RPC services keyed by method name. + * @param ctx The current HTTP context. + * @param exceptionTranslator The translator used to map standard Throwables to JSON-RPC error + * codes. + * @param request The incoming JSON-RPC request. + * @param parseError Any exception that occurred during the initial JSON parsing phase. + */ public JsonRpcExecutor( - Map services, Context ctx, JsonRpcRequest request) { + Map, Logger> loggers, + Map services, + Context ctx, + JsonRpcExceptionTranslator exceptionTranslator, + JsonRpcRequest request, + Exception parseError) { this.services = services; this.ctx = ctx; + this.exceptionTranslator = exceptionTranslator; this.request = request; + this.parseError = parseError; + this.loggers = loggers; } + /** + * 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. + * + * @return An Optional containing the JSON-RPC response, or empty if the request was a valid + * Notification. + * @throws Exception Only thrown if a fatal JVM error occurs (e.g., OutOfMemoryError) that cannot + * be recovered. + */ @Override public Optional tryGet() throws Exception { - var fullMethod = request.getMethod(); - if (fullMethod == null) { - return Optional.of( - JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null)); + 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(log, request, cause); + } + } + + private Optional toRpcResponse( + Logger log, JsonRpcRequest request, Throwable ex) { + var code = exceptionTranslator.toErrorCode(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)); } - var targetService = services.get(fullMethod); - if (targetService != null) { - var result = targetService.execute(ctx, request); - return request.getId() != null - ? Optional.of(JsonRpcResponse.success(request.getId(), result)) - : Optional.empty(); + 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); + } + } } - return Optional.of( - JsonRpcResponse.error( - request.getId(), JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod)); } } 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..e432c22810 --- /dev/null +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcHandler.java @@ -0,0 +1,95 @@ +/* + * 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 JsonRpcExceptionTranslator exceptionTranslator; + private final HashMap, Logger> loggers; + private final JsonRpcInvoker invoker; + + public JsonRpcHandler( + Map services, + JsonRpcExceptionTranslator exceptionTranslator, + JsonRpcInvoker invoker) { + this.services = services; + this.exceptionTranslator = exceptionTranslator; + this.invoker = invoker; + this.loggers = new HashMap<>(); + 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())); + }); + } + + /** + * 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(); + + // Look up all generated *Rpc classes registered in the service registry + for (var request : input) { + var target = + new JsonRpcExecutor(loggers, services, ctx, exceptionTranslator, request, parseError); + var response = invoker == null ? target.get() : invoker.invoke(ctx, request, target); + 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/JsonRpcException.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java index bca65f05c1..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 @@ -32,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. * 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 index 505f7506e2..7533bab455 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java @@ -11,12 +11,67 @@ import io.jooby.Context; import io.jooby.SneakyThrows; +/** + * 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 invoker 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 {@code action} in a try-catch block. Instead, if your + * middleware needs to react to a failure (e.g., to record an error metric), you can execute the + * action 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 action The next step in the invocation chain (or 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, SneakyThrows.Supplier> action) - throws Exception; + Context ctx, JsonRpcRequest request, SneakyThrows.Supplier> action); + /** + * 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, action) -> 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 56f2a4fab6..dc2e5ce8e6 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 @@ -15,7 +15,7 @@ import io.jooby.exception.MissingValueException; import io.jooby.exception.TypeMismatchException; import io.jooby.internal.jsonrpc.JsonRpcExceptionTranslator; -import io.jooby.internal.jsonrpc.JsonRpcExecutor; +import io.jooby.internal.jsonrpc.JsonRpcHandler; import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing; /** @@ -56,7 +56,6 @@ public class JsonRpcModule implements Extension { private final String path; private @Nullable JsonRpcInvoker invoker; private @Nullable OtelJsonRcpTracing head; - private JsonRpcExceptionTranslator exceptionTranslator; public JsonRpcModule(String path, JsonRpcService service, JsonRpcService... services) { this.path = path; @@ -99,129 +98,12 @@ public void install(Jooby app) throws Exception { if (head != null) { invoker = invoker == null ? head : head.then(invoker); } - app.post(path, this::handle); + app.post(path, new JsonRpcHandler(services, new JsonRpcExceptionTranslator(app), invoker)); - exceptionTranslator = new JsonRpcExceptionTranslator(app); - app.getServices().put(JsonRpcExceptionTranslator.class, exceptionTranslator); // Initialize the custom exception mapping registry app.getServices() .mapOf(Class.class, JsonRpcErrorCode.class) .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) throws Exception { - JsonRpcRequest input; - try { - input = ctx.body(JsonRpcRequest.class); - } catch (Exception cause) { - var badRequest = new JsonRpcRequest(); - badRequest.setMethod(JsonRpcRequest.UNKNOWN_METHOD); - var parseError = JsonRpcResponse.error(null, JsonRpcErrorCode.PARSE_ERROR, cause); - if (head != null) { - // Manually handle bad request for otel - return head.invoke(ctx, badRequest, () -> Optional.of(parseError)); - } - log(badRequest, cause); - return parseError; - } - - List responses = new ArrayList<>(); - - // Look up all generated *Rpc classes registered in the service registry - for (var request : input) { - try { - var target = new JsonRpcExecutor(services, ctx, request); - var response = invoker == null ? target.get() : invoker.invoke(ctx, request, target); - response.ifPresent(responses::add); - } catch (JsonRpcException cause) { - log(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(request, cause); - // Spec: -32603 Internal error for unhandled application exceptions - if (request.getId() != null) { - responses.add( - JsonRpcResponse.error( - request.getId(), exceptionTranslator.toErrorCode(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(JsonRpcRequest request, Throwable cause) { - JsonRpcErrorCode code; - boolean hasCause = true; - if (cause instanceof JsonRpcException rpcException) { - code = rpcException.getCode(); - hasCause = false; - } else { - code = exceptionTranslator.toErrorCode(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()); - } - } - } - } } 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 2f2259d191..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 @@ -33,13 +33,14 @@ * generic structure (e.g., a List or a Map) and populating the batch state. */ public class JsonRpcRequest implements Iterable { - public static final String UNKNOWN_METHOD = "unknown_method"; + 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 @@ -55,23 +56,31 @@ public class JsonRpcRequest implements Iterable { // --- 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; } 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 6f03caa58f..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 @@ -14,6 +14,19 @@ * *

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 { @@ -34,19 +47,20 @@ private JsonRpcResponse( * * @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(@Nullable Object id, JsonRpcErrorCode code, Object data) { if (data instanceof Throwable) { @@ -56,12 +70,12 @@ public static JsonRpcResponse error(@Nullable Object id, JsonRpcErrorCode code, } /** - * Creates an error JSON-RPC response. + * Creates an error JSON-RPC response originating from a Throwable. * - * @param id The id from the corresponding request. - * @param code The error code. - * @param cause 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 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) { @@ -100,7 +114,13 @@ 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 final int code; private final String message; @@ -128,6 +148,12 @@ public String getMessage() { return message; } + /** + * 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(); @@ -135,6 +161,11 @@ public String getMessage() { return 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; 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 index 78b1fa1728..da5bbba3bc 100644 --- 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 @@ -9,68 +9,136 @@ 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.internal.jsonrpc.JsonRpcExceptionTranslator; 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. + * + *

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 Consumer2 onStart; + + private SneakyThrows.@Nullable Consumer2 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.Consumer2 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.Consumer2 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 action 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, - SneakyThrows.@NonNull Supplier> action) - throws Exception { - var method = Optional.ofNullable(request.getMethod()).orElse(JsonRpcRequest.UNKNOWN_METHOD); + SneakyThrows.@NonNull Supplier> action) { + var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method"); var span = tracer - .spanBuilder(request.getMethod()) + .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(request, span); + } var result = action.get(); 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 { - traceError(span, error.exception(), error); + 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; - } catch (JsonRpcException e) { - traceError(span, e, e.toErrorDetail()); - throw e; - } catch (Throwable e) { - traceError(span, e, ctx.require(JsonRpcExceptionTranslator.class).toErrorDetail(e)); - throw e; } finally { + if (onEnd != null) { + onEnd.accept(request, span); + } span.end(); } } - - private static void traceError(Span span, Throwable cause, JsonRpcResponse.ErrorDetail error) { - span.setStatus(StatusCode.ERROR, error.getMessage()); - if (cause != null) { - span.recordException(cause); - } - } } 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(); } From 60e2a4c79dfcbec8328949b42dea06bc8ee4d9a5 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 23 Apr 2026 10:14:02 -0300 Subject: [PATCH 5/7] - replace next action, by a proper `chain` pattern --- .../internal/jsonrpc/JsonRpcExecutor.java | 23 +++--- .../internal/jsonrpc/JsonRpcHandler.java | 6 +- .../java/io/jooby/jsonrpc/JsonRpcChain.java | 50 ++++++++++++ .../java/io/jooby/jsonrpc/JsonRpcInvoker.java | 19 +++-- .../java/io/jooby/jsonrpc/JsonRpcModule.java | 76 +++++++++++++------ .../instrumentation/OtelJsonRcpTracing.java | 25 +++--- 6 files changed, 137 insertions(+), 62 deletions(-) create mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcChain.java 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 index e0ac3cbc6a..98147d0a3c 100644 --- 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 @@ -8,6 +8,7 @@ import java.util.Map; import java.util.Optional; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import io.jooby.Context; @@ -17,7 +18,8 @@ /** * The internal execution engine and "final invoker" for JSON-RPC requests. * - *

This class is responsible for the final stages of the JSON-RPC lifecycle: + *

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. @@ -27,10 +29,8 @@ * compliant {@link JsonRpcResponse} objects. *
*/ -public class JsonRpcExecutor implements SneakyThrows.Supplier> { +public class JsonRpcExecutor implements JsonRpcChain { private final Map services; - private final Context ctx; - private final JsonRpcRequest request; private final Map, Logger> loggers; private final JsonRpcExceptionTranslator exceptionTranslator; private final Exception parseError; @@ -40,25 +40,19 @@ public class JsonRpcExecutor implements SneakyThrows.Supplier, Logger> loggers, Map services, - Context ctx, JsonRpcExceptionTranslator exceptionTranslator, - JsonRpcRequest request, Exception parseError) { this.services = services; - this.ctx = ctx; + this.loggers = loggers; this.exceptionTranslator = exceptionTranslator; - this.request = request; this.parseError = parseError; - this.loggers = loggers; } /** @@ -68,13 +62,14 @@ public JsonRpcExecutor( * 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. - * @throws Exception Only thrown if a fatal JVM error occurs (e.g., OutOfMemoryError) that cannot - * be recovered. */ @Override - public Optional tryGet() throws Exception { + public @NonNull Optional proceed( + @NonNull Context ctx, @NonNull JsonRpcRequest request) { var log = loggers.get(JsonRpcService.class); try { if (parseError != null) { 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 index e432c22810..7dd539558f 100644 --- 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 @@ -70,12 +70,12 @@ public JsonRpcHandler( } var responses = new ArrayList(); + var executor = new JsonRpcExecutor(loggers, services, exceptionTranslator, parseError); // Look up all generated *Rpc classes registered in the service registry for (var request : input) { - var target = - new JsonRpcExecutor(loggers, services, ctx, exceptionTranslator, request, parseError); - var response = invoker == null ? target.get() : invoker.invoke(ctx, request, target); + var response = + invoker == null ? executor.proceed(ctx, request) : invoker.invoke(ctx, request, executor); response.ifPresent(responses::add); } 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/JsonRpcInvoker.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java index 7533bab455..f67673bb61 100644 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java +++ b/modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcInvoker.java @@ -9,7 +9,6 @@ import java.util.Optional; import io.jooby.Context; -import io.jooby.SneakyThrows; /** * Interceptor or middleware for processing JSON-RPC requests. @@ -47,22 +46,22 @@ public interface JsonRpcInvoker { /** - * Invokes the JSON-RPC request, passing control to the next invoker in the chain or to the final + * 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 {@code action} in a try-catch block. Instead, if your - * middleware needs to react to a failure (e.g., to record an error metric), you can execute the - * action and check for an error by evaluating {@code response.get().getError() != null}. + * 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 action The next step in the invocation chain (or the final method execution). + * @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, SneakyThrows.Supplier> action); + Optional invoke(Context ctx, JsonRpcRequest request, JsonRpcChain next); /** * Chains this invoker with another one to form a middleware pipeline. @@ -74,7 +73,7 @@ Optional invoke( */ default JsonRpcInvoker then(JsonRpcInvoker next) { Objects.requireNonNull(next, "next invoker is required"); - return (ctx, request, action) -> - JsonRpcInvoker.this.invoke(ctx, request, () -> next.invoke(ctx, request, action)); + 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 dc2e5ce8e6..0242ab68ab 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 @@ -8,8 +8,6 @@ import java.util.*; import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.jooby.*; import io.jooby.exception.MissingValueException; @@ -19,54 +17,83 @@ 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: @@ -88,10 +115,15 @@ 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 { 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 index da5bbba3bc..5805d664d6 100644 --- 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 @@ -20,7 +20,8 @@ import io.opentelemetry.api.trace.Tracer; /** - * OpenTelemetry tracing middleware for JSON-RPC invocations. + * 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 @@ -28,7 +29,7 @@ * *

Error Tracking

* - * Because the Jooby JSON-RPC pipeline catches application exceptions and transforms them into + *

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}. @@ -46,9 +47,9 @@ public class OtelJsonRcpTracing implements JsonRpcInvoker { private final Tracer tracer; - private SneakyThrows.@Nullable Consumer2 onStart; + private SneakyThrows.@Nullable Consumer3 onStart; - private SneakyThrows.@Nullable Consumer2 onEnd; + private SneakyThrows.@Nullable Consumer3 onEnd; /** * Creates a new OpenTelemetry JSON-RPC tracing middleware. @@ -67,7 +68,7 @@ public OtelJsonRcpTracing(OpenTelemetry otel) { * @param onStart The callback accepting the HTTP Context and the active Span. * @return This invoker instance for chaining. */ - public OtelJsonRcpTracing onStart(SneakyThrows.Consumer2 onStart) { + public OtelJsonRcpTracing onStart(SneakyThrows.Consumer3 onStart) { this.onStart = onStart; return this; } @@ -79,7 +80,7 @@ public OtelJsonRcpTracing onStart(SneakyThrows.Consumer2 o * @param onEnd The callback accepting the HTTP Context and the active Span. * @return This invoker instance for chaining. */ - public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer2 onEnd) { + public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer3 onEnd) { this.onEnd = onEnd; return this; } @@ -93,14 +94,12 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer2 onE * * @param ctx The current HTTP context. * @param request The incoming JSON-RPC request. - * @param action The next step in the invocation chain. + * @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, - SneakyThrows.@NonNull Supplier> action) { + @NonNull Context ctx, @NonNull JsonRpcRequest request, JsonRpcChain chain) { var method = Optional.ofNullable(request.getMethod()).orElse("unknown_method"); var span = tracer @@ -113,9 +112,9 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer2 onE .startSpan(); try (var scope = span.makeCurrent()) { if (onStart != null) { - onStart.accept(request, span); + onStart.accept(ctx, request, span); } - var result = action.get(); + 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 @@ -136,7 +135,7 @@ public OtelJsonRcpTracing onEnd(SneakyThrows.Consumer2 onE return result; } finally { if (onEnd != null) { - onEnd.accept(request, span); + onEnd.accept(ctx, request, span); } span.end(); } From b9b3d0d2041e94cee460442af6ff2c2314f904c9 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 23 Apr 2026 10:36:08 -0300 Subject: [PATCH 6/7] jsonrpc: code cleanup --- .../jsonrpc/JsonRpcExceptionTranslator.java | 51 ------------------- .../internal/jsonrpc/JsonRpcExecutor.java | 32 +++++++----- .../internal/jsonrpc/JsonRpcHandler.java | 14 ++--- .../java/io/jooby/jsonrpc/JsonRpcModule.java | 3 +- 4 files changed, 25 insertions(+), 75 deletions(-) delete mode 100644 modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java diff --git a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java b/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java deleted file mode 100644 index 39e43a9d88..0000000000 --- a/modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 io.jooby.Reified; -import io.jooby.Router; -import io.jooby.jsonrpc.JsonRpcErrorCode; -import io.jooby.jsonrpc.JsonRpcException; - -public class JsonRpcExceptionTranslator { - private final Router router; - - public JsonRpcExceptionTranslator(Router router) { - this.router = router; - } - - public JsonRpcErrorCode toErrorCode(Throwable cause) { - if (cause instanceof JsonRpcException rpcException) { - return rpcException.getCode(); - } - // Attempt to look up any user-defined exception mappings from the registry - Map, JsonRpcErrorCode> customMapping = - router.require(Reified.map(Class.class, JsonRpcErrorCode.class)); - return errorCode(customMapping, cause) - .orElseGet(() -> JsonRpcErrorCode.of(router.errorCode(cause))); - } - - /** - * Evaluates the given exception against the registered custom exception mappings. - * - * @param mappings A map of Exception classes to specific tRPC error codes. - * @param cause 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 cause) { - for (var mapping : mappings.entrySet()) { - if (mapping.getKey().isInstance(cause)) { - return Optional.of(mapping.getValue()); - } - } - return Optional.empty(); - } -} 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 index 98147d0a3c..bc5d144fce 100644 --- 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 @@ -12,6 +12,7 @@ import org.slf4j.Logger; import io.jooby.Context; +import io.jooby.Reified; import io.jooby.SneakyThrows; import io.jooby.jsonrpc.*; @@ -32,26 +33,19 @@ public class JsonRpcExecutor implements JsonRpcChain { private final Map services; private final Map, Logger> loggers; - private final JsonRpcExceptionTranslator exceptionTranslator; private final Exception parseError; /** * Constructs a new executor for a single JSON-RPC request. * - * @param loggers A map of loggers keyed by service class. * @param services A map of registered JSON-RPC services keyed by method name. - * @param exceptionTranslator The translator used to map standard Throwables to JSON-RPC error - * codes. + * @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, Logger> loggers, - Map services, - JsonRpcExceptionTranslator exceptionTranslator, - Exception parseError) { + Map services, Map, Logger> loggers, Exception parseError) { this.services = services; this.loggers = loggers; - this.exceptionTranslator = exceptionTranslator; this.parseError = parseError; } @@ -93,13 +87,13 @@ public JsonRpcExecutor( throw new JsonRpcException( JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod); } catch (Throwable cause) { - return toRpcResponse(log, request, cause); + return toRpcResponse(ctx, log, request, cause); } } private Optional toRpcResponse( - Logger log, JsonRpcRequest request, Throwable ex) { - var code = exceptionTranslator.toErrorCode(ex); + Context ctx, Logger log, JsonRpcRequest request, Throwable ex) { + var code = toErrorCode(ctx, ex); log(log, request, code, ex); if (SneakyThrows.isFatal(ex)) { @@ -172,4 +166,18 @@ private void log(Logger log, JsonRpcRequest request, JsonRpcErrorCode code, Thro } } } + + 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 index 7dd539558f..6310fef1b4 100644 --- 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 @@ -24,18 +24,11 @@ public class JsonRpcHandler implements Route.Handler { private final Map services; - private final JsonRpcExceptionTranslator exceptionTranslator; - private final HashMap, Logger> loggers; private final JsonRpcInvoker invoker; + private final Map, Logger> loggers = new HashMap<>(); - public JsonRpcHandler( - Map services, - JsonRpcExceptionTranslator exceptionTranslator, - JsonRpcInvoker invoker) { + public JsonRpcHandler(Map services, JsonRpcInvoker invoker) { this.services = services; - this.exceptionTranslator = exceptionTranslator; - this.invoker = invoker; - this.loggers = new HashMap<>(); loggers.put(JsonRpcService.class, LoggerFactory.getLogger(JsonRpcService.class)); services .values() @@ -44,6 +37,7 @@ public JsonRpcHandler( var generated = service.getClass().getAnnotation(Generated.class); loggers.put(service.getClass(), LoggerFactory.getLogger(generated.value())); }); + this.invoker = invoker; } /** @@ -70,7 +64,7 @@ public JsonRpcHandler( } var responses = new ArrayList(); - var executor = new JsonRpcExecutor(loggers, services, exceptionTranslator, parseError); + var executor = new JsonRpcExecutor(services, loggers, parseError); // Look up all generated *Rpc classes registered in the service registry for (var request : input) { 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 0242ab68ab..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 @@ -12,7 +12,6 @@ import io.jooby.*; import io.jooby.exception.MissingValueException; import io.jooby.exception.TypeMismatchException; -import io.jooby.internal.jsonrpc.JsonRpcExceptionTranslator; import io.jooby.internal.jsonrpc.JsonRpcHandler; import io.jooby.jsonrpc.instrumentation.OtelJsonRcpTracing; @@ -130,7 +129,7 @@ public void install(Jooby app) throws Exception { if (head != null) { invoker = invoker == null ? head : head.then(invoker); } - app.post(path, new JsonRpcHandler(services, new JsonRpcExceptionTranslator(app), invoker)); + app.post(path, new JsonRpcHandler(services, invoker)); // Initialize the custom exception mapping registry app.getServices() From 0f866239877665df7e745753c354a24eec6b3fa6 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Thu, 23 Apr 2026 10:57:37 -0300 Subject: [PATCH 7/7] - document new features --- docs/asciidoc/modules/json-rpc.adoc | 84 ++++++++++++++++++++++++ docs/asciidoc/modules/opentelemetry.adoc | 40 +++++++++++ 2 files changed, 124 insertions(+) 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.