Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions docs/asciidoc/modules/json-rpc.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonRpcResponse> invoke(Context ctx, JsonRpcRequest request, JsonRpcChain next) {
long start = System.currentTimeMillis();

// Proceed down the chain
Optional<JsonRpcResponse> 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<JsonRpcResponse> {
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<JsonRpcResponse>`. 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.
Expand Down
40 changes: 40 additions & 0 deletions docs/asciidoc/modules/opentelemetry.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public JsonRpcRequest fromJson(JsonReader reader) {
JsonRpcRequest invalid = new JsonRpcRequest();
invalid.setMethod(null);
invalid.setBatch(false);
invalid.setJsonrpc(null);
return invalid;
}

Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions modules/jooby-jsonrpc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@
<version>${jooby.version}</version>
</dependency>

<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>${opentelemetry.version}</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Jooby https://jooby.io
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
* Copyright 2014 Edgar Espina
*/
package io.jooby.internal.jsonrpc;

import java.util.Map;
import java.util.Optional;

import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;

import io.jooby.Context;
import io.jooby.Reified;
import io.jooby.SneakyThrows;
import io.jooby.jsonrpc.*;

/**
* The internal execution engine and "final invoker" for JSON-RPC requests.
*
* <p>This class acts as the terminal end of the {@link JsonRpcChain}. It is responsible for the
* final stages of the JSON-RPC lifecycle:
*
* <ul>
* <li>Validating the parsed request envelope.
* <li>Routing the request to the appropriate {@link JsonRpcService}.
* <li>Executing the target method.
* <li>Acting as the ultimate safety net by catching all exceptions and translating them into
* compliant {@link JsonRpcResponse} objects.
* </ul>
*/
public class JsonRpcExecutor implements JsonRpcChain {
private final Map<String, JsonRpcService> services;
private final Map<Class<?>, Logger> loggers;
private final Exception parseError;

/**
* Constructs a new executor for a single JSON-RPC request.
*
* @param services A map of registered JSON-RPC services keyed by method name.
* @param loggers A map of service loggers keyed by service class.
* @param parseError Any exception that occurred during the initial JSON parsing phase.
*/
public JsonRpcExecutor(
Map<String, JsonRpcService> services, Map<Class<?>, Logger> loggers, Exception parseError) {
this.services = services;
this.loggers = loggers;
this.parseError = parseError;
}

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

private Optional<JsonRpcResponse> toRpcResponse(
Context ctx, Logger log, JsonRpcRequest request, Throwable ex) {
var code = toErrorCode(ctx, ex);
log(log, request, code, ex);

if (SneakyThrows.isFatal(ex)) {
throw SneakyThrows.propagate(ex);
} else if (ex.getCause() != null && SneakyThrows.isFatal(ex.getCause())) {
throw SneakyThrows.propagate(ex.getCause());
}

if (request.getId() != null) {
return Optional.of(JsonRpcResponse.error(request.getId(), code, ex));
} else if (code == JsonRpcErrorCode.PARSE_ERROR || code == JsonRpcErrorCode.INVALID_REQUEST) {
// must return a valid response even if the request is invalid
return Optional.of(JsonRpcResponse.error(null, code, ex));
}
return Optional.empty();
}

/**
* Logs JSON-RPC errors adaptively based on the error code.
*
* <p>Internal server errors are logged as standard errors. Authorization and routing errors are
* logged at debug level to prevent log flooding. Other application errors are logged as warnings.
*
* @param log The logger instance to use.
* @param request The request that triggered the error.
* @param code The error code.
* @param cause The underlying exception.
*/
private void log(Logger log, JsonRpcRequest request, JsonRpcErrorCode code, Throwable cause) {
var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client";
var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})";
switch (code) {
case INTERNAL_ERROR ->
log.error(
message,
type,
code.getCode(),
code.getMessage(),
request.getMethod(),
request.getId(),
cause);
case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR ->
log.debug(
message,
type,
code.getCode(),
code.getMessage(),
request.getMethod(),
request.getId(),
cause);
default -> {
if (cause instanceof JsonRpcException) {
log.warn(
message,
type,
code.getCode(),
code.getMessage(),
request.getMethod(),
request.getId());
} else {
log.warn(
message,
type,
code.getCode(),
code.getMessage(),
request.getMethod(),
request.getId(),
cause);
}
}
}
}

public JsonRpcErrorCode toErrorCode(Context ctx, Throwable cause) {
if (cause instanceof JsonRpcException rpcException) {
return rpcException.getCode();
}
// Attempt to look up any user-defined exception mappings from the registry
Map<Class<?>, 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)));
}
}
Loading
Loading