Skip to content

Commit df73717

Browse files
authored
Merge pull request #3867 from jooby-project/3863
feature: end-to-end type safety with native tRPC (trpc.io) integration
2 parents e634a8b + ced5331 commit df73717

File tree

61 files changed

+4537
-172
lines changed

Some content is hidden

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

61 files changed

+4537
-172
lines changed

docs/asciidoc/tRPC.adoc

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
=== tRPC
2+
3+
The tRPC module provides end-to-end type safety by integrating the https://trpc.io/[tRPC] protocol directly into Jooby.
4+
5+
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.
6+
7+
==== Usage
8+
9+
Because tRPC relies heavily on JSON serialization to communicate with the frontend client, a JSON module **must** be installed prior to the `TrpcModule`.
10+
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.
12+
13+
[source, java]
14+
----
15+
import io.jooby.Jooby;
16+
import io.jooby.json.JacksonModule;
17+
import io.jooby.trpc.TrpcModule;
18+
19+
public class App extends Jooby {
20+
{
21+
install(new JacksonModule()); // <1>
22+
23+
install(new TrpcModule()); // <2>
24+
25+
install(new MovieService_()); // <3>
26+
}
27+
}
28+
----
29+
30+
1. Install a supported JSON engine (Jackson or Avaje)
31+
2. Install the tRPC extension
32+
3. Register your @Trpc annotated controllers (using the APT generated route)
33+
34+
==== Writing a Service
35+
36+
You can define your procedures using explicit tRPC annotations or a hybrid approach combining tRPC with standard HTTP methods:
37+
38+
* **Explicit Annotations:** Use `@Trpc.Query` (maps to `GET`) and `@Trpc.Mutation` (maps to `POST`).
39+
* **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.
40+
41+
.MovieService
42+
[source, java]
43+
----
44+
import io.jooby.annotation.Trpc;
45+
import io.jooby.annotation.DELETE;
46+
47+
public record Movie(int id, String title, int year) {}
48+
49+
@Trpc("movies") // Defines the 'movies' namespace
50+
public class MovieService {
51+
52+
// 1. Explicit tRPC Query
53+
@Trpc.Query
54+
public Movie getById(int id) {
55+
return new Movie(id, "Pulp Fiction", 1994);
56+
}
57+
58+
// 2. Explicit tRPC Mutation
59+
@Trpc.Mutation
60+
public Movie create(Movie movie) {
61+
// Save to database logic here
62+
return movie;
63+
}
64+
65+
// 3. Hybrid Mutation
66+
@Trpc
67+
@DELETE
68+
public void delete(int id) {
69+
// Delete from database
70+
}
71+
}
72+
----
73+
74+
==== Build Tool Configuration
75+
76+
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.
77+
78+
.pom.xml
79+
[source, xml, role = "primary", subs="verbatim,attributes"]
80+
----
81+
<plugin>
82+
<groupId>io.jooby</groupId>
83+
<artifactId>jooby-maven-plugin</artifactId>
84+
<version>${jooby.version}</version>
85+
<executions>
86+
<execution>
87+
<goals>
88+
<goal>trpc</goal>
89+
</goals>
90+
</execution>
91+
</executions>
92+
<configuration>
93+
<jsonLibrary>jackson2</jsonLibrary>
94+
<outputDir>${project.build.outputDirectory}</outputDir>
95+
</configuration>
96+
</plugin>
97+
----
98+
99+
.gradle.build
100+
[source, groovy, role = "secondary", subs="verbatim,attributes"]
101+
----
102+
plugins {
103+
id 'io.jooby.trpc' version "${joobyVersion}"
104+
}
105+
106+
trpc {
107+
// Optional settings
108+
jsonLibrary = 'jackson2'
109+
}
110+
----
111+
112+
==== Consuming the API (Frontend)
113+
114+
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:
115+
116+
[source, bash]
117+
----
118+
npm install @trpc/client
119+
----
120+
121+
[source, typescript]
122+
----
123+
import { createTRPCProxyClient, httpLink } from '@trpc/client';
124+
import type { AppRouter } from './target/classes/trpc'; // Path to generated file
125+
126+
// Initialize the strongly-typed client
127+
export const trpc = createTRPCProxyClient<AppRouter>({
128+
links: [
129+
httpLink({
130+
url: 'http://localhost:8080/trpc',
131+
}),
132+
],
133+
});
134+
135+
// 100% Type-safe! IDEs will autocomplete namespaces, inputs, and outputs.
136+
const movie = await trpc.movies.getById.query(1);
137+
console.log(`Fetched: ${movie.title} (${movie.year})`);
138+
----
139+
140+
==== Advanced Configuration
141+
142+
===== Custom Exception Mapping
143+
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.
144+
145+
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:
146+
147+
[source, java]
148+
----
149+
import io.jooby.trpc.TrpcErrorCode;
150+
151+
{
152+
install(new TrpcModule());
153+
154+
// Map your custom business exception to a standard tRPC error code
155+
getServices().mapOf(Class.class, TrpcErrorCode.class)
156+
.put(IllegalArgumentException.class, TrpcErrorCode.BAD_REQUEST)
157+
.put(MovieNotFoundException.class, TrpcErrorCode.NOT_FOUND);
158+
}
159+
----
160+
161+
===== Custom TypeScript Mappings
162+
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:
163+
164+
**Maven:**
165+
[source, xml]
166+
----
167+
<configuration>
168+
<customTypeMappings>
169+
<java.util.UUID>string</java.util.UUID>
170+
<java.math.BigDecimal>number</java.math.BigDecimal>
171+
</customTypeMappings>
172+
</configuration>
173+
----
174+
175+
**Gradle:**
176+
[source, groovy]
177+
----
178+
trpc {
179+
customTypeMappings = [
180+
'java.util.UUID': 'string',
181+
'java.math.BigDecimal': 'number'
182+
]
183+
}
184+
----

