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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions docs/asciidoc/tRPC.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
=== 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

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.

[source, java]
----
import io.jooby.Jooby;
import io.jooby.json.JacksonModule;
import io.jooby.trpc.TrpcModule;

public class App extends Jooby {
{
install(new JacksonModule()); // <1>

install(new TrpcModule()); // <2>

install(new 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

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]
----
import io.jooby.annotation.Trpc;
import io.jooby.annotation.DELETE;

public record Movie(int id, String title, int year) {}

@Trpc("movies") // Defines the 'movies' namespace
public class MovieService {

// 1. Explicit tRPC Query
@Trpc.Query
public Movie getById(int id) {
return new Movie(id, "Pulp Fiction", 1994);
}

// 2. Explicit tRPC Mutation
@Trpc.Mutation
public Movie create(Movie movie) {
// Save to database logic here
return movie;
}

// 3. Hybrid Mutation
@Trpc
@DELETE
public void delete(int id) {
// Delete from database
}
}
----

==== Build Tool 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"]
----
<plugin>
<groupId>io.jooby</groupId>
<artifactId>jooby-maven-plugin</artifactId>
<version>${jooby.version}</version>
<executions>
<execution>
<goals>
<goal>trpc</goal>
</goals>
</execution>
</executions>
<configuration>
<jsonLibrary>jackson2</jsonLibrary>
<outputDir>${project.build.outputDirectory}</outputDir>
</configuration>
</plugin>
----

.gradle.build
[source, groovy, role = "secondary", subs="verbatim,attributes"]
----
plugins {
id 'io.jooby.trpc' version "${joobyVersion}"
}

trpc {
// Optional settings
jsonLibrary = 'jackson2'
}
----

==== 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]
----
npm install @trpc/client
----

[source, typescript]
----
import { createTRPCProxyClient, httpLink } from '@trpc/client';
import type { AppRouter } from './target/classes/trpc'; // Path to generated file

// Initialize the strongly-typed client
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpLink({
url: 'http://localhost:8080/trpc',
}),
],
});

// 100% Type-safe! IDEs will autocomplete namespaces, inputs, and outputs.
const movie = await trpc.movies.getById.query(1);
console.log(`Fetched: ${movie.title} (${movie.year})`);
----

==== Advanced Configuration

===== 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]
----
import io.jooby.trpc.TrpcErrorCode;

