Skip to content

Commit e84e2f9

Browse files
authored
Merge pull request #3870 from jooby-project/3868
feat: JSON-RPC protocol engines for Jackson & Avaje
2 parents df73717 + 1879db7 commit e84e2f9

File tree

58 files changed

+3784
-431
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+3784
-431
lines changed

docs/asciidoc/json-rpc.adoc

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
==== JSON-RPC
2+
3+
Jooby provides full support for the JSON-RPC 2.0 specification, allowing you to build robust RPC APIs using standard Java and Kotlin controllers.
4+
5+
The implementation leverages Jooby's Annotation Processing Tool (APT) to generate highly optimized, reflection-free dispatchers at compile time.
6+
7+
===== Usage
8+
9+
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.
10+
11+
.JSON-RPC
12+
[source,java,role="primary"]
13+
----
14+
import io.jooby.jsonrpc.JsonRpc;
15+
16+
@JsonRpc("movies")
17+
public class MovieService {
18+
19+
public Movie getById(int id) {
20+
return database.stream()
21+
.filter(m -> m.id() == id)
22+
.findFirst()
23+
.orElseThrow(() -> new NotFoundException("Movie not found: " + id));
24+
}
25+
}
26+
----
27+
28+
.Kotlin
29+
[source,kotlin,role="secondary"]
30+
----
31+
import io.jooby.jsonrpc.JsonRpc
32+
33+
@JsonRpc("movies")
34+
class MovieService {
35+
36+
fun getById(id: Int): Movie {
37+
return database.asSequence()
38+
.filter { it.id == id }
39+
.firstOrNull() ?: throw NotFoundException("Movie not found: $id")
40+
}
41+
}
42+
----
43+
44+
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.
45+
46+
The annotation dictates how the protocol methods are named and exposed:
47+
48+
* **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.
49+
* **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.
50+
51+
**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.
52+
53+
===== Registration
54+
55+
Register the generated `JsonRpcService` in your application using the `jsonrpc` method. You must also install a supported JSON engine.
56+
57+
.JSON-RPC
58+
[source,java,role="primary"]
59+
----
60+
import io.jooby.Jooby;
61+
import io.jooby.jackson.JacksonModule;
62+
63+
public class App extends Jooby {
64+
{
65+
install(new JacksonModule()); // <1>
66+
67+
jsonrpc(new MovieServiceRpc_()); // <2>
68+
69+
// Alternatively, you can override the default path:
70+
// jsonrpc("/json-rpc", new MovieServiceRpc_());
71+
}
72+
}
73+
----
74+
75+
.Kotlin
76+
[source,kotlin,role="secondary"]
77+
----
78+
import io.jooby.kt.Kooby
79+
import io.jooby.jackson.JacksonModule
80+
81+
class App : Kooby({
82+
83+
install(JacksonModule()) // <1>
84+
85+
jsonrpc(MovieServiceRpc_()) // <2>
86+
87+
// Custom endpoint:
88+
// jsonrpc("/json-rpc", MovieServiceRpc_())
89+
})
90+
----
91+
92+
1. Install a JSON engine
93+
2. Register the generated JSON-RPC service
94+
95+
===== JSON Engine Support
96+
97+
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).
98+
99+
Supported engines include:
100+
101+
* **Jackson 2** (`JacksonModule`)
102+
* **Jackson 3** (`Jackson3Module`)
103+
* **Avaje JSON-B** (`AvajeJsonbModule`)
104+
105+
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.
106+
107+
===== Error Mapping
108+
109+
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.
110+
111+
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:
112+
113+
[source,json]
114+
----
115+
{
116+
"jsonrpc": "2.0",
117+
"error": {
118+
"code": -32004,
119+
"message": "Not found",
120+
"data": "Movie not found: 99"
121+
},
122+
"id": 1
123+
}
124+
----
125+
126+
====== Standard Protocol Errors
127+
128+
The engine handles core JSON-RPC 2.0 protocol errors automatically, returning HTTP 200 OK with the corresponding error payload:
129+
130+
* `-32700`: Parse error (Malformed JSON)
131+
* `-32600`: Invalid Request (Missing version, ID, or malformed envelope)
132+
* `-32601`: Method not found
133+
* `-32602`: Invalid params (Missing arguments, type mismatches)
134+
* `-32603`: Internal error
135+
136+
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`).
137+
138+
===== Batch Processing
139+
140+
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.
141+
142+
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.

docs/asciidoc/rpc.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
=== RPC
2+
3+
include::json-rpc.adoc[]
4+
5+
include::tRPC.adoc[]

docs/asciidoc/tRPC.adoc

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
=== tRPC
1+
==== tRPC
22

33
The tRPC module provides end-to-end type safety by integrating the https://trpc.io/[tRPC] protocol directly into Jooby.
44

55
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.
66

7-
==== Usage
7+
===== Usage
88

99
Because tRPC relies heavily on JSON serialization to communicate with the frontend client, a JSON module **must** be installed prior to the `TrpcModule`.
1010

11-
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.
11+
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.
1212

13-
[source, java]
13+
.Java
14+
[source,java,role="primary"]
1415
----
1516
import io.jooby.Jooby;
1617
import io.jooby.json.JacksonModule;
@@ -27,19 +28,35 @@ public class App extends Jooby {
2728
}
2829
----
2930

31+
.Kotlin
32+
[source,kotlin,role="secondary"]
33+
----
34+
import io.jooby.kt.Kooby
35+
import io.jooby.json.JacksonModule
36+
import io.jooby.trpc.TrpcModule
37+
38+
class App : Kooby({
39+
install(JacksonModule()) // <1>
40+
41+
install(TrpcModule()) // <2>
42+
43+
install(MovieService_()) // <3>
44+
})
45+
----
46+
3047
1. Install a supported JSON engine (Jackson or Avaje)
3148
2. Install the tRPC extension
3249
3. Register your @Trpc annotated controllers (using the APT generated route)
3350

34-
==== Writing a Service
51+
===== Writing a Service
3552

3653
You can define your procedures using explicit tRPC annotations or a hybrid approach combining tRPC with standard HTTP methods:
3754

3855
* **Explicit Annotations:** Use `@Trpc.Query` (maps to `GET`) and `@Trpc.Mutation` (maps to `POST`).
3956
* **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.
4057

41-
.MovieService
42-
[source, java]
58+
.Java
59+
[source,java,role="primary"]
4360
----
4461
import io.jooby.annotation.Trpc;
4562
import io.jooby.annotation.DELETE;
@@ -71,12 +88,45 @@ public class MovieService {
7188
}
7289
----
7390

74-
==== Build Tool Configuration
91+
.Kotlin
92+
[source,kotlin,role="secondary"]
93+
----
94+
import io.jooby.annotation.Trpc
95+
import io.jooby.annotation.DELETE
96+
97+
data class Movie(val id: Int, val title: String, val year: Int)
98+
99+
@Trpc("movies") // Defines the 'movies' namespace
100+
class MovieService {
101+
102+
// 1. Explicit tRPC Query
103+
@Trpc.Query
104+
fun getById(id: Int): Movie {
105+
return Movie(id, "Pulp Fiction", 1994)
106+
}
107+
108+
// 2. Explicit tRPC Mutation
109+
@Trpc.Mutation
110+
fun create(movie: Movie): Movie {
111+
// Save to database logic here
112+
return movie
113+
}
114+
115+
// 3. Hybrid Mutation
116+
@Trpc
117+
@DELETE
118+
fun delete(id: Int) {
119+
// Delete from database
120+
}
121+
}
122+
----
123+
124+
===== Build Configuration
75125

76126
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.
77127

78128
.pom.xml
79-
[source, xml, role = "primary", subs="verbatim,attributes"]
129+
[source,xml,role="primary",subs="verbatim,attributes"]
80130
----
81131
<plugin>
82132
<groupId>io.jooby</groupId>
@@ -96,8 +146,8 @@ To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby
96146
</plugin>
97147
----
98148

99-
.gradle.build
100-
[source, groovy, role = "secondary", subs="verbatim,attributes"]
149+
.build.gradle
150+
[source,groovy,role="secondary",subs="verbatim,attributes"]
101151
----
102152
plugins {
103153
id 'io.jooby.trpc' version "${joobyVersion}"
@@ -109,16 +159,16 @@ trpc {
109159
}
110160
----
111161

112-
==== Consuming the API (Frontend)
162+
===== Consuming the API (Frontend)
113163

114164
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:
115165

116-
[source, bash]
166+
[source,bash]
117167
----
118168
npm install @trpc/client
119169
----
120170

121-
[source, typescript]
171+
[source,typescript]
122172
----
123173
import { createTRPCProxyClient, httpLink } from '@trpc/client';
124174
import type { AppRouter } from './target/classes/trpc'; // Path to generated file
@@ -137,14 +187,15 @@ const movie = await trpc.movies.getById.query(1);
137187
console.log(`Fetched: ${movie.title} (${movie.year})`);
138188
----
139189

140-
==== Advanced Configuration
190+
===== Advanced Configuration
141191

142-
===== Custom Exception Mapping
192+
====== Custom Exception Mapping
143193
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.
144194

145195
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:
146196

147-
[source, java]
197+
.Java
198+
[source,java,role="primary"]
148199
----
149200
import io.jooby.trpc.TrpcErrorCode;
150201
@@ -158,11 +209,28 @@ import io.jooby.trpc.TrpcErrorCode;
158209
}
159210
----
160211

161-
===== Custom TypeScript Mappings
212+
.Kotlin
213+
[source,kotlin,role="secondary"]
214+
----
215+
import io.jooby.kt.Kooby
216+
import io.jooby.trpc.TrpcModule
217+
import io.jooby.trpc.TrpcErrorCode
218+
219+
class App : Kooby({
220+
install(TrpcModule())
221+
222+
// Map your custom business exception to a standard tRPC error code
223+
services.mapOf(Class::class.java, TrpcErrorCode::class.java)
224+
.put(IllegalArgumentException::class.java, TrpcErrorCode.BAD_REQUEST)
225+
.put(MovieNotFoundException::class.java, TrpcErrorCode.NOT_FOUND)
226+
})
227+
----
228+
229+
====== Custom TypeScript Mappings
162230
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:
163231

164-
**Maven:**
165-
[source, xml]
232+
.Maven
233+
[source,xml,role="primary"]
166234
----
167235
<configuration>
168236
<customTypeMappings>
@@ -172,8 +240,8 @@ Sometimes you have custom Java types (like `java.util.UUID` or `java.math.BigDec
172240
</configuration>
173241
----
174242

175-
**Gradle:**
176-
[source, groovy]
243+
.Gradle
244+
[source,groovy,role="secondary"]
177245
----
178246
trpc {
179247
customTypeMappings = [

docs/asciidoc/web.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ include::session.adoc[]
1010

1111
include::server-sent-event.adoc[]
1212

13-
include::tRPC.adoc[]
13+
include::rpc.adoc[]
1414

1515
include::websocket.adoc[]

0 commit comments

Comments
 (0)