docs/asciidoc/web.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ include::session.adoc[]
1010

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

13+
include::tRPC.adoc[]
14+
1315
include::websocket.adoc[]

jooby/src/main/java/io/jooby/StatusCode.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,14 @@ public final class StatusCode {
738738
public static final StatusCode REQUEST_HEADER_FIELDS_TOO_LARGE =
739739
new StatusCode(REQUEST_HEADER_FIELDS_TOO_LARGE_CODE, "Request Header Fields Too Large");
740740

741+
/** {@code 499 The client aborted the request before completion}. */
742+
public static final int CLIENT_CLOSED_REQUEST_CODE = 499;
743+
744+
/** {@code 499 The client aborted the request before completion}. */
745+
public static final StatusCode CLIENT_CLOSED_REQUEST =
746+
new StatusCode(
747+
CLIENT_CLOSED_REQUEST_CODE, "The client aborted the request before completion");
748+
741749
// --- 5xx Server Error ---
742750

743751
/**
@@ -1025,6 +1033,7 @@ public static StatusCode valueOf(final int statusCode) {
10251033
case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED;
10261034
case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS;
10271035
case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE;
1036+
case CLIENT_CLOSED_REQUEST_CODE -> CLIENT_CLOSED_REQUEST;
10281037
case SERVER_ERROR_CODE -> SERVER_ERROR;
10291038
case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED;
10301039
case BAD_GATEWAY_CODE -> BAD_GATEWAY;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.annotation;
7+
8+
import java.lang.annotation.Documented;
9+
import java.lang.annotation.ElementType;
10+
import java.lang.annotation.Retention;
11+
import java.lang.annotation.RetentionPolicy;
12+
import java.lang.annotation.Target;
13+
14+
/**
15+
* Marks a controller class or a specific route method for tRPC TypeScript generation.
16+
*
17+
* <p>When applied to a class, it defines a namespace for the tRPC router. All tRPC-annotated
18+
* methods within the class will be grouped under this namespace in the generated TypeScript {@code
19+
* AppRouter}.
20+
*
21+
* <p><b>Defining Procedures:</b>
22+
*
23+
* <p>There are two ways to expose a method as a tRPC procedure:
24+
*
25+
* <ul>
26+
* <li><b>Explicit tRPC Annotations:</b> Use {@link Trpc.Query} for read-only operations (mapped
27+
* to HTTP GET) and {@link Trpc.Mutation} for state-changing operations (mapped to HTTP POST).
28+
* <li><b>Hybrid HTTP Annotations:</b> Combine the base {@code @Trpc} annotation with standard
29+
* HTTP annotations. A {@code @GET} annotation maps to a query, while {@code @POST},
30+
* {@code @PUT}, {@code @PATCH}, and {@code @DELETE} map to a mutation.
31+
* </ul>
32+
*
33+
* <p><b>Network Payloads:</b>
34+
*
35+
* <p>Because tRPC natively supports only a single input payload, Java methods with multiple
36+
* parameters will automatically require a JSON array (Tuple) from the frontend client. Framework
37+
* parameters like {@code io.jooby.Context} are ignored during payload calculation.
38+
*
39+
* <p><b>Example:</b>
40+
*
41+
* <pre>{@code
42+
* @Trpc("movies") // Defines the 'movies' namespace
43+
* public class MovieService {
44+
*
45+
* @Trpc.Query // Becomes 'movies.list' query
46+
* public List<Movie> list() { ... }
47+
*
48+
* @Trpc // Hybrid approach: Becomes 'movies.delete' mutation
49+
* @DELETE
50+
* public void delete(int id) { ... }
51+
* }
52+
* }</pre>
53+
*/
54+
@Target({ElementType.TYPE, ElementType.METHOD})
55+
@Retention(RetentionPolicy.RUNTIME)
56+
@Documented
57+
public @interface Trpc {
58+
59+
/**
60+
* Marks a method as a tRPC mutation.
61+
*
62+
* <p>Mutations are used for creating, updating, or deleting data. Under the hood, Jooby will
63+
* automatically expose this method as an HTTP POST route on the {@code /trpc} endpoint.
64+
*/
65+
@Target(ElementType.METHOD)
66+
@Retention(RetentionPolicy.RUNTIME)
67+
@Documented
68+
@interface Mutation {
69+
/**
70+
* Custom name for the tRPC mutation.
71+
*
72+
* <p>This overrides the generated procedure name in the TypeScript router.
73+
*
74+
* @return The custom procedure name. Empty by default, which means the generator will use the
75+
* Java method name.
76+
*/
77+
String value() default "";
78+
}
79+
80+
/**
81+
* Marks a method as a tRPC query.
82+
*
83+
* <p>Queries are strictly used for fetching data. Under the hood, Jooby will automatically expose
84+
* this method as an HTTP GET route on the {@code /trpc} endpoint.
85+
*/
86+
@Target(ElementType.METHOD)
87+
@Retention(RetentionPolicy.RUNTIME)
88+
@Documented
89+
@interface Query {
90+
/**
91+
* Custom name for the tRPC query.
92+
*
93+
* <p>This overrides the generated procedure name in the TypeScript router.
94+
*
95+
* @return The custom procedure name. Empty by default, which means the generator will use the
96+
* Java method name.
97+
*/
98+
String value() default "";
99+
}
100+
101+
/**
102+
* Custom name for the tRPC procedure or namespace.
103+
*
104+
* <p>If applied to a method, this overrides the generated procedure name. If applied to a class,
105+
* this overrides the generated namespace in the {@code AppRouter}.
106+
*
107+
* @return The custom procedure or namespace name. Empty by default, which means the generator
108+
* will use the Java method or class name.
109+
*/
110+
String value() default "";
111+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.trpc;
7+
8+
/**
9+
* A pre-resolved decoder used at runtime to deserialize tRPC network payloads into complex Java
10+
* objects.
11+
*
12+
* <p>This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling
13+
* tRPC-annotated controllers, the APT generates highly optimized routing code that resolves the
14+
* appropriate {@code TrpcDecoder} for each method argument. By pre-resolving these decoders, Jooby
15+
* efficiently parses incoming JSON payloads without incurring reflection overhead on every request.
16+
*
17+
* <p>Note: Primitive types and standard wrappers (like {@code int}, {@code String}, {@code
18+
* boolean}) are typically handled directly by the {@code TrpcReader} rather than requiring a
19+
* dedicated decoder.
20+
*
21+
* @param <T> The target Java type this decoder produces.
22+
*/
23+
public interface TrpcDecoder<T> {
24+
25+
/**
26+
* Decodes a raw byte array payload into the target Java object.
27+
*
28+
* @param name The name of the parameter being decoded (useful for error reporting or wrapping).
29+
* @param payload The raw JSON byte array received from the network.
30+
* @return The fully deserialized Java object.
31+
*/
32+
T decode(String name, byte[] payload);
33+
34+
/**
35+
* Decodes a string payload into the target Java object.
36+
*
37+
* @param name The name of the parameter being decoded (useful for error reporting or wrapping).
38+
* @param payload The JSON string received from the network.
39+
* @return The fully deserialized Java object.
40+
*/
41+
T decode(String name, String payload);
42+
}

0 commit comments

Comments
 (0)