{
install(new TrpcModule());

// Map your custom business exception to a standard tRPC error code
getServices().mapOf(Class.class, TrpcErrorCode.class)
.put(IllegalArgumentException.class, TrpcErrorCode.BAD_REQUEST)
.put(MovieNotFoundException.class, 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]
----
<configuration>
<customTypeMappings>
<java.util.UUID>string</java.util.UUID>
<java.math.BigDecimal>number</java.math.BigDecimal>
</customTypeMappings>
</configuration>
----

**Gradle:**
[source, groovy]
----
trpc {
customTypeMappings = [
'java.util.UUID': 'string',
'java.math.BigDecimal': 'number'
]
}
----
2 changes: 2 additions & 0 deletions docs/asciidoc/web.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ include::session.adoc[]

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

include::tRPC.adoc[]

include::websocket.adoc[]
9 changes: 9 additions & 0 deletions jooby/src/main/java/io/jooby/StatusCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,14 @@ public final class StatusCode {
public static final StatusCode REQUEST_HEADER_FIELDS_TOO_LARGE =
new StatusCode(REQUEST_HEADER_FIELDS_TOO_LARGE_CODE, "Request Header Fields Too Large");

/** {@code 499 The client aborted the request before completion}. */
public static final int CLIENT_CLOSED_REQUEST_CODE = 499;

/** {@code 499 The client aborted the request before completion}. */
public static final StatusCode CLIENT_CLOSED_REQUEST =
new StatusCode(
CLIENT_CLOSED_REQUEST_CODE, "The client aborted the request before completion");

// --- 5xx Server Error ---

/**
Expand Down Expand Up @@ -1025,6 +1033,7 @@ public static StatusCode valueOf(final int statusCode) {
case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED;
case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS;
case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE;
case CLIENT_CLOSED_REQUEST_CODE -> CLIENT_CLOSED_REQUEST;
case SERVER_ERROR_CODE -> SERVER_ERROR;
case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED;
case BAD_GATEWAY_CODE -> BAD_GATEWAY;
Expand Down
111 changes: 111 additions & 0 deletions jooby/src/main/java/io/jooby/annotation/Trpc.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Marks a controller class or a specific route method for tRPC TypeScript generation.
*
* <p>When applied to a class, it defines a namespace for the tRPC router. All tRPC-annotated
* methods within the class will be grouped under this namespace in the generated TypeScript {@code
* AppRouter}.
*
* <p><b>Defining Procedures:</b>
*
* <p>There are two ways to expose a method as a tRPC procedure:
*
* <ul>
* <li><b>Explicit tRPC Annotations:</b> Use {@link Trpc.Query} for read-only operations (mapped
* to HTTP GET) and {@link Trpc.Mutation} for state-changing operations (mapped to HTTP POST).
* <li><b>Hybrid HTTP Annotations:</b> Combine the base {@code @Trpc} annotation with standard
* HTTP annotations. A {@code @GET} annotation maps to a query, while {@code @POST},
* {@code @PUT}, {@code @PATCH}, and {@code @DELETE} map to a mutation.
* </ul>
*
* <p><b>Network Payloads:</b>
*
* <p>Because tRPC natively supports only a single input payload, Java methods with multiple
* parameters will automatically require a JSON array (Tuple) from the frontend client. Framework
* parameters like {@code io.jooby.Context} are ignored during payload calculation.
*
* <p><b>Example:</b>
*
* <pre>{@code
* @Trpc("movies") // Defines the 'movies' namespace
* public class MovieService {
*
* @Trpc.Query // Becomes 'movies.list' query
* public List<Movie> list() { ... }
*
* @Trpc // Hybrid approach: Becomes 'movies.delete' mutation
* @DELETE
* public void delete(int id) { ... }
* }
* }</pre>
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Trpc {

/**
* Marks a method as a tRPC mutation.
*
* <p>Mutations are used for creating, updating, or deleting data. Under the hood, Jooby will
* automatically expose this method as an HTTP POST route on the {@code /trpc} endpoint.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface Mutation {
/**
* Custom name for the tRPC mutation.
*
* <p>This overrides the generated procedure name in the TypeScript router.
*
* @return The custom procedure name. Empty by default, which means the generator will use the
* Java method name.
*/
String value() default "";
}

/**
* Marks a method as a tRPC query.
*
* <p>Queries are strictly used for fetching data. Under the hood, Jooby will automatically expose
* this method as an HTTP GET route on the {@code /trpc} endpoint.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface Query {
/**
* Custom name for the tRPC query.
*
* <p>This overrides the generated procedure name in the TypeScript router.
*
* @return The custom procedure name. Empty by default, which means the generator will use the
* Java method name.
*/
String value() default "";
}

/**
* Custom name for the tRPC procedure or namespace.
*
* <p>If applied to a method, this overrides the generated procedure name. If applied to a class,
* this overrides the generated namespace in the {@code AppRouter}.
*
* @return The custom procedure or namespace name. Empty by default, which means the generator
* will use the Java method or class name.
*/
String value() default "";
}
42 changes: 42 additions & 0 deletions jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Jooby https://jooby.io
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
* Copyright 2014 Edgar Espina
*/
package io.jooby.trpc;

/**
* A pre-resolved decoder used at runtime to deserialize tRPC network payloads into complex Java
* objects.
*
* <p>This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling
* tRPC-annotated controllers, the APT generates highly optimized routing code that resolves the
* appropriate {@code TrpcDecoder} for each method argument. By pre-resolving these decoders, Jooby
* efficiently parses incoming JSON payloads without incurring reflection overhead on every request.
*
* <p>Note: Primitive types and standard wrappers (like {@code int}, {@code String}, {@code
* boolean}) are typically handled directly by the {@code TrpcReader} rather than requiring a
* dedicated decoder.
*
* @param <T> The target Java type this decoder produces.
*/
public interface TrpcDecoder<T> {

/**
* Decodes a raw byte array payload into the target Java object.
*
* @param name The name of the parameter being decoded (useful for error reporting or wrapping).
* @param payload The raw JSON byte array received from the network.
* @return The fully deserialized Java object.
*/
T decode(String name, byte[] payload);

/**
* Decodes a string payload into the target Java object.
*
* @param name The name of the parameter being decoded (useful for error reporting or wrapping).
* @param payload The JSON string received from the network.
* @return The fully deserialized Java object.
*/
T decode(String name, String payload);
}
Loading