diff --git a/docs/asciidoc/json-rpc.adoc b/docs/asciidoc/json-rpc.adoc
new file mode 100644
index 0000000000..03ad8fe020
--- /dev/null
+++ b/docs/asciidoc/json-rpc.adoc
@@ -0,0 +1,142 @@
+==== JSON-RPC
+
+Jooby provides full support for the JSON-RPC 2.0 specification, allowing you to build robust RPC APIs using standard Java and Kotlin controllers.
+
+The implementation leverages Jooby's Annotation Processing Tool (APT) to generate highly optimized, reflection-free dispatchers at compile time.
+
+===== Usage
+
+To expose a JSON-RPC endpoint, annotate your controller or service with `@JsonRpc`. You can optionally provide a namespace to the annotation, which will prefix all generated method names for that class.
+
+.JSON-RPC
+[source,java,role="primary"]
+----
+import io.jooby.jsonrpc.JsonRpc;
+
+@JsonRpc("movies")
+public class MovieService {
+
+ public Movie getById(int id) {
+ return database.stream()
+ .filter(m -> m.id() == id)
+ .findFirst()
+ .orElseThrow(() -> new NotFoundException("Movie not found: " + id));
+ }
+}
+----
+
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.jsonrpc.JsonRpc
+
+@JsonRpc("movies")
+class MovieService {
+
+ fun getById(id: Int): Movie {
+ return database.asSequence()
+ .filter { it.id == id }
+ .firstOrNull() ?: throw NotFoundException("Movie not found: $id")
+ }
+}
+----
+
+When the Jooby APT detects the `@JsonRpc` annotation, it generates a class ending in `Rpc_` (e.g., `MovieServiceRpc_`) that implements the `io.jooby.jsonrpc.JsonRpcService` interface.
+
+The annotation dictates how the protocol methods are named and exposed:
+
+* **Class Level:** Placing `@JsonRpc` on a class treats its public methods as JSON-RPC endpoints. An optional string value defines a namespace prefix for all methods within that class (e.g., `@JsonRpc("movies")`). If no value is provided, no namespace is applied.
+* **Method Level:** By default, the generated JSON-RPC method name is exactly the name of the Java/Kotlin method, prefixed by the class-level namespace if one is present (e.g., `movies.getById` or just `getById` if no namespace was set). You can also place `@JsonRpc("customName")` directly on specific methods to explicitly override this default naming convention.
+
+**Mixing Annotations:** You can freely mix standard REST annotations (like `@GET`, `@POST`) and `@JsonRpc` on the same class. The APT handles this by generating two entirely separate dispatchers: one standard MVC extension (e.g., `MovieService_`) and one JSON-RPC service (e.g., `MovieServiceRpc_`). They do not interfere with each other, allowing you to expose the exact same business logic over both REST and JSON-RPC simultaneously.
+
+===== Registration
+
+Register the generated `JsonRpcService` in your application using the `jsonrpc` method. You must also install a supported JSON engine.
+
+.JSON-RPC
+[source,java,role="primary"]
+----
+import io.jooby.Jooby;
+import io.jooby.jackson.JacksonModule;
+
+public class App extends Jooby {
+ {
+ install(new JacksonModule()); // <1>
+
+ jsonrpc(new MovieServiceRpc_()); // <2>
+
+ // Alternatively, you can override the default path:
+ // jsonrpc("/json-rpc", new MovieServiceRpc_());
+ }
+}
+----
+
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.kt.Kooby
+import io.jooby.jackson.JacksonModule
+
+class App : Kooby({
+
+ install(JacksonModule()) // <1>
+
+ jsonrpc(MovieServiceRpc_()) // <2>
+
+ // Custom endpoint:
+ // jsonrpc("/json-rpc", MovieServiceRpc_())
+})
+----
+
+1. Install a JSON engine
+2. Register the generated JSON-RPC service
+
+===== JSON Engine Support
+
+The JSON-RPC extension delegates payload parsing and serialization to Jooby's standard JSON modules while enforcing strict JSON-RPC 2.0 compliance (such as the mutual exclusivity of `result` and `error` fields).
+
+Supported engines include:
+
+* **Jackson 2** (`JacksonModule`)
+* **Jackson 3** (`Jackson3Module`)
+* **Avaje JSON-B** (`AvajeJsonbModule`)
+
+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.
+
+===== 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.
+
+When an application exception is thrown (like a `NotFoundException` with an HTTP 404 status), the dispatcher catches it and translates it into a compliant JSON-RPC error. The standard HTTP status defines the error `code`, while the specific exception message is safely passed into the `data` field:
+
+[source,json]
+----
+{
+ "jsonrpc": "2.0",
+ "error": {
+ "code": -32004,
+ "message": "Not found",
+ "data": "Movie not found: 99"
+ },
+ "id": 1
+}
+----
+
+====== Standard Protocol Errors
+
+The engine handles core JSON-RPC 2.0 protocol errors automatically, returning HTTP 200 OK with the corresponding error payload:
+
+* `-32700`: Parse error (Malformed JSON)
+* `-32600`: Invalid Request (Missing version, ID, or malformed envelope)
+* `-32601`: Method not found
+* `-32602`: Invalid params (Missing arguments, type mismatches)
+* `-32603`: Internal error
+
+Application-defined errors map standard HTTP status codes to the `-32000` to `-32099` range (e.g., an HTTP 404 maps to `-32004`, an HTTP 401 maps to `-32001`).
+
+===== Batch Processing
+
+Batch processing is natively supported. Clients can send an array of JSON-RPC request objects, and the dispatcher will process them and return an array of corresponding response objects.
+
+In accordance with the specification, notifications (requests lacking an `id` field) are processed normally but generate no response payload, leaving no trace in the returned batch array.
diff --git a/docs/asciidoc/rpc.adoc b/docs/asciidoc/rpc.adoc
new file mode 100644
index 0000000000..b8cfee1986
--- /dev/null
+++ b/docs/asciidoc/rpc.adoc
@@ -0,0 +1,5 @@
+=== RPC
+
+include::json-rpc.adoc[]
+
+include::tRPC.adoc[]
diff --git a/docs/asciidoc/tRPC.adoc b/docs/asciidoc/tRPC.adoc
index 15973342dc..4ff4cac5ef 100644
--- a/docs/asciidoc/tRPC.adoc
+++ b/docs/asciidoc/tRPC.adoc
@@ -1,16 +1,17 @@
-=== tRPC
+==== tRPC
The tRPC module provides end-to-end type safety by integrating the https://trpc.io/[tRPC] protocol directly into Jooby.
Because the `io.jooby.trpc` package is included in Jooby core, there are no extra dependencies to add to your project. This integration allows you to write standard Java/Kotlin controllers and consume them directly in the browser using the official `@trpc/client`—complete with 100% type safety, autocomplete, and zero manual client generation.
-==== Usage
+===== Usage
Because tRPC relies heavily on JSON serialization to communicate with the frontend client, a JSON module **must** be installed prior to the `TrpcModule`.
-NOTE: Currently, Jooby only provides the required `TrpcParser` SPI implementation for two JSON engines: **Jackson 2/3** and **AvajeJsonbModule** . Using other JSON modules (like Gson) will result in a missing service exception at startup.
+NOTE: Currently, Jooby only provides the required `TrpcParser` SPI implementation for two JSON engines: **Jackson 2/3** and **AvajeJsonbModule**. Using other JSON modules (like Gson) will result in a missing service exception at startup.
-[source, java]
+.Java
+[source,java,role="primary"]
----
import io.jooby.Jooby;
import io.jooby.json.JacksonModule;
@@ -27,19 +28,35 @@ public class App extends Jooby {
}
----
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.kt.Kooby
+import io.jooby.json.JacksonModule
+import io.jooby.trpc.TrpcModule
+
+class App : Kooby({
+ install(JacksonModule()) // <1>
+
+ install(TrpcModule()) // <2>
+
+ install(MovieService_()) // <3>
+})
+----
+
1. Install a supported JSON engine (Jackson or Avaje)
2. Install the tRPC extension
3. Register your @Trpc annotated controllers (using the APT generated route)
-==== Writing a Service
+===== Writing a Service
You can define your procedures using explicit tRPC annotations or a hybrid approach combining tRPC with standard HTTP methods:
* **Explicit Annotations:** Use `@Trpc.Query` (maps to `GET`) and `@Trpc.Mutation` (maps to `POST`).
* **Hybrid Annotations:** Combine the base `@Trpc` annotation with Jooby's standard HTTP annotations. A `@GET` resolves to a tRPC query, while state-changing methods (`@POST`, `@PUT`, `@DELETE`) resolve to tRPC mutations.
-.MovieService
-[source, java]
+.Java
+[source,java,role="primary"]
----
import io.jooby.annotation.Trpc;
import io.jooby.annotation.DELETE;
@@ -71,12 +88,45 @@ public class MovieService {
}
----
-==== Build Tool Configuration
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.annotation.Trpc
+import io.jooby.annotation.DELETE
+
+data class Movie(val id: Int, val title: String, val year: Int)
+
+@Trpc("movies") // Defines the 'movies' namespace
+class MovieService {
+
+ // 1. Explicit tRPC Query
+ @Trpc.Query
+ fun getById(id: Int): Movie {
+ return Movie(id, "Pulp Fiction", 1994)
+ }
+
+ // 2. Explicit tRPC Mutation
+ @Trpc.Mutation
+ fun create(movie: Movie): Movie {
+ // Save to database logic here
+ return movie
+ }
+
+ // 3. Hybrid Mutation
+ @Trpc
+ @DELETE
+ fun delete(id: Int) {
+ // Delete from database
+ }
+}
+----
+
+===== Build Configuration
To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby build plugin for your project. The generator parses your source code and emits the definitions during the compilation phase.
.pom.xml
-[source, xml, role = "primary", subs="verbatim,attributes"]
+[source,xml,role="primary",subs="verbatim,attributes"]
----
io.jooby
@@ -96,8 +146,8 @@ To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby
----
-.gradle.build
-[source, groovy, role = "secondary", subs="verbatim,attributes"]
+.build.gradle
+[source,groovy,role="secondary",subs="verbatim,attributes"]
----
plugins {
id 'io.jooby.trpc' version "${joobyVersion}"
@@ -109,16 +159,16 @@ trpc {
}
----
-==== Consuming the API (Frontend)
+===== Consuming the API (Frontend)
Once the project is compiled, the build plugin generates a `trpc.d.ts` file containing your exact `AppRouter` shape. You can then use the official client in your TypeScript frontend:
-[source, bash]
+[source,bash]
----
npm install @trpc/client
----
-[source, typescript]
+[source,typescript]
----
import { createTRPCProxyClient, httpLink } from '@trpc/client';
import type { AppRouter } from './target/classes/trpc'; // Path to generated file
@@ -137,14 +187,15 @@ const movie = await trpc.movies.getById.query(1);
console.log(`Fetched: ${movie.title} (${movie.year})`);
----
-==== Advanced Configuration
+===== Advanced Configuration
-===== Custom Exception Mapping
+====== Custom Exception Mapping
The tRPC protocol expects specific JSON-RPC error codes (e.g., `-32600` for Bad Request). `TrpcModule` automatically registers a specialized error handler to format these errors.
If you throw custom domain exceptions, you can map them directly to tRPC error codes using the service registry so the frontend client receives the correct error state:
-[source, java]
+.Java
+[source,java,role="primary"]
----
import io.jooby.trpc.TrpcErrorCode;
@@ -158,11 +209,28 @@ import io.jooby.trpc.TrpcErrorCode;
}
----
-===== Custom TypeScript Mappings
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+import io.jooby.kt.Kooby
+import io.jooby.trpc.TrpcModule
+import io.jooby.trpc.TrpcErrorCode
+
+class App : Kooby({
+ install(TrpcModule())
+
+ // Map your custom business exception to a standard tRPC error code
+ services.mapOf(Class::class.java, TrpcErrorCode::class.java)
+ .put(IllegalArgumentException::class.java, TrpcErrorCode.BAD_REQUEST)
+ .put(MovieNotFoundException::class.java, TrpcErrorCode.NOT_FOUND)
+})
+----
+
+====== Custom TypeScript Mappings
Sometimes you have custom Java types (like `java.util.UUID` or `java.math.BigDecimal`) that you want translated into specific TypeScript primitives. You can define these overrides in your build tool:
-**Maven:**
-[source, xml]
+.Maven
+[source,xml,role="primary"]
----
@@ -172,8 +240,8 @@ Sometimes you have custom Java types (like `java.util.UUID` or `java.math.BigDec
----
-**Gradle:**
-[source, groovy]
+.Gradle
+[source,groovy,role="secondary"]
----
trpc {
customTypeMappings = [
diff --git a/docs/asciidoc/web.adoc b/docs/asciidoc/web.adoc
index ffd262d3ce..3ea42db322 100644
--- a/docs/asciidoc/web.adoc
+++ b/docs/asciidoc/web.adoc
@@ -10,6 +10,6 @@ include::session.adoc[]
include::server-sent-event.adoc[]
-include::tRPC.adoc[]
+include::rpc.adoc[]
include::websocket.adoc[]
diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java
index 44e833d3d0..465ced7fb7 100644
--- a/jooby/src/main/java/io/jooby/Jooby.java
+++ b/jooby/src/main/java/io/jooby/Jooby.java
@@ -35,6 +35,8 @@
import io.jooby.internal.MutedServer;
import io.jooby.internal.RegistryRef;
import io.jooby.internal.RouterImpl;
+import io.jooby.jsonrpc.JsonRpcModule;
+import io.jooby.jsonrpc.JsonRpcService;
import io.jooby.output.OutputFactory;
import io.jooby.problem.ProblemDetailsHandler;
import io.jooby.value.ValueFactory;
@@ -102,6 +104,8 @@ public class Jooby implements Router, Registry {
private List locales;
+ private Map dispatchers;
+
private boolean lateInit;
private String name;
@@ -383,7 +387,7 @@ public String getContextPath() {
* @param factory Application factory.
* @return This application.
*/
- @NonNull public Jooby install(
+ public Jooby install(
@NonNull String path,
@NonNull Predicate predicate,
@NonNull SneakyThrows.Supplier factory) {
@@ -426,7 +430,7 @@ public String getContextPath() {
* @param factory Application factory.
* @return This application.
*/
- @NonNull public Jooby install(
+ public Jooby install(
@NonNull Predicate predicate, @NonNull SneakyThrows.Supplier factory) {
return install("/", predicate, factory);
}
@@ -478,8 +482,7 @@ public Route.Set mount(@NonNull Predicate predicate, @NonNull Router su
@Override
public Route.Set mount(@NonNull String path, @NonNull Router router) {
var rs = this.router.mount(path, router);
- if (router instanceof Jooby) {
- Jooby child = (Jooby) router;
+ if (router instanceof Jooby child) {
child.registry = this.registry;
}
return rs;
@@ -490,19 +493,46 @@ public Route.Set mount(@NonNull Router router) {
return mount("/", router);
}
+ public Jooby jsonRpc(String path, @NonNull JsonRpcService service) {
+ if (dispatchers == null) {
+ dispatchers = new HashMap<>();
+ }
+ dispatchers
+ .computeIfAbsent(
+ Router.normalizePath(path),
+ normalizedPath -> {
+ var dispatcher = new JsonRpcModule(normalizedPath);
+ install(dispatcher);
+ return dispatcher;
+ })
+ .add(service);
+ return this;
+ }
+
+ public Jooby jsonRpc(@NonNull JsonRpcService service) {
+ return jsonRpc("/rpc", service);
+ }
+
/**
* Add controller routes.
*
* @param router Mvc extension.
* @return Route set.
*/
- @NonNull public Route.Set mvc(@NonNull Extension router) {
- try {
- int start = this.router.getRoutes().size();
- router.install(this);
- return new Route.Set(this.router.getRoutes().subList(start, this.router.getRoutes().size()));
- } catch (Exception cause) {
- throw SneakyThrows.propagate(cause);
+ public Route.Set mvc(@NonNull Extension router) {
+ if (router instanceof JsonRpcService jsonRpcService) {
+ jsonRpc(jsonRpcService);
+ // NOOP
+ return new Route.Set(Collections.emptyList());
+ } else {
+ try {
+ int start = this.router.getRoutes().size();
+ router.install(this);
+ return new Route.Set(
+ this.router.getRoutes().subList(start, this.router.getRoutes().size()));
+ } catch (Exception cause) {
+ throw SneakyThrows.propagate(cause);
+ }
}
}
@@ -1455,5 +1485,6 @@ private static void copyState(Jooby source, Jooby dest) {
dest.readyCallbacks = source.readyCallbacks;
dest.startingCallbacks = source.startingCallbacks;
dest.stopCallbacks = source.stopCallbacks;
+ dest.dispatchers = source.dispatchers;
}
}
diff --git a/jooby/src/main/java/io/jooby/Router.java b/jooby/src/main/java/io/jooby/Router.java
index 802b9618d5..b07d64ef5a 100644
--- a/jooby/src/main/java/io/jooby/Router.java
+++ b/jooby/src/main/java/io/jooby/Router.java
@@ -908,7 +908,7 @@ static String leadingSlash(@Nullable String path) {
* @param path Path to process.
* @return Path without trailing slashes.
*/
- static @NonNull String noTrailingSlash(@NonNull String path) {
+ static String noTrailingSlash(@NonNull String path) {
StringBuilder buff = new StringBuilder(path);
int i = buff.length() - 1;
while (i > 0 && buff.charAt(i) == '/') {
@@ -927,7 +927,7 @@ static String leadingSlash(@Nullable String path) {
* @param path Path to process.
* @return Safe path pattern.
*/
- static @NonNull String normalizePath(@Nullable String path) {
+ static String normalizePath(@Nullable String path) {
if (path == null || path.length() == 0 || path.equals("/")) {
return "/";
}
diff --git a/jooby/src/main/java/io/jooby/annotation/JsonRpc.java b/jooby/src/main/java/io/jooby/annotation/JsonRpc.java
new file mode 100644
index 0000000000..e0f0c0d681
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/annotation/JsonRpc.java
@@ -0,0 +1,51 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a class or its methods as a JSON-RPC 2.0 endpoint.
+ *
+ *
Discovery Rules:
+ *
+ *
+ *
Implicit Mapping: If a class is annotated with {@code @JsonRpc} and no
+ * methods are explicitly annotated, all public methods are automatically exposed as
+ * JSON-RPC endpoints.
+ *
Explicit Mapping: If at least one method in the class is explicitly annotated
+ * with {@code @JsonRpc}, implicit mapping is disabled. Only the annotated methods will
+ * be exposed, requiring you to map any other desired endpoints one by one.
+ *
+ *
+ *
Naming & Routing Rules:
+ *
+ *
+ *
Class Level (Namespace): When applied to a class, the {@link #value()} defines the
+ * namespace for all JSON-RPC methods within that class. If the annotation is present but the
+ * value is empty (the default), no namespace is applied.
+ *
Method Level (Method Name): When applied to a method, the {@link #value()} defines
+ * the exact RPC method name. If the value is empty, the actual Java/Kotlin method name is
+ * used.
+ *
+ *
+ *
The final JSON-RPC method string expected by the dispatcher is formatted as {@code
+ * "namespace.methodName"} (or just {@code "methodName"} if no namespace exists).
+ */
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JsonRpc {
+
+ /**
+ * The explicit namespace (when used on a class) or the explicit method name (when used on a
+ * method). * @return The overridden name, or an empty string to use the defaults (no namespace
+ * for classes, actual method name for methods).
+ */
+ String value() default "";
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java
new file mode 100644
index 0000000000..232ea3b3b9
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java
@@ -0,0 +1,35 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.jsonrpc;
+
+/**
+ * A pre-resolved decoder used at runtime to deserialize JSON-RPC parameter nodes into complex Java
+ * objects.
+ *
+ *
This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling
+ * {@code @JsonRpc}-annotated controllers, the APT generates highly optimized routing code that
+ * resolves the appropriate {@code JsonRpcDecoder} for each method argument. By pre-resolving these
+ * decoders, Jooby efficiently parses incoming JSON arguments without incurring reflection overhead
+ * on every request.
+ *
+ *
Note: Primitive types and standard wrappers (like {@code int}, {@code String}, {@code
+ * boolean}) are typically handled directly by the {@link JsonRpcReader} rather than requiring a
+ * dedicated decoder.
+ *
+ * @param The target Java type this decoder produces.
+ */
+public interface JsonRpcDecoder {
+
+ /**
+ * Decodes a generic JSON node object into the target Java object.
+ *
+ * @param name The name of the parameter being decoded (useful for error reporting or wrapping).
+ * @param node The generic JSON node (e.g., a Map, List, or library-specific AST node)
+ * representing this specific argument.
+ * @return The fully deserialized Java object.
+ */
+ T decode(String name, Object node);
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java
new file mode 100644
index 0000000000..b0af276159
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java
@@ -0,0 +1,141 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.jsonrpc;
+
+import io.jooby.StatusCode;
+
+/**
+ * Standard JSON-RPC 2.0 Error Codes mapped to HTTP status codes.
+ *
+ *
The JSON-RPC 2.0 specification defines a specific set of integer codes for standard errors.
+ * This enumeration provides the canonical mapping between those JSON-RPC errors and Jooby's {@link
+ * StatusCode} for HTTP transport bindings.
+ */
+public enum JsonRpcErrorCode {
+
+ // --- Core JSON-RPC 2.0 Errors ---
+
+ /** The JSON sent is not a valid Request object. */
+ INVALID_REQUEST(-32600, "Invalid Request", StatusCode.BAD_REQUEST, true),
+
+ /**
+ * Invalid JSON was received by the server. An error occurred on the server while parsing the JSON
+ * text.
+ */
+ PARSE_ERROR(-32700, "Parse error", StatusCode.BAD_REQUEST, true),
+
+ /** The method does not exist / is not available. */
+ METHOD_NOT_FOUND(-32601, "Method not found", StatusCode.NOT_FOUND, true),
+
+ /** Invalid method parameter(s). */
+ INVALID_PARAMS(-32602, "Invalid params", StatusCode.BAD_REQUEST, true),
+
+ /** Internal JSON-RPC error. */
+ INTERNAL_ERROR(-32603, "Internal error", StatusCode.SERVER_ERROR, true),
+
+ // --- Implementation-defined Server Errors (-32000 to -32099) ---
+
+ /** Missing or invalid authentication. */
+ UNAUTHORIZED(-32001, "Unauthorized", StatusCode.UNAUTHORIZED, false),
+
+ /** Authenticated user lacks required permissions. */
+ FORBIDDEN(-32003, "Forbidden", StatusCode.FORBIDDEN, false),
+
+ /** The requested resource or procedure was not found (Business Logic). */
+ NOT_FOUND_ERROR(-32004, "Not found", StatusCode.NOT_FOUND, false),
+
+ /** State conflict, such as a duplicate database entry. */
+ CONFLICT(-32009, "Conflict", StatusCode.CONFLICT, false),
+
+ /** The client's preconditions were not met. */
+ PRECONDITION_FAILED(-32012, "Precondition failed", StatusCode.PRECONDITION_FAILED, false),
+
+ /**
+ * The payload format is valid, but the content is semantically incorrect (e.g., validation
+ * failed).
+ */
+ UNPROCESSABLE_CONTENT(-32022, "Unprocessable content", StatusCode.UNPROCESSABLE_ENTITY, false),
+
+ /** Rate limiting applied. */
+ TOO_MANY_REQUESTS(-32029, "Too many requests", StatusCode.TOO_MANY_REQUESTS, false);
+
+ private final int code;
+ private final String message;
+ private final StatusCode statusCode;
+ private final boolean protocol;
+
+ /**
+ * Defines a JSON-RPC error code mapping.
+ *
+ * @param code The JSON-RPC 2.0 integer code.
+ * @param message The standard error message.
+ * @param statusCode The HTTP status code to associate with this error.
+ * @param protocol True if this is a strict JSON-RPC 2.0 protocol error, false if
+ * implementation-defined.
+ */
+ JsonRpcErrorCode(int code, String message, StatusCode statusCode, boolean protocol) {
+ this.code = code;
+ this.message = message;
+ this.statusCode = statusCode;
+ this.protocol = protocol;
+ }
+
+ /**
+ * Retrieves the JSON-RPC integer code.
+ *
+ * @return The integer code (e.g., -32600).
+ */
+ public int getCode() {
+ return code;
+ }
+
+ /**
+ * Retrieves the standard JSON-RPC error message.
+ *
+ * @return The error message.
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * Retrieves the corresponding HTTP status code.
+ *
+ * @return The Jooby {@link StatusCode}.
+ */
+ public StatusCode getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Indicates if this error is a core JSON-RPC 2.0 protocol error.
+ *
+ * @return True for strict protocol errors (-32700 to -32603), false for implementation-defined
+ * errors.
+ */
+ public boolean isProtocol() {
+ return protocol;
+ }
+
+ /**
+ * Resolves the closest JSON-RPC error code for a given Jooby HTTP status code.
+ *
+ *
If an exact match is not found for the provided HTTP status, this method falls back to
+ * {@link #INTERNAL_ERROR}.
+ *
+ * @param status The Jooby HTTP status code.
+ * @return The corresponding {@code JsonRpcErrorCode}, or {@code INTERNAL_ERROR} if no match
+ * exists.
+ */
+ public static JsonRpcErrorCode of(StatusCode status) {
+ for (var errorCode : values()) {
+ if (!errorCode.protocol && errorCode.statusCode.value() == status.value()) {
+ return errorCode;
+ }
+ }
+ return INTERNAL_ERROR;
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java
new file mode 100644
index 0000000000..31c8c7279b
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java
@@ -0,0 +1,74 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.jsonrpc;
+
+/**
+ * Exception thrown when a JSON-RPC error occurs during routing, parsing, or execution.
+ *
+ *
Contains standard JSON-RPC 2.0 error codes. When caught by the dispatcher, this exception
+ * should be transformed into a {@link JsonRpcResponse} containing the error details.
+ */
+public class JsonRpcException extends RuntimeException {
+ private final JsonRpcErrorCode code;
+
+ private final Object data;
+
+ /**
+ * Constructs a new JSON-RPC exception.
+ *
+ * @param code The integer error code (preferably one of the standard constants).
+ * @param message A short description of the error.
+ */
+ public JsonRpcException(JsonRpcErrorCode code, String message) {
+ super(message);
+ this.code = code;
+ this.data = null;
+ }
+
+ /**
+ * Constructs a new JSON-RPC exception.
+ *
+ * @param code The integer error code (preferably one of the standard constants).
+ * @param message A short description of the error.
+ * @param cause The underlying cause of the error.
+ */
+ public JsonRpcException(JsonRpcErrorCode code, String message, Throwable cause) {
+ super(message, cause);
+ this.code = code;
+ this.data = null;
+ }
+
+ /**
+ * Constructs a new JSON-RPC exception with additional error data.
+ *
+ * @param code The integer error code.
+ * @param message A short description of the error.
+ * @param data Additional data about the error (e.g., stack trace or validation messages).
+ */
+ public JsonRpcException(JsonRpcErrorCode code, String message, Object data) {
+ super(message);
+ this.code = code;
+ this.data = data;
+ }
+
+ /**
+ * Returns the JSON-RPC error code.
+ *
+ * @return The JSON-RPC error code.
+ */
+ public JsonRpcErrorCode getCode() {
+ return code;
+ }
+
+ /**
+ * Returns additional data regarding the error.
+ *
+ * @return Additional data regarding the error, or null if none was provided.
+ */
+ public Object getData() {
+ return data;
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java
new file mode 100644
index 0000000000..23810fcce7
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java
@@ -0,0 +1,236 @@
+/*
+ * 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.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.jooby.*;
+import io.jooby.exception.MissingValueException;
+import io.jooby.exception.TypeMismatchException;
+
+/**
+ * Global Tier 1 Dispatcher for JSON-RPC 2.0 requests. *
+ *
+ *
This dispatcher acts as the central entry point for all JSON-RPC traffic. It manages the
+ * lifecycle of a request by:
+ *
+ *
+ *
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.
+ *
+ *
+ * @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;
+
+ public JsonRpcModule(String path) {
+ this.path = path;
+ }
+
+ public void add(JsonRpcService service) {
+ for (var method : service.getMethods()) {
+ this.services.put(method, service);
+ }
+ }
+
+ /**
+ * Installs the JSON-RPC handler at the default {@code /rpc} endpoint.
+ *
+ * @param app The Jooby application instance.
+ * @throws Exception If registration fails.
+ */
+ @Override
+ public void install(Jooby app) throws Exception {
+ app.post(path, this::handle);
+
+ // 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) {
+ JsonRpcRequest input;
+ try {
+ input = ctx.body(JsonRpcRequest.class);
+ } catch (Exception e) {
+ // Spec: -32700 Parse error if the JSON is physically malformed.
+ return JsonRpcResponse.error(null, JsonRpcErrorCode.PARSE_ERROR, e);
+ }
+
+ List responses = new ArrayList<>();
+
+ // Look up all generated *Rpc classes registered in the service registry
+
+ for (var request : input) {
+ var fullMethod = request.getMethod();
+
+ // Spec: -32600 Invalid Request if the method member is missing or null
+ if (fullMethod == null) {
+ responses.add(
+ JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null));
+ continue;
+ }
+
+ try {
+ var targetService = services.get(fullMethod);
+ if (targetService != null) {
+ var result = targetService.execute(ctx, request);
+ // Spec: If the "id" is missing, it is a notification and no response is returned.
+ if (request.getId() != null) {
+ responses.add(JsonRpcResponse.success(request.getId(), result));
+ }
+ } else {
+ // Spec: -32601 Method not found
+ if (request.getId() != null) {
+ responses.add(
+ JsonRpcResponse.error(
+ request.getId(),
+ JsonRpcErrorCode.METHOD_NOT_FOUND,
+ "Method not found: " + fullMethod));
+ }
+ }
+ } catch (JsonRpcException cause) {
+ log(ctx, request, cause);
+ // Domain-specific or protocol-level exceptions (e.g., -32602 Invalid Params)
+ if (request.getId() != null) {
+ responses.add(JsonRpcResponse.error(request.getId(), cause.getCode(), cause.getCause()));
+ }
+ } catch (Exception cause) {
+ log(ctx, request, cause);
+ // Spec: -32603 Internal error for unhandled application exceptions
+ if (request.getId() != null) {
+ responses.add(
+ JsonRpcResponse.error(request.getId(), computeErrorCode(ctx, cause), cause));
+ }
+ }
+ }
+
+ // Handle the case where all requests in a batch were notifications
+ if (responses.isEmpty()) {
+ ctx.setResponseCode(StatusCode.NO_CONTENT);
+ return "";
+ }
+
+ // Spec: Return an array only if the original request was a batch
+ return input.isBatch() ? responses : responses.getFirst();
+ }
+
+ private void log(Context ctx, JsonRpcRequest request, Throwable cause) {
+ JsonRpcErrorCode code;
+ boolean hasCause = true;
+ if (cause instanceof JsonRpcException rpcException) {
+ code = rpcException.getCode();
+ hasCause = false;
+ } else {
+ code = computeErrorCode(ctx, cause);
+ }
+ var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client";
+ var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})";
+ switch (code) {
+ case INTERNAL_ERROR ->
+ log.error(
+ message,
+ type,
+ code.getCode(),
+ code.getMessage(),
+ request.getMethod(),
+ request.getId(),
+ cause);
+ case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR ->
+ log.debug(
+ message,
+ type,
+ code.getCode(),
+ code.getMessage(),
+ request.getMethod(),
+ request.getId(),
+ cause);
+ default -> {
+ if (hasCause) {
+ log.warn(
+ message,
+ type,
+ code.getCode(),
+ code.getMessage(),
+ request.getMethod(),
+ request.getId(),
+ cause);
+ } else {
+ log.debug(
+ message,
+ type,
+ code.getCode(),
+ code.getMessage(),
+ request.getMethod(),
+ request.getId());
+ }
+ }
+ }
+ }
+
+ private JsonRpcErrorCode computeErrorCode(Context ctx, Throwable cause) {
+ JsonRpcErrorCode code;
+ // Attempt to look up any user-defined exception mappings from the registry
+ Map, JsonRpcErrorCode> customMapping =
+ ctx.require(Reified.map(Class.class, JsonRpcErrorCode.class));
+ code =
+ errorCode(customMapping, cause)
+ .orElseGet(() -> JsonRpcErrorCode.of(ctx.getRouter().errorCode(cause)));
+ return code;
+ }
+
+ /**
+ * Evaluates the given exception against the registered custom exception mappings.
+ *
+ * @param mappings A map of Exception classes to specific tRPC error codes.
+ * @param x The exception to evaluate.
+ * @return An {@code Optional} containing the matched {@code TrpcErrorCode}, or empty if no match
+ * is found.
+ */
+ private Optional errorCode(
+ Map, JsonRpcErrorCode> mappings, Throwable x) {
+ for (var mapping : mappings.entrySet()) {
+ if (mapping.getKey().isInstance(x)) {
+ return Optional.of(mapping.getValue());
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcParser.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcParser.java
new file mode 100644
index 0000000000..3c557113f7
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcParser.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.lang.reflect.Type;
+
+/**
+ * The core JSON parsing SPI (Service Provider Interface) for JSON-RPC.
+ *
+ *
This factory is implemented by Jooby's JSON modules (such as Jackson or Avaje) and serves as
+ * the bridge between the parsed JSON-RPC request and the application's Java objects.
+ *
+ *
Startup Optimization:
+ *
+ *
The Jooby Annotation Processor (APT) generates highly optimized routing code that relies on
+ * this interface. At application startup, the generated routes use this parser to eagerly resolve
+ * and cache type-specific {@link JsonRpcDecoder}s. This ensures that during actual HTTP requests,
+ * arguments are deserialized with near-zero reflection overhead.
+ */
+public interface JsonRpcParser {
+
+ /**
+ * Resolves and caches a type-specific deserializer during route initialization.
+ *
+ *
This method is invoked by the APT-generated code when the application starts. By eagerly
+ * requesting a decoder for complex generic types, the framework avoids expensive reflection
+ * lookups during runtime request processing.
+ *
+ * @param type The target Java type (e.g., {@code List}, {@code User}) to decode.
+ * @param The expected Java type.
+ * @return A highly optimized decoder capable of parsing generic JSON nodes into the target type.
+ */
+ JsonRpcDecoder decoder(Type type);
+
+ /**
+ * Creates a stateful reader for extracting JSON-RPC arguments from the request parameters.
+ *
+ *
Because JSON-RPC 2.0 parameters can be either positional (a JSON Array) or named (a JSON
+ * Object), this reader manages the extraction context to hand off the correct JSON segment to the
+ * pre-resolved {@link JsonRpcDecoder}s or extract primitives.
+ *
+ * @param params The parsed parameter object (typically a List or Map depending on the underlying
+ * JSON library) from the {@link JsonRpcRequest}.
+ * @return A reader for argument extraction.
+ */
+ JsonRpcReader reader(Object params);
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.java
new file mode 100644
index 0000000000..e08d34f5b2
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.java
@@ -0,0 +1,109 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.jsonrpc;
+
+import io.jooby.exception.MissingValueException;
+
+/**
+ * A stateful reader used at runtime to extract arguments from JSON-RPC parameters.
+ *
+ *
When a JSON-RPC client sends a request, it packs the arguments into the {@code params} field
+ * as either an Array (positional) or an Object (named). This reader acts as a cursor or keyed
+ * accessor over that structure. The Jooby Annotation Processor (APT) generates routing code that
+ * calls the {@code next*()} methods on this interface sequentially, perfectly matching the
+ * parameters in the target Java method.
+ *
+ *
Zero-Boxing Performance:
+ *
+ *
To maximize throughput, this interface provides dedicated methods for extracting primitive
+ * types (e.g., {@link #nextInt(String)}, {@link #nextBoolean(String)}). This allows the underlying
+ * JSON parser to read numbers and booleans without ever boxing them into heap-allocated objects
+ * like {@code Integer} or {@code Boolean}.
+ *
+ *
Because it may hold resources (depending on the JSON library implementation), it implements
+ * {@link AutoCloseable} and must be closed after the final argument is read.
+ */
+public interface JsonRpcReader extends AutoCloseable {
+
+ /**
+ * Evaluates the next parameter to determine if it is a literal {@code null} or missing.
+ *
+ * @param name The logical name of the parameter being evaluated (used for named lookup or
+ * context).
+ * @return {@code true} if the parameter is null or missing; {@code false} otherwise.
+ */
+ boolean nextIsNull(String name);
+
+ /**
+ * Asserts that the next parameter is not null.
+ *
+ *
This is a fast-fail mechanism generated by the APT for non-nullable primitive arguments
+ * (e.g., {@code int}, {@code double}) or explicitly required parameters.
+ *
+ * @param name The logical name of the parameter.
+ * @throws MissingValueException If the parameter evaluates to null or is missing.
+ */
+ default void requireNext(String name) {
+ if (nextIsNull(name)) {
+ throw new MissingValueException(name);
+ }
+ }
+
+ /**
+ * Reads the next parameter as a primitive 32-bit integer.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted integer value.
+ */
+ int nextInt(String name);
+
+ /**
+ * Reads the next parameter as a primitive 64-bit integer.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted long value.
+ */
+ long nextLong(String name);
+
+ /**
+ * Reads the next parameter as a primitive boolean.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted boolean value.
+ */
+ boolean nextBoolean(String name);
+
+ /**
+ * Reads the next parameter as a primitive 64-bit floating-point number.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted double value.
+ */
+ double nextDouble(String name);
+
+ /**
+ * Reads the next parameter as a String.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted String value, or {@code null} if the parameter is a JSON null.
+ */
+ String nextString(String name);
+
+ /**
+ * Reads the next parameter (which may be a complex JSON object or array) and deserializes it
+ * using a pre-resolved type decoder.
+ *
+ *
This method delegates the heavy lifting of mapping arbitrary JSON structures to the
+ * underlying JSON engine via the provided {@link JsonRpcDecoder}.
+ *
+ * @param name The logical name of the parameter.
+ * @param decoder The optimized decoder created during application startup to handle this specific
+ * type.
+ * @param The target Java type.
+ * @return The fully deserialized Java object, or {@code null} if the parameter is a JSON null.
+ */
+ T nextObject(String name, JsonRpcDecoder decoder);
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java
new file mode 100644
index 0000000000..82c6e89f9b
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java
@@ -0,0 +1,149 @@
+/*
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+
+/**
+ * Represents a JSON-RPC 2.0 Request object, and simultaneously acts as an iterable container for
+ * batch requests.
+ *
+ *
Single vs. Batch Processing:
+ * To seamlessly support JSON-RPC batching without complicating the generated routing layer, this
+ * class implements {@code Iterable}.
+ *
+ *
+ *
If the payload is a single request, iterating over this object yields exactly one
+ * element (itself).
+ *
If the payload is a batch request, the underlying JSON library's custom deserializer
+ * will flag this instance as a batch and populate it with the parsed requests. Iterating over
+ * it will yield each underlying request.
+ *
+ *
+ *
This class is intentionally independent of any specific JSON library. The underlying JSON
+ * provider (like Jackson or Avaje) is responsible for deserializing the {@code params} field into a
+ * generic structure (e.g., a List or a Map) and populating the batch state.
+ */
+public class JsonRpcRequest implements Iterable {
+
+ /** A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". */
+ private String jsonrpc = "2.0";
+
+ /** A String containing the name of the method to be invoked. */
+ private String method;
+
+ /**
+ * A Structured value that holds the parameter values to be used during the invocation of the
+ * method. Can be omitted, an Array (positional), or an Object (named).
+ */
+ private Object params;
+
+ /**
+ * 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;
+
+ // --- Batch State ---
+ private boolean batch;
+ private List requests;
+
+ public JsonRpcRequest() {}
+
+ public String getJsonrpc() {
+ return jsonrpc;
+ }
+
+ public void setJsonrpc(String jsonrpc) {
+ this.jsonrpc = jsonrpc;
+ }
+
+ public String getMethod() {
+ return method;
+ }
+
+ public void setMethod(String method) {
+ this.method = method;
+ }
+
+ public Object getParams() {
+ return params;
+ }
+
+ public void setParams(Object params) {
+ this.params = params;
+ }
+
+ public Object getId() {
+ return id;
+ }
+
+ public void setId(Object id) {
+ this.id = id;
+ }
+
+ /**
+ * Identifies if this request object is acting as a container for a JSON-RPC batch array.
+ *
+ * @return {@code true} if this represents a batch, {@code false} if it is a single request.
+ */
+ public boolean isBatch() {
+ return batch;
+ }
+
+ public void setBatch(boolean batch) {
+ this.batch = batch;
+ }
+
+ /**
+ * Returns the underlying requests if this instance represents a batch.
+ *
+ * @return A list of requests, or {@code null} if this is a single request.
+ */
+ public List getRequests() {
+ return requests == null ? List.of() : requests;
+ }
+
+ /**
+ * Populates this request as a batch container.
+ *
+ * @param requests The list of requests parsed from a JSON array.
+ */
+ public void setRequests(List requests) {
+ this.requests = requests;
+ this.batch = true;
+ }
+
+ /**
+ * Adds a request to this batch container. If this is the first request added, it automatically
+ * converts this instance into a batch.
+ *
+ * @param request The JSON-RPC request to add.
+ * @return This instance for method chaining.
+ */
+ public JsonRpcRequest add(JsonRpcRequest request) {
+ if (this.requests == null) {
+ this.requests = new ArrayList<>();
+ this.batch = true;
+ }
+ this.requests.add(request);
+ return this;
+ }
+
+ @Override
+ public @NonNull Iterator iterator() {
+ if (batch) {
+ return getRequests().iterator();
+ }
+ // If it's not a batch, it iterates over itself exactly once.
+ return Collections.singletonList(this).iterator();
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java
new file mode 100644
index 0000000000..f0945a42fc
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java
@@ -0,0 +1,130 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.jsonrpc;
+
+/**
+ * Represents a JSON-RPC 2.0 Response object.
+ *
+ *
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.
+ */
+public class JsonRpcResponse {
+
+ private String jsonrpc = "2.0";
+ private Object result;
+ private ErrorDetail error;
+ private Object id;
+
+ public JsonRpcResponse() {}
+
+ private JsonRpcResponse(Object id, Object result, ErrorDetail error) {
+ this.id = id;
+ this.result = result;
+ this.error = error;
+ }
+
+ /**
+ * Creates a successful JSON-RPC response.
+ *
+ * @param id The id from the corresponding request.
+ * @param result The result of the invoked method.
+ * @return A populated JsonRpcResponse.
+ */
+ public static JsonRpcResponse success(Object id, Object result) {
+ return new JsonRpcResponse(id, result, null);
+ }
+
+ /**
+ * Creates an error JSON-RPC response.
+ *
+ * @param id The id from the corresponding request.
+ * @param code The error code.
+ * @param data Additional data about the error.
+ * @return A populated JsonRpcResponse.
+ */
+ public static JsonRpcResponse error(Object id, JsonRpcErrorCode code, Object data) {
+ return new JsonRpcResponse(
+ id, null, new ErrorDetail(code.getCode(), code.getMessage(), data(data)));
+ }
+
+ private static Object data(Object data) {
+ if (data instanceof Throwable cause) {
+ return cause.getMessage();
+ }
+ return data;
+ }
+
+ public String getJsonrpc() {
+ return jsonrpc;
+ }
+
+ public void setJsonrpc(String jsonrpc) {
+ this.jsonrpc = jsonrpc;
+ }
+
+ public Object getResult() {
+ return result;
+ }
+
+ public void setResult(Object result) {
+ this.result = result;
+ }
+
+ public ErrorDetail getError() {
+ return error;
+ }
+
+ public void setError(ErrorDetail error) {
+ this.error = error;
+ }
+
+ public Object getId() {
+ return id;
+ }
+
+ public void setId(Object id) {
+ this.id = id;
+ }
+
+ /** Represents the error object inside a JSON-RPC response. */
+ public static class ErrorDetail {
+ private int code;
+ private String message;
+ private Object data;
+
+ public ErrorDetail() {}
+
+ public ErrorDetail(int code, String message, Object data) {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public void setCode(int code) {
+ this.code = code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public Object getData() {
+ return data;
+ }
+
+ public void setData(Object data) {
+ this.data = data;
+ }
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java
new file mode 100644
index 0000000000..c538318951
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java
@@ -0,0 +1,32 @@
+/*
+ * 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.List;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jooby.Context;
+
+/**
+ * Interface for generated JSON-RPC service glue code (*Rpc classes).
+ *
+ *
This interface allows the global {@link JsonRpcModule} to coordinate multiple JSON-RPC
+ * services on a single endpoint by checking which service supports a specific method namespace.
+ */
+public interface JsonRpcService {
+
+ List getMethods();
+
+ /**
+ * Executes the requested method using the provided context and request data.
+ *
+ * @param ctx The current Jooby context.
+ * @param req The individual JSON-RPC request object.
+ * @return The result of the method invocation.
+ * @throws Exception If an error occurs during execution.
+ */
+ Object execute(@NonNull Context ctx, @NonNull JsonRpcRequest req) throws Exception;
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java
index c10b92b68f..7226483c34 100644
--- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java
@@ -24,7 +24,7 @@ public enum TrpcErrorCode {
/** Invalid routing or parameters. Mapped to HTTP 400. */
BAD_REQUEST(-32600, StatusCode.BAD_REQUEST),
- /** Invalid JSON was received by the server. Mapped to HTTP 400. */
+ /** The server received invalid JSON. Mapped to HTTP 400. */
PARSE_ERROR(-32700, StatusCode.BAD_REQUEST),
/** Internal server error. Mapped to HTTP 500. */
diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java
index a0a2f8de49..c66af74129 100644
--- a/jooby/src/main/java/module-info.java
+++ b/jooby/src/main/java/module-info.java
@@ -14,6 +14,7 @@
exports io.jooby.problem;
exports io.jooby.value;
exports io.jooby.output;
+ exports io.jooby.jsonrpc;
exports io.jooby.trpc;
uses io.jooby.Server;
diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java
index e67006a707..96d561ab84 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java
@@ -129,21 +129,71 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
try {
if (roundEnv.processingOver()) {
context.debug("Output:");
- context.getRouters().forEach(it -> context.debug(" %s.java", it.getGeneratedType()));
+ // Print all generated types for both REST and RPC
+ context
+ .getRouters()
+ .forEach(
+ it -> {
+ if (it.hasRestRoutes()) {
+ context.debug(" %s", it.getRestGeneratedType());
+ }
+ if (it.hasJsonRpcRoutes()) {
+ context.debug(" %s", it.getRpcGeneratedType());
+ }
+ });
return false;
} else {
var routeMap = buildRouteRegistry(annotations, roundEnv);
verifyBeanValidationDependency(routeMap.values());
for (var router : routeMap.values()) {
try {
+ // Track the router unconditionally so routes are available in processingOver
context.add(router);
- var sourceCode = router.toSourceCode(null);
- var sourceLocation = router.getGeneratedFilename();
- onGeneratedSource(
- router.getGeneratedType(), toJavaFileObject(sourceLocation, sourceCode));
- context.debug("router %s: %s", router.getTargetType(), router.getGeneratedType());
- router.getRoutes().forEach(it -> context.debug(" %s", it));
- writeSource(router, sourceLocation, sourceCode);
+
+ // 1. Generate Standard REST/tRPC File (e.g., MovieService_.java)
+ if (router.hasRestRoutes()) {
+ var restSource = router.getRestSourceCode(null);
+ if (restSource != null) {
+ var sourceLocation = router.getRestGeneratedFilename();
+ var generatedType = router.getRestGeneratedType();
+ onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, restSource));
+
+ context.debug("router %s: %s", router.getTargetType(), generatedType);
+ router.getRoutes().stream()
+ .filter(it -> !it.isJsonRpc())
+ .forEach(it -> context.debug(" %s", it));
+
+ writeSource(
+ router.isKt(),
+ generatedType,
+ sourceLocation,
+ restSource,
+ router.getTargetType());
+ }
+ }
+
+ // 2. Generate JSON-RPC File (e.g., MovieServiceRpc_.java)
+ if (router.hasJsonRpcRoutes()) {
+ var rpcSource = router.getRpcSourceCode(null);
+ if (rpcSource != null) {
+ var sourceLocation = router.getRpcGeneratedFilename();
+ var generatedType = router.getRpcGeneratedType();
+ onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, rpcSource));
+
+ context.debug("jsonrpc router %s: %s", router.getTargetType(), generatedType);
+ router.getRoutes().stream()
+ .filter(MvcRoute::isJsonRpc)
+ .forEach(it -> context.debug(" %s", it));
+
+ writeSource(
+ router.isKt(),
+ generatedType,
+ sourceLocation,
+ rpcSource,
+ router.getTargetType());
+ }
+ }
+
} catch (IOException cause) {
throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause);
}
@@ -157,25 +207,29 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
}
}
- private void writeSource(MvcRouter router, String sourceLocation, String sourceCode)
+ private void writeSource(
+ boolean isKt,
+ String className,
+ String sourceLocation,
+ String sourceCode,
+ Element... originatingElements)
throws IOException {
var environment = context.getProcessingEnvironment();
var filer = environment.getFiler();
- if (router.isKt()) {
+ if (isKt) {
var kapt = environment.getOptions().get("kapt.kotlin.generated");
if (kapt != null) {
var output = Paths.get(kapt, sourceLocation);
Files.createDirectories(output.getParent());
Files.writeString(output, sourceCode);
} else {
- var ktFile =
- filer.createResource(SOURCE_OUTPUT, "", sourceLocation, router.getTargetType());
+ var ktFile = filer.createResource(SOURCE_OUTPUT, "", sourceLocation, originatingElements);
try (var writer = ktFile.openWriter()) {
writer.write(sourceCode);
}
}
} else {
- var javaFIle = filer.createSourceFile(router.getGeneratedType(), router.getTargetType());
+ var javaFIle = filer.createSourceFile(className, originatingElements);
try (var writer = javaFIle.openWriter()) {
writer.write(sourceCode);
}
@@ -223,12 +277,18 @@ private Map buildRouteRegistry(
for (var element : elements) {
context.debug(" %s", element);
if (element instanceof TypeElement typeElement) {
+ // FORCE INIT: Ensures MvcRouter constructor executes our JsonRpc class-level rules
+ registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type));
buildRouteRegistry(registry, typeElement);
} else if (element instanceof ExecutableElement method) {
- buildRouteRegistry(registry, (TypeElement) method.getEnclosingElement());
+ TypeElement typeElement = (TypeElement) method.getEnclosingElement();
+ // FORCE INIT
+ registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type));
+ buildRouteRegistry(registry, typeElement);
}
}
}
+
// Remove all abstract router
var abstractTypes =
registry.entrySet().stream()
@@ -344,7 +404,7 @@ public Set getSupportedOptions() {
*
Example usage:
*
*
public void run() {
- * throw sneakyThrow(new IOException("You don't need to catch me!"));
+ * throw sneakyThrow(new IOException("You don't need to catch me!"));
* }
*
*
NB: The exception is not wrapped, ignored, swallowed, or redefined. The JVM actually does
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
index 9b4fb93bbd..f7d2026edc 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java
@@ -33,7 +33,9 @@ public enum HttpMethod implements AnnotationSupport {
List.of(
"io.jooby.annotation.Trpc",
"io.jooby.annotation.Trpc.Mutation",
- "io.jooby.annotation.Trpc.Query"));
+ "io.jooby.annotation.Trpc.Query")),
+ JSON_RPC(List.of("io.jooby.annotation.JsonRpc"));
+
private final List annotations;
HttpMethod(String... packages) {
@@ -46,13 +48,6 @@ public enum HttpMethod implements AnnotationSupport {
this.annotations = annotations;
}
- /**
- * Look at path attribute over HTTP method annotation (like io.jooby.annotation.GET) or fallback
- * to Path annotation.
- *
- * @param element Type or Method.
- * @return Path.
- */
public List path(Element element) {
var path =
annotations.stream()
@@ -64,24 +59,10 @@ public List path(Element element) {
return path.isEmpty() ? HttpPath.PATH.path(element) : path;
}
- /**
- * Look at consumes attribute over HTTP method annotation (like io.jooby.annotation.GET) or
- * fallback to Consumes annotation.
- *
- * @param element Type or Method.
- * @return Consumes media type.
- */
public List consumes(Element element) {
return mediaType(element, HttpMediaType.Consumes, "consumes"::equals);
}
- /**
- * Look at produces attribute over HTTP method annotation (like io.jooby.annotation.GET) or
- * fallback to Produces annotation.
- *
- * @param element Type or Method.
- * @return Produces media type.
- */
public List produces(Element element) {
return mediaType(element, HttpMediaType.Produces, "produces"::equals);
}
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java
index 55f77d60d1..e9a5024a5d 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java
@@ -7,7 +7,7 @@
import static io.jooby.internal.apt.AnnotationSupport.*;
import static io.jooby.internal.apt.CodeBlock.*;
-import static java.lang.System.*;
+import static java.lang.System.lineSeparator;
import static java.util.Optional.ofNullable;
import java.util.*;
@@ -33,7 +33,9 @@ public class MvcRoute {
private boolean uncheckedCast;
private final boolean hasBeanValidation;
private final Set pending = new HashSet<>();
+
private boolean isTrpc = false;
+ private boolean isJsonRpc = false;
private HttpMethod resolvedTrpcMethod = null;
public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) {
@@ -125,6 +127,10 @@ public TypeMirror getReturnTypeHandler() {
}
public List generateMapping(boolean kt) {
+ if (isJsonRpc) {
+ return Collections.emptyList();
+ }
+
List block = new ArrayList<>();
var methodName = getGeneratedName();
var returnType = getReturnType();
@@ -244,7 +250,56 @@ static String leadingSlash(String path) {
return path.charAt(0) == '/' ? path : "/" + path;
}
+ public List generateJsonRpcDispatchCase(boolean kt) {
+ var buffer = new ArrayList();
+ var paramList = new StringJoiner(", ", "(", ")");
+
+ // Check if we have any parameters that actually need to be parsed from the JSON payload.
+ // We ignore Jooby's Context and Kotlin's Continuation since they are provided by the framework.
+ boolean needsReader =
+ parameters.stream()
+ .anyMatch(
+ p -> {
+ String type = p.getType().toString();
+ return !type.equals("io.jooby.Context")
+ && !type.startsWith("kotlin.coroutines.Continuation");
+ });
+
+ if (needsReader) {
+ if (kt) {
+ buffer.add(statement(indent(8), "parser.reader(req.params).use { reader ->"));
+ } else {
+ buffer.add(statement(indent(8), "try (var reader = parser.reader(req.getParams())) {"));
+ }
+ }
+
+ // This method will now be responsible for pushing "ctx" directly to paramList
+ // for Context parameters, instead of reading them from the JSON.
+ buffer.addAll(generateRpcParameter(kt, paramList::add, true));
+
+ // Dynamically adjust indentation based on whether the reader block was opened
+ int callIndent = needsReader ? 10 : 8;
+ var call = CodeBlock.of("c.", getMethodName(), paramList.toString());
+
+ if (returnType.isVoid()) {
+ buffer.add(statement(indent(callIndent), call, semicolon(kt)));
+ buffer.add(statement(indent(callIndent), kt ? "null" : "return null", semicolon(kt)));
+ } else {
+ buffer.add(statement(indent(callIndent), kt ? call : "return " + call, semicolon(kt)));
+ }
+
+ if (needsReader) {
+ buffer.add(statement(indent(8), "}"));
+ }
+
+ return buffer;
+ }
+
public List generateHandlerCall(boolean kt) {
+ if (isJsonRpc) {
+ return Collections.emptyList();
+ }
+
var buffer = new ArrayList();
var methodName =
isTrpc
@@ -269,7 +324,6 @@ public List generateHandlerCall(boolean kt) {
var isReactiveVoid = false;
var innerReactiveType = "Object";
- // 1. Resolve Target Signature
var methodReturnTypeString = returnTypeString;
if (isTrpc) {
if (reactive != null) {
@@ -317,7 +371,6 @@ public List generateHandlerCall(boolean kt) {
")",
semicolon(kt)));
- // Calculate actual tRPC payload parameters (ignore Context and Coroutines)
long trpcPayloadCount =
parameters.stream()
.filter(
@@ -339,7 +392,7 @@ public List generateHandlerCall(boolean kt) {
").value()",
semicolon(kt)));
- if (isTuple) { // <-- Use calculated isTuple
+ if (isTuple) {
if (kt) {
buffer.add(
statement(
@@ -362,7 +415,7 @@ public List generateHandlerCall(boolean kt) {
} else {
buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt)));
- if (isTuple) { // <-- Use calculated isTuple
+ if (isTuple) {
if (kt) {
buffer.add(
statement(
@@ -399,7 +452,7 @@ public List generateHandlerCall(boolean kt) {
")) {"));
}
- buffer.addAll(generateTrpcParameter(kt, paramList::add));
+ buffer.addAll(generateRpcParameter(kt, paramList::add, false));
} else if (!isTrpc) {
for (var parameter : getParameters(true)) {
String generatedParameter = parameter.generateMapping(kt);
@@ -414,7 +467,6 @@ public List generateHandlerCall(boolean kt) {
controllerVar(kt, buffer, controllerIndent);
- // 2. Resolve Return Flow
if (returnType.isVoid()) {
String statusCode =
annotationMap.size() == 1
@@ -543,7 +595,6 @@ public List generateHandlerCall(boolean kt) {
if (isTrpc && reactive != null) {
if (isReactiveVoid) {
- // Ensure empty void streams systematically resolve into an empty TrpcResponse
var handler = reactive.handlerType();
if (handler.contains("Reactor")) {
buffer.add(
@@ -620,7 +671,7 @@ public List generateHandlerCall(boolean kt) {
buffer.add(statement(indent(2), "}"));
}
- buffer.add(statement("}", System.lineSeparator()));
+ buffer.add(statement("}", lineSeparator()));
if (uncheckedCast) {
if (kt) {
@@ -690,8 +741,13 @@ private boolean methodCallHeader(
return nullable;
}
- private List generateTrpcParameter(boolean kt, Consumer arguments) {
+ private List generateRpcParameter(
+ boolean kt, Consumer arguments, boolean isJsonRpc) {
var statements = new ArrayList();
+ var decoderInterface =
+ isJsonRpc ? "io.jooby.jsonrpc.JsonRpcDecoder" : "io.jooby.trpc.TrpcDecoder";
+ int baseIndent = isJsonRpc ? 10 : 4;
+
for (var parameter : parameters) {
var paramenterName = parameter.getName();
var type = type(kt, parameter.getType().toString());
@@ -719,7 +775,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
if (kt) {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
"val ",
paramenterName,
" = if (reader.nextIsNull(",
@@ -732,7 +788,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
} else {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
var(kt),
paramenterName,
" = reader.nextIsNull(",
@@ -747,7 +803,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
} else {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
var(kt),
paramenterName,
" = reader.",
@@ -782,7 +838,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
+ "()";
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
"val ",
paramenterName,
" = if (reader.nextIsNull(",
@@ -799,7 +855,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
var javaSuffix = isChar ? ".charAt(0)" : "";
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
var(kt),
paramenterName,
" = reader.nextIsNull(",
@@ -825,7 +881,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
+ "()";
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
var(kt),
paramenterName,
" = reader.",
@@ -841,7 +897,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
var javaSuffix = isChar ? ".charAt(0)" : "";
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
var(kt),
paramenterName,
" = ",
@@ -861,10 +917,12 @@ private List generateTrpcParameter(boolean kt, Consumer argument
if (kt) {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
"val ",
paramenterName,
- "Decoder: io.jooby.trpc.TrpcDecoder<",
+ "Decoder: ",
+ decoderInterface,
+ "<",
type,
"> = parser.decoder(",
parameter.getType().toSourceCode(kt),
@@ -873,7 +931,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
if (isNullable) {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
"val ",
paramenterName,
" = if (reader.nextIsNull(",
@@ -886,7 +944,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
} else {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
"val ",
paramenterName,
" = reader.nextObject(",
@@ -899,8 +957,9 @@ private List generateTrpcParameter(boolean kt, Consumer argument
} else {
statements.add(
statement(
- indent(4),
- "io.jooby.trpc.TrpcDecoder<",
+ indent(baseIndent),
+ decoderInterface,
+ "<",
type,
"> ",
paramenterName,
@@ -911,7 +970,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
if (isNullable) {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
parameter.getType().toString(),
" ",
paramenterName,
@@ -926,7 +985,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument
} else {
statements.add(
statement(
- indent(4),
+ indent(baseIndent),
parameter.getType().toString(),
" ",
paramenterName,
@@ -962,16 +1021,27 @@ public void setGeneratedName(String generatedName) {
}
public MvcRoute addHttpMethod(TypeElement annotation) {
- var annotationMirror =
- ofNullable(findAnnotationByName(this.method, annotation.getQualifiedName().toString()))
- .orElseThrow(() -> new IllegalArgumentException("Annotation not found: " + annotation));
+ String annotationName = annotation.getQualifiedName().toString();
+ var annotationMirror = findAnnotationByName(this.method, annotationName);
+
+ // Fallback to the class-level annotation if the method isn't explicitly annotated
+ if (annotationMirror == null) {
+ annotationMirror = findAnnotationByName(this.method.getEnclosingElement(), annotationName);
+ }
+
+ if (annotationMirror == null) {
+ throw new IllegalArgumentException("Annotation not found: " + annotation);
+ }
+
annotationMap.put(annotation, annotationMirror);
- // Eagerly flag as tRPC so equals/hashCode can differentiate hybrid methods early
- if (HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString())
- == HttpMethod.tRPC) {
+ var httpMethod = HttpMethod.findByAnnotationName(annotationName);
+ if (httpMethod == HttpMethod.tRPC) {
this.isTrpc = true;
}
+ if (httpMethod == HttpMethod.JSON_RPC) {
+ this.isJsonRpc = true;
+ }
return this;
}
@@ -1016,15 +1086,31 @@ public String getMethodName() {
return getMethod().getSimpleName().toString();
}
+ public String getJsonRpcMethodName() {
+ var annotation = AnnotationSupport.findAnnotationByName(method, "io.jooby.jsonrpc.JsonRpc");
+ if (annotation != null) {
+ var val =
+ AnnotationSupport.findAnnotationValue(annotation, VALUE).stream().findFirst().orElse("");
+ if (!val.isEmpty()) return val;
+ }
+ return getMethodName();
+ }
+
+ public boolean isJsonRpc() {
+ return isJsonRpc;
+ }
+
@Override
public int hashCode() {
- return Objects.hash(method.toString(), isTrpc);
+ return Objects.hash(method.toString(), isTrpc, isJsonRpc);
}
@Override
public boolean equals(Object obj) {
if (obj instanceof MvcRoute that) {
- return this.method.toString().equals(that.method.toString()) && this.isTrpc == that.isTrpc;
+ return this.method.toString().equals(that.method.toString())
+ && this.isTrpc == that.isTrpc
+ && this.isJsonRpc == that.isJsonRpc;
}
return false;
}
@@ -1105,7 +1191,6 @@ public boolean hasBeanValidation() {
}
private HttpMethod trpcMethod(Element element) {
- // 1. High Precedence: Explicit tRPC procedure annotations
if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Query") != null) {
return HttpMethod.GET;
}
@@ -1114,30 +1199,23 @@ private HttpMethod trpcMethod(Element element) {
return HttpMethod.POST;
}
- // 2. Base Precedence: @Trpc combined with standard HTTP annotations
var trpc = AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc");
if (trpc != null) {
if (HttpMethod.GET.matches(element)) {
return HttpMethod.GET;
}
-
- // Map all state-changing HTTP annotations to a tRPC POST mutation
if (HttpMethod.POST.matches(element)
|| HttpMethod.PUT.matches(element)
|| HttpMethod.PATCH.matches(element)
|| HttpMethod.DELETE.matches(element)) {
return HttpMethod.POST;
}
-
- // 3. Fallback: Missing HTTP Method -> Compilation Error
throw new IllegalArgumentException(
"tRPC procedure missing HTTP mapping. Method "
+ element.getSimpleName()
+ "() in "
+ element.getEnclosingElement().getSimpleName()
- + " is annotated with @Trpc but lacks a valid HTTP method annotation. Please annotate"
- + " the method with @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET, @POST,"
- + " @PUT, @PATCH, or @DELETE.");
+ + " is annotated with @Trpc but lacks a valid HTTP method annotation.");
}
return null;
}
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java
index ebbe396fe4..0788b0144f 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java
@@ -5,9 +5,9 @@
*/
package io.jooby.internal.apt;
+import static io.jooby.internal.apt.AnnotationSupport.VALUE;
import static io.jooby.internal.apt.AnnotationSupport.findAnnotationByName;
-import static io.jooby.internal.apt.CodeBlock.indent;
-import static io.jooby.internal.apt.CodeBlock.semicolon;
+import static io.jooby.internal.apt.CodeBlock.*;
import static java.util.Collections.emptyList;
import java.io.IOException;
@@ -19,7 +19,6 @@
import java.util.stream.Stream;
import javax.lang.model.element.*;
-import javax.tools.JavaFileObject;
public class MvcRouter {
private final MvcContext context;
@@ -33,6 +32,58 @@ public class MvcRouter {
public MvcRouter(MvcContext context, TypeElement clazz) {
this.context = context;
this.clazz = clazz;
+
+ // JSON-RPC Method Discovery Logic
+ var classJsonRpcAnno =
+ AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc");
+
+ List explicitlyAnnotated = new ArrayList<>();
+ List allPublicMethods = new ArrayList<>();
+
+ for (var enclosed : clazz.getEnclosedElements()) {
+ if (enclosed.getKind() == ElementKind.METHOD) {
+ var method = (ExecutableElement) enclosed;
+ var modifiers = method.getModifiers();
+
+ // Only consider public, non-static, non-abstract methods
+ if (modifiers.contains(Modifier.PUBLIC)
+ && !modifiers.contains(Modifier.STATIC)
+ && !modifiers.contains(Modifier.ABSTRACT)) {
+
+ // Ignore standard Java Object methods
+ String methodName = method.getSimpleName().toString();
+ if (methodName.equals("toString")
+ || methodName.equals("hashCode")
+ || methodName.equals("equals")
+ || methodName.equals("clone")) {
+ continue;
+ }
+
+ allPublicMethods.add(method);
+ var methodJsonRpcAnno =
+ AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc");
+ if (methodJsonRpcAnno != null) {
+ explicitlyAnnotated.add(method);
+ }
+ }
+ }
+ }
+
+ if (!explicitlyAnnotated.isEmpty()) {
+ // Rule 2: If one or more methods are explicitly annotated, ONLY expose those methods.
+ for (var method : explicitlyAnnotated) {
+ var methodAnno =
+ AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc");
+ TypeElement annoElement = (TypeElement) methodAnno.getAnnotationType().asElement();
+ put(annoElement, method);
+ }
+ } else if (classJsonRpcAnno != null) {
+ // Rule 1: Class is annotated, but no specific methods are. Expose ALL public methods.
+ var annoElement = (TypeElement) classJsonRpcAnno.getAnnotationType().asElement();
+ for (var method : allPublicMethods) {
+ put(annoElement, method);
+ }
+ }
}
public MvcRouter(TypeElement clazz, MvcRouter parent) {
@@ -57,19 +108,24 @@ public TypeElement getTargetType() {
}
public String getGeneratedType() {
- return context.generateRouterName(getTargetType().getQualifiedName().toString());
+ String baseName = getTargetType().getQualifiedName().toString();
+ String name = isJsonRpc() ? baseName + "Rpc" : baseName;
+ return context.generateRouterName(name);
}
public String getGeneratedFilename() {
- return getGeneratedType().replace('.', '/')
- + (isKt() ? ".kt" : JavaFileObject.Kind.SOURCE.extension);
+ return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java");
}
public MvcRouter put(TypeElement httpMethod, ExecutableElement route) {
var isTrpc =
HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString())
== HttpMethod.tRPC;
- var routeKey = (isTrpc ? "trpc" : "") + route.toString();
+ var isJsonRpc =
+ HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString())
+ == HttpMethod.JSON_RPC;
+
+ var routeKey = (isTrpc ? "trpc" : (isJsonRpc ? "jsonrpc" : "")) + route.toString();
var existing = routes.get(routeKey);
if (existing == null) {
@@ -99,6 +155,44 @@ public String getPackageName() {
return pkgEnd > 0 ? classname.substring(0, pkgEnd) : "";
}
+ public boolean isJsonRpc() {
+ return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc);
+ }
+
+ public String getJsonRpcNamespace() {
+ var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc");
+ if (annotation != null) {
+ return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream()
+ .findFirst()
+ .orElse("");
+ }
+ return "";
+ }
+
+ public boolean hasRestRoutes() {
+ return getRoutes().stream().anyMatch(it -> !it.isJsonRpc());
+ }
+
+ public boolean hasJsonRpcRoutes() {
+ return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc);
+ }
+
+ public String getRestGeneratedType() {
+ return context.generateRouterName(getTargetType().getQualifiedName().toString());
+ }
+
+ public String getRpcGeneratedType() {
+ return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Rpc");
+ }
+
+ public String getRestGeneratedFilename() {
+ return getRestGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java");
+ }
+
+ public String getRpcGeneratedFilename() {
+ return getRpcGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java");
+ }
+
/**
* Generate the controller extension for MVC controller:
*
@@ -109,23 +203,29 @@ public String getPackageName() {
*
* }
*
- * @return
+ * @return The source code to write, or null if the controller only contains JSON-RPC routes.
*/
- public String toSourceCode(Boolean generateKotlin) throws IOException {
+ public String getRestSourceCode(Boolean generateKotlin) throws IOException {
+ var mvcRoutes = this.routes.values().stream().filter(it -> !it.isJsonRpc()).toList();
+
+ if (mvcRoutes.isEmpty()) {
+ return null; // Safety check if called on a JSON-RPC-only controller
+ }
+
var kt = generateKotlin == Boolean.TRUE || isKt();
- var generateTypeName = context.generateRouterName(getTargetType().getSimpleName().toString());
+ var generateTypeName = getTargetType().getSimpleName().toString();
+ var generatedClass = context.generateRouterName(generateTypeName);
+
try (var in = getClass().getResourceAsStream("Source" + (kt ? ".kt" : ".java"))) {
Objects.requireNonNull(in);
- var routes = this.routes.values();
- var suspended = routes.stream().filter(MvcRoute::isSuspendFun).toList();
- var noSuspended = routes.stream().filter(it -> !it.isSuspendFun()).toList();
+ var suspended = mvcRoutes.stream().filter(MvcRoute::isSuspendFun).toList();
+ var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList();
var buffer = new StringBuilder();
context.generateStaticImports(
this,
(owner, fn) ->
buffer.append(
- CodeBlock.statement(
- "import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt))));
+ statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt))));
var imports = buffer.toString();
buffer.setLength(0);
@@ -143,13 +243,13 @@ public String toSourceCode(Boolean generateKotlin) throws IOException {
.append(System.lineSeparator());
}
if (!suspended.isEmpty()) {
- buffer.append(CodeBlock.statement(indent(6), "val kooby = app as io.jooby.kt.Kooby"));
- buffer.append(CodeBlock.statement(indent(6), "kooby.coroutine {"));
+ buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby"));
+ buffer.append(statement(indent(6), "kooby.coroutine {"));
suspended.stream()
.flatMap(it -> it.generateMapping(kt).stream())
.forEach(line -> buffer.append(CodeBlock.indent(8)).append(line));
trimr(buffer);
- buffer.append(System.lineSeparator()).append(CodeBlock.statement(indent(6), "}"));
+ buffer.append(System.lineSeparator()).append(statement(indent(6), "}"));
}
noSuspended.stream()
.flatMap(it -> it.generateMapping(kt).stream())
@@ -163,19 +263,274 @@ public String toSourceCode(Boolean generateKotlin) throws IOException {
.append(System.lineSeparator());
// end install
- routes.stream()
+ mvcRoutes.stream()
.flatMap(it -> it.generateHandlerCall(kt).stream())
.forEach(line -> buffer.append(CodeBlock.indent(4)).append(line));
+
return new String(in.readAllBytes(), StandardCharsets.UTF_8)
.replace("${packageName}", getPackageName())
.replace("${imports}", imports)
- .replace("${className}", getTargetType().getSimpleName())
- .replace("${generatedClassName}", generateTypeName)
- .replace("${constructors}", constructors(generateTypeName, kt))
+ .replace("${className}", generateTypeName)
+ .replace("${generatedClassName}", generatedClass)
+ .replace("${constructors}", constructors(generatedClass, kt))
.replace("${methods}", trimr(buffer));
}
}
+ public String getRpcSourceCode(Boolean generateKotlin) {
+ if (!hasJsonRpcRoutes()) {
+ return null;
+ }
+ return generateJsonRpcService(generateKotlin == Boolean.TRUE || isKt());
+ }
+
+ private String generateJsonRpcService(boolean kt) {
+ var buffer = new StringBuilder();
+ var generateTypeName = getTargetType().getSimpleName().toString();
+ var rpcClassName = context.generateRouterName(generateTypeName + "Rpc");
+ var namespace = getJsonRpcNamespace();
+ var packageName = getPackageName();
+
+ var rpcRoutes = getRoutes().stream().filter(MvcRoute::isJsonRpc).toList();
+
+ List fullMethods = new ArrayList<>();
+ for (MvcRoute route : rpcRoutes) {
+ String routeName = route.getJsonRpcMethodName();
+ fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName);
+ }
+
+ String methodListString =
+ fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", "));
+
+ buffer.append(statement("package ", packageName, semicolon(kt)));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement("@io.jooby.annotation.Generated(", generateTypeName, clazz(kt), ")"));
+
+ if (kt) {
+ buffer.append(
+ statement(
+ "class ", rpcClassName, " : io.jooby.jsonrpc.JsonRpcService, io.jooby.Extension {"));
+ buffer.append(
+ statement(
+ indent(2),
+ "protected lateinit var factory: (io.jooby.Context) -> ",
+ generateTypeName));
+
+ String ktConstructors = constructors(rpcClassName, true).toString().replaceAll("(?m)^ ", "");
+ buffer.append(ktConstructors);
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "constructor(instance: ", generateTypeName, ") {"));
+ buffer.append(statement(indent(4), "setup { ctx -> instance }"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2), "constructor(provider: javax.inject.Provider<", generateTypeName, ">) {"));
+ buffer.append(statement(indent(4), "setup { ctx -> provider.get() }"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2),
+ "constructor(provider: java.util.function.Function, ",
+ generateTypeName,
+ ">) {"));
+ buffer.append(
+ statement(
+ indent(4), "setup { ctx -> provider.apply(", generateTypeName, "::class.java) }"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2),
+ "private fun setup(factory: (io.jooby.Context) -> ",
+ generateTypeName,
+ ") {"));
+ buffer.append(statement(indent(4), "this.factory = factory"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "override fun install(app: io.jooby.Jooby) {"));
+ buffer.append(
+ statement(
+ indent(4),
+ "app.services.listOf(io.jooby.jsonrpc.JsonRpcService::class.java).add(this)"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "override fun getMethods(): List {"));
+ buffer.append(statement(indent(4), "return listOf(", methodListString, ")"));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "@Throws(Exception::class)"));
+ buffer.append(
+ statement(
+ indent(2),
+ "override fun execute(ctx: io.jooby.Context, req: io.jooby.jsonrpc.JsonRpcRequest):"
+ + " Any? {"));
+ buffer.append(statement(indent(4), "val c = factory(ctx)"));
+ buffer.append(statement(indent(4), "val method = req.method"));
+ buffer.append(
+ statement(
+ indent(4),
+ var(kt),
+ "parser = ctx.require(io.jooby.jsonrpc.JsonRpcParser",
+ clazz(kt),
+ ")",
+ semicolon(kt)));
+ buffer.append(statement(indent(4), "return when(method) {"));
+
+ for (int i = 0; i < rpcRoutes.size(); i++) {
+ buffer.append(statement(indent(6), "\"", fullMethods.get(i), "\" -> {"));
+ rpcRoutes.get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append);
+ buffer.append(statement(indent(6), "}"));
+ }
+
+ buffer.append(
+ statement(
+ indent(6),
+ "else -> throw"
+ + " io.jooby.jsonrpc.JsonRpcException(io.jooby.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND,"
+ + " \"Method not found: $method\")"));
+ buffer.append(statement(indent(4), "}"));
+ buffer.append(statement(indent(2), "}"));
+
+ } else {
+ buffer.append(
+ statement(
+ "public class ",
+ rpcClassName,
+ " implements io.jooby.jsonrpc.JsonRpcService, io.jooby.Extension {"));
+ buffer.append(
+ statement(
+ indent(2),
+ "protected java.util.function.Function factory",
+ semicolon(kt)));
+
+ String javaConstructors =
+ constructors(rpcClassName, false).toString().replaceAll("(?m)^ ", "");
+ buffer.append(javaConstructors);
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(indent(2), "public ", rpcClassName, "(", generateTypeName, " instance) {"));
+ buffer.append(statement(indent(4), "setup(ctx -> instance)", semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2),
+ "public ",
+ rpcClassName,
+ "(io.jooby.SneakyThrows.Supplier<",
+ generateTypeName,
+ "> provider) {"));
+ buffer.append(
+ statement(
+ indent(4), "setup(ctx -> (", generateTypeName, ") provider.get())", semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2),
+ "public ",
+ rpcClassName,
+ "(io.jooby.SneakyThrows.Function, ",
+ generateTypeName,
+ "> provider) {"));
+ buffer.append(
+ statement(
+ indent(4),
+ "setup(ctx -> provider.apply(",
+ generateTypeName,
+ ".class))",
+ semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(
+ statement(
+ indent(2),
+ "private void setup(java.util.function.Function factory) {"));
+ buffer.append(statement(indent(4), "this.factory = factory", semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "@Override"));
+ buffer.append(
+ statement(indent(2), "public void install(io.jooby.Jooby app) throws Exception {"));
+ buffer.append(
+ statement(
+ indent(4),
+ "app.getServices().listOf(io.jooby.jsonrpc.JsonRpcService.class).add(this)",
+ semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "@Override"));
+ buffer.append(statement(indent(2), "public java.util.List getMethods() {"));
+ buffer.append(
+ statement(indent(4), "return java.util.List.of(", methodListString, ")", semicolon(kt)));
+ buffer.append(statement(indent(2), "}"));
+ buffer.append(System.lineSeparator());
+
+ buffer.append(statement(indent(2), "@Override"));
+ buffer.append(
+ statement(
+ indent(2),
+ "public Object execute(io.jooby.Context ctx, io.jooby.jsonrpc.JsonRpcRequest req)"
+ + " throws Exception {"));
+ buffer.append(
+ statement(indent(4), generateTypeName, " c = factory.apply(ctx)", semicolon(kt)));
+ buffer.append(statement(indent(4), "String method = req.getMethod()", semicolon(kt)));
+ buffer.append(
+ statement(
+ indent(4),
+ var(kt),
+ "parser = ctx.require(io.jooby.jsonrpc.JsonRpcParser",
+ clazz(kt),
+ ")",
+ semicolon(kt)));
+ buffer.append(statement(indent(4), "switch(method) {"));
+
+ for (int i = 0; i < rpcRoutes.size(); i++) {
+ buffer.append(statement(indent(6), "case \"", fullMethods.get(i), "\": {"));
+ rpcRoutes.get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append);
+ buffer.append(statement(indent(6), "}"));
+ }
+
+ buffer.append(
+ statement(
+ indent(6),
+ "default: throw new"
+ + " io.jooby.jsonrpc.JsonRpcException(io.jooby.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND,"
+ + " \"Method not found: \" + method)",
+ semicolon(kt)));
+ buffer.append(statement(indent(4), "}"));
+ buffer.append(statement(indent(2), "}"));
+ }
+
+ buffer.append(statement("}"));
+
+ return buffer.toString();
+ }
+
private StringBuilder trimr(StringBuilder buffer) {
var i = buffer.length() - 1;
while (i > 0 && Character.isWhitespace(buffer.charAt(i))) {
diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java
index ab3c3005ef..15f39a62da 100644
--- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java
+++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java
@@ -79,7 +79,7 @@ protected void onGeneratedSource(String classname, JavaFileObject source) {
javaFiles.put(classname, source);
try {
// Generate kotlin source code inside the compiler scope... avoid false positive errors
- kotlinFiles.put(classname, context.getRouters().get(0).toSourceCode(true));
+ kotlinFiles.put(classname, context.getRouters().get(0).getRestSourceCode(true));
} catch (IOException e) {
SneakyThrows.propagate(e);
}
diff --git a/modules/jooby-apt/src/test/java/tests/i3864/C3864.java b/modules/jooby-apt/src/test/java/tests/i3864/C3864.java
new file mode 100644
index 0000000000..ff4c9693d4
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/i3864/C3864.java
@@ -0,0 +1,18 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.i3864;
+
+import io.jooby.Context;
+import io.jooby.annotation.JsonRpc;
+
+@JsonRpc("users")
+public class C3864 {
+
+ @JsonRpc
+ public String ping(Context ctx, int year) {
+ return null;
+ }
+}
diff --git a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java
new file mode 100644
index 0000000000..237b2e197c
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java
@@ -0,0 +1,21 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.i3864;
+
+import org.junit.jupiter.api.Test;
+
+import io.jooby.apt.ProcessorRunner;
+
+public class Issue3868 {
+ @Test
+ public void shouldGenerateJsonRpcService() throws Exception {
+ new ProcessorRunner(new C3864())
+ .withSourceCode(
+ source -> {
+ System.out.println(source);
+ });
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java
index a1e97b2ff2..6e9fb58b06 100644
--- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java
@@ -15,9 +15,10 @@
import io.avaje.jsonb.JsonView;
import io.avaje.jsonb.Jsonb;
import io.jooby.*;
-import io.jooby.internal.avaje.jsonb.AvajeTrpcParser;
-import io.jooby.internal.avaje.jsonb.AvajeTrpcResponseAdapter;
-import io.jooby.internal.avaje.jsonb.BufferedJsonOutput;
+import io.jooby.internal.avaje.jsonb.*;
+import io.jooby.jsonrpc.JsonRpcParser;
+import io.jooby.jsonrpc.JsonRpcRequest;
+import io.jooby.jsonrpc.JsonRpcResponse;
import io.jooby.output.Output;
import io.jooby.trpc.TrpcErrorCode;
import io.jooby.trpc.TrpcParser;
@@ -82,7 +83,7 @@ public AvajeJsonbModule(@NonNull Jsonb jsonb) {
/** Creates a new Avaje-JsonB module. */
public AvajeJsonbModule() {
- this(Jsonb.builder().add(TrpcResponse.class, trpcResponseAdapter()).build());
+ this(builder().build());
}
@Override
@@ -98,6 +99,8 @@ public void install(@NonNull Jooby application) throws Exception {
services
.mapOf(Class.class, TrpcErrorCode.class)
.put(JsonDataException.class, TrpcErrorCode.BAD_REQUEST);
+ // JSON-RPC
+ services.put(JsonRpcParser.class, new AvajeJsonRpcParser(jsonb));
}
@Override
@@ -156,12 +159,12 @@ private void encodeProjection(JsonWriter writer, Projected> projected) {
view.toJson(value, writer);
}
- /**
- * Custom adapter for {@link TrpcResponse}.
- *
- * @return Custom adapter for {@link TrpcResponse}.
- */
- public static Jsonb.AdapterBuilder trpcResponseAdapter() {
- return AvajeTrpcResponseAdapter::new;
+ public static Jsonb.Builder builder() {
+ var jsonb = Jsonb.builder();
+ jsonb.add(TrpcResponse.class, AvajeTrpcResponseAdapter::new);
+ jsonb.add(JsonRpcRequest.class, AvajeJsonRpcRequestAdapter::new);
+ jsonb.add(JsonRpcResponse.class, AvajeJsonRpcResponseAdapter::new);
+ jsonb.add(JsonRpcResponse.ErrorDetail.class, AvajeJsonRpcErrorAdapter::new);
+ return jsonb;
}
}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java
new file mode 100644
index 0000000000..de770b6a44
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java
@@ -0,0 +1,41 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.avaje.jsonb;
+
+import java.lang.reflect.Type;
+
+import io.avaje.jsonb.JsonType;
+import io.avaje.jsonb.Jsonb;
+import io.jooby.exception.MissingValueException;
+import io.jooby.exception.TypeMismatchException;
+import io.jooby.jsonrpc.JsonRpcDecoder;
+
+public class AvajeJsonRpcDecoder implements JsonRpcDecoder {
+
+ private final Jsonb jsonb;
+ private final Type type;
+ private final JsonType typeAdapter;
+
+ public AvajeJsonRpcDecoder(Jsonb jsonb, Type type) {
+ this.jsonb = jsonb;
+ this.type = type;
+ this.typeAdapter = jsonb.type(type);
+ }
+
+ @Override
+ public T decode(String name, Object node) {
+ try {
+ if (node == null) {
+ throw new MissingValueException(name);
+ }
+ // Convert the Map/List/primitive back to JSON, then to the target type
+ // This leverages Avaje's exact mappings without needing a tree traversal model
+ return typeAdapter.fromJson(jsonb.toJson(node));
+ } catch (Exception x) {
+ throw new TypeMismatchException(name, type, x);
+ }
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java
new file mode 100644
index 0000000000..070a226272
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java
@@ -0,0 +1,44 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.avaje.jsonb;
+
+import io.avaje.json.JsonAdapter;
+import io.avaje.json.JsonReader;
+import io.avaje.json.JsonWriter;
+import io.avaje.jsonb.Jsonb;
+import io.jooby.jsonrpc.JsonRpcResponse.ErrorDetail;
+
+public class AvajeJsonRpcErrorAdapter implements JsonAdapter {
+
+ private final Jsonb jsonb;
+
+ public AvajeJsonRpcErrorAdapter(Jsonb jsonb) {
+ this.jsonb = jsonb;
+ }
+
+ @Override
+ public void toJson(JsonWriter writer, ErrorDetail error) {
+ writer.beginObject();
+
+ writer.name("code");
+ writer.value(error.getCode());
+
+ writer.name("message");
+ writer.value(error.getMessage());
+
+ if (error.getData() != null) {
+ writer.name("data");
+ jsonb.adapter(Object.class).toJson(writer, error.getData());
+ }
+
+ writer.endObject();
+ }
+
+ @Override
+ public ErrorDetail fromJson(JsonReader reader) {
+ throw new UnsupportedOperationException("Servers don't deserialize error responses");
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java
new file mode 100644
index 0000000000..26b3053d84
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java
@@ -0,0 +1,33 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.avaje.jsonb;
+
+import java.lang.reflect.Type;
+
+import io.avaje.jsonb.Jsonb;
+import io.jooby.jsonrpc.JsonRpcDecoder;
+import io.jooby.jsonrpc.JsonRpcParser;
+import io.jooby.jsonrpc.JsonRpcReader;
+
+public class AvajeJsonRpcParser implements JsonRpcParser {
+
+ private final Jsonb jsonb;
+
+ public AvajeJsonRpcParser(Jsonb jsonb) {
+ this.jsonb = jsonb;
+ }
+
+ @Override
+ public JsonRpcDecoder decoder(Type type) {
+ return new AvajeJsonRpcDecoder<>(jsonb, type);
+ }
+
+ @Override
+ public JsonRpcReader reader(Object params) {
+ // params will be either a List (positional) or a Map (named)
+ return new AvajeJsonRpcReader(params);
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java
new file mode 100644
index 0000000000..8620eb46f3
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java
@@ -0,0 +1,108 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.avaje.jsonb;
+
+import java.util.List;
+import java.util.Map;
+
+import io.jooby.exception.MissingValueException;
+import io.jooby.exception.TypeMismatchException;
+import io.jooby.jsonrpc.JsonRpcDecoder;
+import io.jooby.jsonrpc.JsonRpcReader;
+
+public class AvajeJsonRpcReader implements JsonRpcReader {
+
+ private final Map map;
+ private final List