Skip to content

Commit 2abc32d

Browse files
committed
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<JsonRpcResponse>` to properly handle fire-and-forget Notifications (which require no response) versus standard Method Calls. Key additions: - `JsonRpcInvoker`: Middleware interface using `Optional<JsonRpcResponse>` 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`.
1 parent 143894d commit 2abc32d

14 files changed

Lines changed: 501 additions & 188 deletions

File tree

modules/jooby-jsonrpc-avaje-jsonb/src/main/java/io/jooby/internal/jsonrpc/avaje/jsonb/AvajeJsonRpcRequestAdapter.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public JsonRpcRequest fromJson(JsonReader reader) {
3434
JsonRpcRequest invalid = new JsonRpcRequest();
3535
invalid.setMethod(null);
3636
invalid.setBatch(false);
37+
invalid.setJsonrpc(null);
3738
return invalid;
3839
}
3940

@@ -67,10 +68,11 @@ private JsonRpcRequest parseSingle(Object node) {
6768

6869
// 2. Validate JSON-RPC version
6970
Object versionVal = map.get("jsonrpc");
70-
if (!"2.0".equals(versionVal)) {
71+
if (!JsonRpcRequest.JSONRPC.equals(versionVal)) {
7172
req.setMethod(null);
7273
return req;
7374
}
75+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7476

7577
// 3. Extract Method
7678
Object methodVal = map.get("method");

modules/jooby-jsonrpc-jackson2/src/main/java/io/jooby/internal/jsonrpc/jackson2/JacksonJsonRpcRequestDeserializer.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ private JsonRpcRequest parseSingle(JsonNode node) {
6666

6767
// 2. Validate JSON-RPC version
6868
JsonNode versionNode = node.get("jsonrpc");
69-
if (versionNode == null || !versionNode.isTextual() || !"2.0".equals(versionNode.asText())) {
69+
if (versionNode == null
70+
|| !versionNode.isTextual()
71+
|| !JsonRpcRequest.JSONRPC.equals(versionNode.asText())) {
7072
req.setMethod(null); // Triggers -32600 Invalid Request
7173
return req;
7274
}
75+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7376

7477
// 3. Extract Method
7578
JsonNode methodNode = node.get("method");

modules/jooby-jsonrpc-jackson3/src/main/java/io/jooby/internal/jsonrpc/jackson3/JacksonJsonRpcRequestDeserializer.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public JsonRpcRequest deserialize(JsonParser p, DeserializationContext ctxt) {
3030
JsonRpcRequest invalid = new JsonRpcRequest();
3131
invalid.setMethod(null); // Acts as a flag for Invalid Request
3232
invalid.setBatch(false); // Force single return shape
33+
invalid.setJsonrpc(null);
3334
return invalid;
3435
}
3536

@@ -63,10 +64,13 @@ private JsonRpcRequest parseSingle(JsonNode node) {
6364

6465
// 2. Validate JSON-RPC version
6566
JsonNode versionNode = node.get("jsonrpc");
66-
if (versionNode == null || !versionNode.isString() || !"2.0".equals(versionNode.asString())) {
67+
if (versionNode == null
68+
|| !versionNode.isString()
69+
|| !JsonRpcRequest.JSONRPC.equals(versionNode.asString())) {
6770
req.setMethod(null); // Triggers -32600 Invalid Request
6871
return req;
6972
}
73+
req.setJsonrpc(JsonRpcRequest.JSONRPC);
7074

7175
// 3. Extract Method
7276
JsonNode methodNode = node.get("method");

modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExceptionTranslator.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import io.jooby.Reified;
1212
import io.jooby.Router;
1313
import io.jooby.jsonrpc.JsonRpcErrorCode;
14-
import io.jooby.jsonrpc.JsonRpcResponse;
14+
import io.jooby.jsonrpc.JsonRpcException;
1515

1616
public class JsonRpcExceptionTranslator {
1717
private final Router router;
@@ -21,17 +21,16 @@ public JsonRpcExceptionTranslator(Router router) {
2121
}
2222

2323
public JsonRpcErrorCode toErrorCode(Throwable cause) {
24+
if (cause instanceof JsonRpcException rpcException) {
25+
return rpcException.getCode();
26+
}
2427
// Attempt to look up any user-defined exception mappings from the registry
2528
Map<Class<?>, JsonRpcErrorCode> customMapping =
2629
router.require(Reified.map(Class.class, JsonRpcErrorCode.class));
2730
return errorCode(customMapping, cause)
2831
.orElseGet(() -> JsonRpcErrorCode.of(router.errorCode(cause)));
2932
}
3033

31-
public JsonRpcResponse.ErrorDetail toErrorDetail(Throwable cause) {
32-
return new JsonRpcResponse.ErrorDetail(toErrorCode(cause), cause);
33-
}
34-
3534
/**
3635
* Evaluates the given exception against the registered custom exception mappings.
3736
*

modules/jooby-jsonrpc/src/main/java/io/jooby/internal/jsonrpc/JsonRpcExecutor.java

Lines changed: 150 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,173 @@
88
import java.util.Map;
99
import java.util.Optional;
1010

11+
import org.slf4j.Logger;
12+
1113
import io.jooby.Context;
1214
import io.jooby.SneakyThrows;
13-
import io.jooby.jsonrpc.JsonRpcErrorCode;
14-
import io.jooby.jsonrpc.JsonRpcRequest;
15-
import io.jooby.jsonrpc.JsonRpcResponse;
16-
import io.jooby.jsonrpc.JsonRpcService;
15+
import io.jooby.jsonrpc.*;
1716

17+
/**
18+
* The internal execution engine and "final invoker" for JSON-RPC requests.
19+
*
20+
* <p>This class is responsible for the final stages of the JSON-RPC lifecycle:
21+
*
22+
* <ul>
23+
* <li>Validating the parsed request envelope.
24+
* <li>Routing the request to the appropriate {@link JsonRpcService}.
25+
* <li>Executing the target method.
26+
* <li>Acting as the ultimate safety net by catching all exceptions and translating them into
27+
* compliant {@link JsonRpcResponse} objects.
28+
* </ul>
29+
*/
1830
public class JsonRpcExecutor implements SneakyThrows.Supplier<Optional<JsonRpcResponse>> {
1931
private final Map<String, JsonRpcService> services;
2032
private final Context ctx;
2133
private final JsonRpcRequest request;
34+
private final Map<Class<?>, Logger> loggers;
35+
private final JsonRpcExceptionTranslator exceptionTranslator;
36+
private final Exception parseError;
2237

38+
/**
39+
* Constructs a new executor for a single JSON-RPC request.
40+
*
41+
* @param loggers A map of loggers keyed by service class.
42+
* @param services A map of registered JSON-RPC services keyed by method name.
43+
* @param ctx The current HTTP context.
44+
* @param exceptionTranslator The translator used to map standard Throwables to JSON-RPC error
45+
* codes.
46+
* @param request The incoming JSON-RPC request.
47+
* @param parseError Any exception that occurred during the initial JSON parsing phase.
48+
*/
2349
public JsonRpcExecutor(
24-
Map<String, JsonRpcService> services, Context ctx, JsonRpcRequest request) {
50+
Map<Class<?>, Logger> loggers,
51+
Map<String, JsonRpcService> services,
52+
Context ctx,
53+
JsonRpcExceptionTranslator exceptionTranslator,
54+
JsonRpcRequest request,
55+
Exception parseError) {
2556
this.services = services;
2657
this.ctx = ctx;
58+
this.exceptionTranslator = exceptionTranslator;
2759
this.request = request;
60+
this.parseError = parseError;
61+
this.loggers = loggers;
2862
}
2963

64+
/**
65+
* Executes the JSON-RPC request and returns an optional response.
66+
*
67+
* <p>This method adheres strictly to the JSON-RPC 2.0 specification regarding error handling and
68+
* response generation. It will return {@link Optional#empty()} for Notifications, unless a
69+
* fundamental Parse Error or Invalid Request error occurs, which always require a response.
70+
*
71+
* @return An Optional containing the JSON-RPC response, or empty if the request was a valid
72+
* Notification.
73+
* @throws Exception Only thrown if a fatal JVM error occurs (e.g., OutOfMemoryError) that cannot
74+
* be recovered.
75+
*/
3076
@Override
3177
public Optional<JsonRpcResponse> tryGet() throws Exception {
32-
var fullMethod = request.getMethod();
33-
if (fullMethod == null) {
34-
return Optional.of(
35-
JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null));
78+
var log = loggers.get(JsonRpcService.class);
79+
try {
80+
if (parseError != null) {
81+
throw new JsonRpcException(JsonRpcErrorCode.PARSE_ERROR, parseError);
82+
}
83+
if (!request.isValid()) {
84+
throw new JsonRpcException(JsonRpcErrorCode.INVALID_REQUEST, "Invalid JSON-RPC request");
85+
}
86+
var fullMethod = request.getMethod();
87+
var targetService = services.get(fullMethod);
88+
if (targetService != null) {
89+
log = loggers.get(targetService.getClass());
90+
var result = targetService.execute(ctx, request);
91+
return request.getId() != null
92+
? Optional.of(JsonRpcResponse.success(request.getId(), result))
93+
: Optional.empty();
94+
}
95+
if (request.getId() == null) {
96+
return Optional.empty();
97+
}
98+
throw new JsonRpcException(
99+
JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod);
100+
} catch (Throwable cause) {
101+
return toRpcResponse(log, request, cause);
102+
}
103+
}
104+
105+
private Optional<JsonRpcResponse> toRpcResponse(
106+
Logger log, JsonRpcRequest request, Throwable ex) {
107+
var code = exceptionTranslator.toErrorCode(ex);
108+
log(log, request, code, ex);
109+
110+
if (SneakyThrows.isFatal(ex)) {
111+
throw SneakyThrows.propagate(ex);
112+
} else if (ex.getCause() != null && SneakyThrows.isFatal(ex.getCause())) {
113+
throw SneakyThrows.propagate(ex.getCause());
114+
}
115+
116+
if (request.getId() != null) {
117+
return Optional.of(JsonRpcResponse.error(request.getId(), code, ex));
118+
} else if (code == JsonRpcErrorCode.PARSE_ERROR || code == JsonRpcErrorCode.INVALID_REQUEST) {
119+
// must return a valid response even if the request is invalid
120+
return Optional.of(JsonRpcResponse.error(null, code, ex));
36121
}
37-
var targetService = services.get(fullMethod);
38-
if (targetService != null) {
39-
var result = targetService.execute(ctx, request);
40-
return request.getId() != null
41-
? Optional.of(JsonRpcResponse.success(request.getId(), result))
42-
: Optional.empty();
122+
return Optional.empty();
123+
}
124+
125+
/**
126+
* Logs JSON-RPC errors adaptively based on the error code.
127+
*
128+
* <p>Internal server errors are logged as standard errors. Authorization and routing errors are
129+
* logged at debug level to prevent log flooding. Other application errors are logged as warnings.
130+
*
131+
* @param log The logger instance to use.
132+
* @param request The request that triggered the error.
133+
* @param code The error code.
134+
* @param cause The underlying exception.
135+
*/
136+
private void log(Logger log, JsonRpcRequest request, JsonRpcErrorCode code, Throwable cause) {
137+
var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client";
138+
var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})";
139+
switch (code) {
140+
case INTERNAL_ERROR ->
141+
log.error(
142+
message,
143+
type,
144+
code.getCode(),
145+
code.getMessage(),
146+
request.getMethod(),
147+
request.getId(),
148+
cause);
149+
case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR ->
150+
log.debug(
151+
message,
152+
type,
153+
code.getCode(),
154+
code.getMessage(),
155+
request.getMethod(),
156+
request.getId(),
157+
cause);
158+
default -> {
159+
if (cause instanceof JsonRpcException) {
160+
log.warn(
161+
message,
162+
type,
163+
code.getCode(),
164+
code.getMessage(),
165+
request.getMethod(),
166+
request.getId());
167+
} else {
168+
log.warn(
169+
message,
170+
type,
171+
code.getCode(),
172+
code.getMessage(),
173+
request.getMethod(),
174+
request.getId(),
175+
cause);
176+
}
177+
}
43178
}
44-
return Optional.of(
45-
JsonRpcResponse.error(
46-
request.getId(), JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + fullMethod));
47179
}
48180
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.jsonrpc;
7+
8+
import java.util.ArrayList;
9+
import java.util.HashMap;
10+
import java.util.Map;
11+
12+
import org.jspecify.annotations.NonNull;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
16+
import io.jooby.Context;
17+
import io.jooby.Route;
18+
import io.jooby.StatusCode;
19+
import io.jooby.annotation.Generated;
20+
import io.jooby.jsonrpc.JsonRpcInvoker;
21+
import io.jooby.jsonrpc.JsonRpcRequest;
22+
import io.jooby.jsonrpc.JsonRpcResponse;
23+
import io.jooby.jsonrpc.JsonRpcService;
24+
25+
public class JsonRpcHandler implements Route.Handler {
26+
private final Map<String, JsonRpcService> services;
27+
private final JsonRpcExceptionTranslator exceptionTranslator;
28+
private final HashMap<Class<?>, Logger> loggers;
29+
private final JsonRpcInvoker invoker;
30+
31+
public JsonRpcHandler(
32+
Map<String, JsonRpcService> services,
33+
JsonRpcExceptionTranslator exceptionTranslator,
34+
JsonRpcInvoker invoker) {
35+
this.services = services;
36+
this.exceptionTranslator = exceptionTranslator;
37+
this.invoker = invoker;
38+
this.loggers = new HashMap<>();
39+
loggers.put(JsonRpcService.class, LoggerFactory.getLogger(JsonRpcService.class));
40+
services
41+
.values()
42+
.forEach(
43+
service -> {
44+
var generated = service.getClass().getAnnotation(Generated.class);
45+
loggers.put(service.getClass(), LoggerFactory.getLogger(generated.value()));
46+
});
47+
}
48+
49+
/**
50+
* Main handler for the JSON-RPC protocol. *
51+
*
52+
* <p>This method implements the flattened iteration logic. Because {@link JsonRpcRequest}
53+
* implements {@code Iterable}, this handler treats single requests and batch requests identically
54+
* during processing.
55+
*
56+
* @param ctx The current Jooby context.
57+
* @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty
58+
* string for notifications.
59+
*/
60+
@Override
61+
public @NonNull Object apply(@NonNull Context ctx) throws Exception {
62+
JsonRpcRequest input;
63+
Exception parseError = null;
64+
try {
65+
input = ctx.body(JsonRpcRequest.class);
66+
} catch (Exception cause) {
67+
// still execute the handler/pipeline so we can log the error properly
68+
input = JsonRpcRequest.BAD_REQUEST;
69+
parseError = cause;
70+
}
71+
72+
var responses = new ArrayList<JsonRpcResponse>();
73+
74+
// Look up all generated *Rpc classes registered in the service registry
75+
for (var request : input) {
76+
var target =
77+
new JsonRpcExecutor(loggers, services, ctx, exceptionTranslator, request, parseError);
78+
var response = invoker == null ? target.get() : invoker.invoke(ctx, request, target);
79+
response.ifPresent(responses::add);
80+
}
81+
82+
// Handle the case where all requests in a batch were notifications
83+
if (responses.isEmpty()) {
84+
return ctx.send(StatusCode.NO_CONTENT);
85+
}
86+
87+
// Spec: Return an array only if the original request was a batch
88+
return input.isBatch() ? responses : responses.getFirst();
89+
}
90+
91+
@Override
92+
public void setRoute(Route route) {
93+
route.setAttribute("jsonrpc", true);
94+
}
95+
}

modules/jooby-jsonrpc/src/main/java/io/jooby/jsonrpc/JsonRpcException.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public JsonRpcException(JsonRpcErrorCode code, String message) {
3232
this.data = null;
3333
}
3434

35+
/**
36+
* Constructs a new JSON-RPC exception.
37+
*
38+
* @param code The integer error code (preferably one of the standard constants).
39+
* @param cause The underlying cause of the error.
40+
*/
41+
public JsonRpcException(JsonRpcErrorCode code, Throwable cause) {
42+
super(code.getMessage(), cause);
43+
this.code = code;
44+
this.data = null;
45+
}
46+
3547
/**
3648
* Constructs a new JSON-RPC exception.
3749
*

0 commit comments

Comments
 (0)