({
+ 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]
+----
+
+
+ string
+ number
+
+
+----
+
+**Gradle:**
+[source, groovy]
+----
+trpc {
+ customTypeMappings = [
+ 'java.util.UUID': 'string',
+ 'java.math.BigDecimal': 'number'
+ ]
+}
+----
diff --git a/docs/asciidoc/web.adoc b/docs/asciidoc/web.adoc
index 6f63aa8da1..ffd262d3ce 100644
--- a/docs/asciidoc/web.adoc
+++ b/docs/asciidoc/web.adoc
@@ -10,4 +10,6 @@ include::session.adoc[]
include::server-sent-event.adoc[]
+include::tRPC.adoc[]
+
include::websocket.adoc[]
diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java
index 0292bcf902..92195514bd 100644
--- a/jooby/src/main/java/io/jooby/StatusCode.java
+++ b/jooby/src/main/java/io/jooby/StatusCode.java
@@ -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 ---
/**
@@ -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;
diff --git a/jooby/src/main/java/io/jooby/annotation/Trpc.java b/jooby/src/main/java/io/jooby/annotation/Trpc.java
new file mode 100644
index 0000000000..a3988077ff
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/annotation/Trpc.java
@@ -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.
+ *
+ * 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}.
+ *
+ *
Defining Procedures:
+ *
+ *
There are two ways to expose a method as a tRPC procedure:
+ *
+ *
+ * Explicit tRPC Annotations: Use {@link Trpc.Query} for read-only operations (mapped
+ * to HTTP GET) and {@link Trpc.Mutation} for state-changing operations (mapped to HTTP POST).
+ * Hybrid HTTP Annotations: 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.
+ *
+ *
+ * Network Payloads:
+ *
+ *
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.
+ *
+ *
Example:
+ *
+ *
{@code
+ * @Trpc("movies") // Defines the 'movies' namespace
+ * public class MovieService {
+ *
+ * @Trpc.Query // Becomes 'movies.list' query
+ * public List list() { ... }
+ *
+ * @Trpc // Hybrid approach: Becomes 'movies.delete' mutation
+ * @DELETE
+ * public void delete(int id) { ... }
+ * }
+ * }
+ */
+@Target({ElementType.TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Trpc {
+
+ /**
+ * Marks a method as a tRPC mutation.
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
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 "";
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java
new file mode 100644
index 0000000000..43f7491813
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java
@@ -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.
+ *
+ *
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.
+ *
+ *
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 The target Java type this decoder produces.
+ */
+public interface TrpcDecoder {
+
+ /**
+ * 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);
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java
new file mode 100644
index 0000000000..c10b92b68f
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java
@@ -0,0 +1,119 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import io.jooby.StatusCode;
+
+/**
+ * Maps standard Jooby HTTP status codes to official tRPC error codes.
+ *
+ * The tRPC specification dictates that failed requests must return specific JSON-RPC style
+ * integer codes (e.g., -32600) in the JSON response payload, alongside the standard HTTP status
+ * codes. This enumeration defines the canonical mapping between Jooby's {@link StatusCode} and the
+ * expected tRPC error shape.
+ *
+ *
When an exception is thrown within a tRPC route, Jooby uses this mapping to format the error
+ * response so the frontend {@code @trpc/client} can correctly parse and reconstruct the {@code
+ * TRPCClientError}.
+ */
+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. */
+ PARSE_ERROR(-32700, StatusCode.BAD_REQUEST),
+
+ /** Internal server error. Mapped to HTTP 500. */
+ INTERNAL_SERVER_ERROR(-32603, StatusCode.SERVER_ERROR),
+
+ /** Missing or invalid authentication. Mapped to HTTP 401. */
+ UNAUTHORIZED(-32001, StatusCode.UNAUTHORIZED),
+
+ /** Authenticated user lacks required permissions. Mapped to HTTP 403. */
+ FORBIDDEN(-32003, StatusCode.FORBIDDEN),
+
+ /** The requested resource or tRPC procedure was not found. Mapped to HTTP 404. */
+ NOT_FOUND(-32004, StatusCode.NOT_FOUND),
+
+ /**
+ * The HTTP method used is not supported by the procedure (e.g., GET on a mutation). Mapped to
+ * HTTP 405.
+ */
+ METHOD_NOT_SUPPORTED(-32005, StatusCode.METHOD_NOT_ALLOWED),
+
+ /** The request took too long to process. Mapped to HTTP 408. */
+ TIMEOUT(-32008, StatusCode.REQUEST_TIMEOUT),
+
+ /** State conflict, such as a duplicate database entry. Mapped to HTTP 409. */
+ CONFLICT(-32009, StatusCode.CONFLICT),
+
+ /** The client's preconditions were not met. Mapped to HTTP 412. */
+ PRECONDITION_FAILED(-32012, StatusCode.PRECONDITION_FAILED),
+
+ /** The incoming request payload exceeds the allowed limits. Mapped to HTTP 413. */
+ PAYLOAD_TOO_LARGE(-32013, StatusCode.REQUEST_ENTITY_TOO_LARGE),
+
+ /** The payload format is valid, but the content is semantically incorrect. Mapped to HTTP 422. */
+ UNPROCESSABLE_CONTENT(-32022, StatusCode.UNPROCESSABLE_ENTITY),
+
+ /** Rate limiting applied. Mapped to HTTP 429. */
+ TOO_MANY_REQUESTS(-32029, StatusCode.TOO_MANY_REQUESTS),
+
+ /** The client disconnected before the server could respond. Mapped to HTTP 499. */
+ CLIENT_CLOSED_REQUEST(-32099, StatusCode.CLIENT_CLOSED_REQUEST);
+
+ private final int rpcCode;
+ private final StatusCode statusCode;
+
+ /**
+ * Defines a tRPC error code mapping.
+ *
+ * @param rpcCode The JSON-RPC 2.0 compatible integer code specified by tRPC.
+ * @param statusCode The HTTP status code to return in the response headers.
+ */
+ TrpcErrorCode(int rpcCode, StatusCode statusCode) {
+ this.rpcCode = rpcCode;
+ this.statusCode = statusCode;
+ }
+
+ /**
+ * Retrieves the JSON-RPC style integer code.
+ *
+ * @return The tRPC integer code (e.g., -32600).
+ */
+ public int getRpcCode() {
+ return rpcCode;
+ }
+
+ /**
+ * Retrieves the corresponding HTTP status code.
+ *
+ * @return The Jooby {@link StatusCode}.
+ */
+ public StatusCode getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Resolves the closest tRPC 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_SERVER_ERROR}.
+ *
+ * @param status The Jooby HTTP status code.
+ * @return The corresponding {@code TrpcErrorCode}, or {@code INTERNAL_SERVER_ERROR} if no match
+ * exists.
+ */
+ public static TrpcErrorCode of(StatusCode status) {
+ for (var code : values()) {
+ if (code.statusCode.value() == status.value()) {
+ return code;
+ }
+ }
+ return INTERNAL_SERVER_ERROR; // Fallback
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java
new file mode 100644
index 0000000000..aef5d3124b
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java
@@ -0,0 +1,86 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import java.util.Map;
+import java.util.Optional;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jooby.Context;
+import io.jooby.ErrorHandler;
+import io.jooby.Reified;
+import io.jooby.StatusCode;
+
+/**
+ * A specialized error handler that formats exceptions into tRPC-compliant JSON responses.
+ *
+ *
This handler strictly listens for requests where the path begins with {@code /trpc/}. When an
+ * exception occurs during a tRPC procedure, this handler ensures the response matches the exact
+ * JSON envelope expected by the frontend {@code @trpc/client}, preventing the client from crashing
+ * due to unexpected HTML or plain-text error pages.
+ *
+ *
Custom Exception Mapping:
+ *
+ *
By default, this handler translates standard Jooby {@link StatusCode}s into their
+ * corresponding {@link TrpcErrorCode}. However, you can register a custom {@code Map,
+ * TrpcErrorCode>} in the Jooby registry to map your own domain-specific exceptions directly to tRPC
+ * codes.
+ *
+ * If the thrown exception is already a {@link TrpcException}, it is rendered directly without
+ * modification.
+ */
+public class TrpcErrorHandler implements ErrorHandler {
+
+ /**
+ * Applies the tRPC error formatting if the request is a tRPC call.
+ *
+ * @param ctx The current routing context.
+ * @param cause The exception that was thrown during the request lifecycle.
+ * @param code The default HTTP status code resolved by Jooby.
+ */
+ @Override
+ public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) {
+ if (ctx.getRequestPath().startsWith("/trpc/")) {
+ TrpcException trpcError;
+ if (cause instanceof TrpcException) {
+ trpcError = (TrpcException) cause;
+ } else {
+ // Attempt to look up any user-defined exception mappings from the registry
+ Map, TrpcErrorCode> customMapping =
+ ctx.require(Reified.map(Class.class, TrpcErrorCode.class));
+
+ // Extract the target procedure name from the URL path
+ var procedure = ctx.getRequestPath().replace("/trpc/", "");
+
+ // Build the tRPC exception, falling back to the default HTTP status mapping if no custom
+ // map matches
+ trpcError =
+ new TrpcException(
+ procedure, errorCode(customMapping, cause).orElse(TrpcErrorCode.of(code)), cause);
+ }
+
+ // Render the response using the exact structure expected by the @trpc/client
+ ctx.setResponseCode(trpcError.getStatusCode()).render(trpcError.toMap());
+ }
+ }
+
+ /**
+ * 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, TrpcErrorCode> 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/trpc/TrpcException.java b/jooby/src/main/java/io/jooby/trpc/TrpcException.java
new file mode 100644
index 0000000000..592805bb64
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcException.java
@@ -0,0 +1,134 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import io.jooby.StatusCode;
+
+/**
+ * A specialized runtime exception that encapsulates a tRPC error.
+ *
+ * This exception is thrown when a tRPC procedure fails, either due to framework-level issues
+ * (like invalid JSON parsing or missing arguments) or user-thrown domain errors. It holds all the
+ * necessary context—the procedure name, the specific {@link TrpcErrorCode}, and the underlying
+ * cause—required to construct a compliant tRPC error response.
+ *
+ *
The frontend {@code @trpc/client} relies on a very specific JSON envelope to correctly
+ * reconstruct the {@code TRPCClientError} on the client side. The {@link #toMap()} method handles
+ * translating this Java exception into that exact nested map structure.
+ */
+public class TrpcException extends RuntimeException {
+ private final String procedure;
+ private final TrpcErrorCode errorCode;
+
+ /**
+ * Constructs a new tRPC exception using a standard Jooby HTTP status code.
+ *
+ * @param procedure The name of the tRPC procedure that failed (e.g., "movies.getById").
+ * @param code The Jooby HTTP status code, which will be mapped to its closest tRPC equivalent.
+ * @param cause The underlying exception that caused this failure.
+ */
+ public TrpcException(String procedure, StatusCode code, Throwable cause) {
+ this(procedure, TrpcErrorCode.of(code), cause);
+ }
+
+ /**
+ * Constructs a new tRPC exception using a specific tRPC error code.
+ *
+ * @param procedure The name of the tRPC procedure that failed.
+ * @param code The explicit tRPC error code.
+ * @param cause The underlying exception that caused this failure.
+ */
+ public TrpcException(String procedure, TrpcErrorCode code, Throwable cause) {
+ super(procedure + ": " + code.name(), cause);
+ this.procedure = procedure;
+ this.errorCode = code;
+ }
+
+ /**
+ * Constructs a new tRPC exception without an underlying cause.
+ *
+ * @param procedure The name of the tRPC procedure that failed.
+ * @param code The explicit tRPC error code.
+ */
+ public TrpcException(String procedure, TrpcErrorCode code) {
+ super(procedure + ": " + code.name());
+ this.procedure = procedure;
+ this.errorCode = code;
+ }
+
+ /**
+ * Constructs a new tRPC exception using a standard Jooby HTTP status code without an underlying
+ * cause.
+ *
+ * @param procedure The name of the tRPC procedure that failed.
+ * @param code The Jooby HTTP status code.
+ */
+ public TrpcException(String procedure, StatusCode code) {
+ this(procedure, TrpcErrorCode.of(code));
+ }
+
+ /**
+ * Gets the HTTP status code associated with this error.
+ *
+ * @return The Jooby {@link StatusCode} to be set in the HTTP response headers.
+ */
+ public StatusCode getStatusCode() {
+ return errorCode.getStatusCode();
+ }
+
+ /**
+ * Gets the name of the tRPC procedure that threw this exception.
+ *
+ * @return The procedure name (e.g., "movies.getById").
+ */
+ public String getProcedure() {
+ return procedure;
+ }
+
+ /**
+ * Serializes the exception into the exact JSON structure expected by the tRPC protocol.
+ *
+ *
The resulting map translates into the following JSON shape:
+ *
+ *
{@code
+ * {
+ * "error": {
+ * "message": "The descriptive error message",
+ * "code": -32600,
+ * "data": {
+ * "code": "BAD_REQUEST",
+ * "httpStatus": 400,
+ * "path": "movies.getById"
+ * }
+ * }
+ * }
+ * }
+ *
+ * @return A nested map representing the JSON error envelope.
+ */
+ public Map toMap() {
+ Map data = new LinkedHashMap<>();
+ data.put("code", errorCode.name());
+ data.put("httpStatus", errorCode.getStatusCode().value());
+ data.put("path", procedure);
+
+ Map error = new LinkedHashMap<>();
+ error.put(
+ "message",
+ Optional.ofNullable(getCause())
+ .map(Throwable::getMessage)
+ .filter(Objects::nonNull)
+ .orElse(errorCode.name()));
+ error.put("code", errorCode.getRpcCode());
+ error.put("data", data);
+ return Map.of("error", error);
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java
new file mode 100644
index 0000000000..f3877861b0
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java
@@ -0,0 +1,73 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jooby.Extension;
+import io.jooby.Jooby;
+
+/**
+ * Jooby extension that enables tRPC support for the application.
+ *
+ * This module is responsible for bootstrapping the required tRPC infrastructure, specifically
+ * the specialized error handling and the parameter parsing mechanisms needed to handle tRPC network
+ * payloads.
+ *
+ *
Prerequisites:
+ *
+ *
Because tRPC relies heavily on JSON serialization, a JSON module (such as {@code
+ * JacksonModule}, or {@code AvajeJsonbModule}) must be installed before installing this
+ * module. The JSON module automatically registers the underlying {@link TrpcParser} that this
+ * extension requires.
+ *
+ *
Usage:
+ *
+ *
{@code
+ * {
+ * // 1. Install a JSON engine (Prerequisite)
+ * install(new JacksonModule());
+ *
+ * // 2. Install the tRPC extension
+ * install(new TrpcModule());
+ *
+ * // 3. Register your @Trpc annotated controllers
+ * mvc(new MovieService_());
+ * }
+ * }
+ */
+public class TrpcModule implements Extension {
+
+ /**
+ * Installs the tRPC extension into the Jooby application.
+ *
+ * During installation, this method performs the following setup:
+ *
+ *
+ * Validates that a {@link TrpcParser} is available in the service registry.
+ * Initializes the registry map for custom {@code Class} to {@link TrpcErrorCode} mappings,
+ * allowing developers to map domain exceptions to specific tRPC errors.
+ * Registers the {@link TrpcErrorHandler} globally to intercept and correctly format
+ * exceptions thrown from {@code /trpc/*} endpoints.
+ *
+ *
+ * @param app The current Jooby application.
+ * @throws Exception If a required service (such as the {@code TrpcParser}) is missing from the
+ * registry.
+ */
+ @Override
+ public void install(@NonNull Jooby app) throws Exception {
+ var services = app.getServices();
+
+ // Ensure a JSON module has provided the necessary parser
+ services.require(TrpcParser.class);
+
+ // Initialize the custom exception mapping registry
+ services.mapOf(Class.class, TrpcErrorCode.class);
+
+ // Register the specialized JSON-RPC error formatter
+ app.error(new TrpcErrorHandler());
+ }
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java
new file mode 100644
index 0000000000..757c9c21f3
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java
@@ -0,0 +1,66 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import java.lang.reflect.Type;
+
+/**
+ * The core JSON parsing SPI (Service Provider Interface) for tRPC.
+ *
+ * This factory is implemented by Jooby's JSON modules (such as Jackson, Gson, JSON-B, or Avaje)
+ * and serves as the bridge between incoming tRPC network payloads 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 TrpcDecoder}s. This ensures that during actual HTTP requests,
+ * payloads are deserialized with near-zero reflection overhead.
+ */
+public interface TrpcParser {
+
+ /**
+ * 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 network payloads into the target type.
+ */
+ TrpcDecoder decoder(Type type);
+
+ /**
+ * Creates a stateful, sequential reader for extracting tRPC arguments from a raw byte payload.
+ *
+ * Because tRPC network payloads can either be a single value or a JSON array (tuple) of
+ * multiple arguments, this reader manages the cursor position to sequentially hand off the
+ * correct JSON segment to the pre-resolved {@link TrpcDecoder}s.
+ *
+ * @param payload The raw JSON byte array received from the network.
+ * @param isTuple {@code true} if the payload is a JSON array representing multiple arguments;
+ * {@code false} if it represents a single, standalone argument.
+ * @return A reader for sequential argument extraction.
+ */
+ TrpcReader reader(byte[] payload, boolean isTuple);
+
+ /**
+ * Creates a stateful, sequential reader for extracting tRPC arguments from a string payload.
+ *
+ *
Because tRPC network payloads can either be a single value or a JSON array (tuple) of
+ * multiple arguments, this reader manages the cursor position to sequentially hand off the
+ * correct JSON segment to the pre-resolved {@link TrpcDecoder}s.
+ *
+ * @param payload The JSON string received from the network.
+ * @param isTuple {@code true} if the payload is a JSON array representing multiple arguments;
+ * {@code false} if it represents a single, standalone argument.
+ * @return A reader for sequential argument extraction.
+ */
+ TrpcReader reader(String payload, boolean isTuple);
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java
new file mode 100644
index 0000000000..a2a1646a00
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcReader.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.trpc;
+
+import io.jooby.exception.MissingValueException;
+
+/**
+ * A stateful, sequential reader used at runtime to extract arguments from a tRPC network payload.
+ *
+ *
When a tRPC client sends a request for a multi-argument method, it packs the arguments into a
+ * JSON array (a tuple). This reader acts as a cursor over that array. The Jooby Annotation
+ * Processor (APT) generates routing code that calls the {@code next*()} methods on this interface
+ * sequentially, perfectly matching the order of 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 (like Jackson or AvajeJsonb) to read numbers and booleans directly off the byte
+ * stream without ever boxing them into heap-allocated objects like {@code Integer} or {@code
+ * Boolean}.
+ *
+ *
Because it holds resources (like an open {@code JsonParser}), it implements {@link
+ * AutoCloseable} and must be closed after the final argument is read.
+ */
+public interface TrpcReader extends AutoCloseable {
+
+ /**
+ * Peeks at the next token in the JSON stream to determine if it is a literal {@code null}.
+ *
+ * @param name The logical name of the parameter being evaluated (used for context or error
+ * reporting).
+ * @return {@code true} if the next token is null; {@code false} otherwise.
+ */
+ boolean nextIsNull(String name);
+
+ /**
+ * Asserts that the next token in the sequence 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 next token evaluates to null.
+ */
+ default void requireNext(String name) {
+ if (nextIsNull(name)) {
+ throw new MissingValueException(name);
+ }
+ }
+
+ /**
+ * Reads the next token in the stream 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 token in the stream 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 token in the stream as a primitive boolean.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted boolean value.
+ */
+ boolean nextBoolean(String name);
+
+ /**
+ * Reads the next token in the stream 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 token in the stream as a String.
+ *
+ * @param name The logical name of the parameter.
+ * @return The extracted String value, or {@code null} if the token is a JSON null.
+ */
+ String nextString(String name);
+
+ /**
+ * Reads the next token (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 (Jackson, Gson, etc.) via the provided {@link TrpcDecoder}.
+ *
+ * @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 token is a JSON null.
+ */
+ T nextObject(String name, TrpcDecoder decoder);
+}
diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java
new file mode 100644
index 0000000000..5bfcc9938f
--- /dev/null
+++ b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java
@@ -0,0 +1,52 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+/**
+ * A standardized envelope for successful tRPC responses.
+ *
+ * Unlike standard REST endpoints which might return raw JSON objects or arrays directly, the
+ * tRPC protocol requires all successful procedure calls to wrap their actual payload inside a
+ * specific JSON envelope (specifically, a {@code data} property).
+ *
+ *
This immutable record ensures that the Jooby routing engine serializes the returned Java
+ * objects into the exact shape expected by the frontend {@code @trpc/client}, preventing parsing
+ * errors on the browser side.
+ *
+ * @param The type of the underlying data being returned.
+ * @param data The actual payload to serialize to the client, or {@code null} if the procedure has
+ * no return value.
+ */
+public record TrpcResponse(@Nullable T data) {
+
+ /**
+ * Wraps a non-null payload into a compliant tRPC success envelope.
+ *
+ * @param data The actual data to return to the client (e.g., a specific {@code Movie} or {@code
+ * List}).
+ * @param The type of the data.
+ * @return A tRPC response envelope containing the provided data.
+ */
+ public static @NonNull TrpcResponse of(@NonNull T data) {
+ return new TrpcResponse<>(data);
+ }
+
+ /**
+ * Creates an empty tRPC success envelope.
+ *
+ * This is typically used by the Jooby routing engine to construct compliant network responses
+ * for procedures that return {@code void} or explicitly return no data.
+ *
+ * @param The inferred type (usually {@code Void} or {@code Object}).
+ * @return A tRPC response envelope where the data property is explicitly null.
+ */
+ public static @NonNull TrpcResponse empty() {
+ return new TrpcResponse<>(null);
+ }
+}
diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java
index d8382ec3b5..a0a2f8de49 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.trpc;
uses io.jooby.Server;
uses io.jooby.SslProvider;
diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml
index 8481f2a0b5..43c163db69 100644
--- a/modules/jooby-apt/pom.xml
+++ b/modules/jooby-apt/pom.xml
@@ -25,6 +25,13 @@
test
+
+ io.jooby
+ jooby-jackson3
+ ${jooby.version}
+ test
+
+
jakarta.validation
jakarta.validation-api
@@ -76,6 +83,7 @@
test
+
io.jooby
jooby-test
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 5beba9abd5..e67006a707 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
@@ -297,7 +297,7 @@ private void buildRouteRegistry(Map registry, TypeElemen
}
});
if (!currentType.equals(superType)) {
- // edge-case #1: when controller has no method and extends another class which has.
+ // edge-case #1: when a controller has no method and extends another class which has.
// edge-case #2: some odd usage a controller could be empty.
// See https://github.com/jooby-project/jooby/issues/3656
if (registry.containsKey(superType)) {
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java
index 86e2c43d85..3fa2a04857 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java
@@ -35,6 +35,10 @@ public static CharSequence semicolon(boolean kt) {
return kt ? "" : ";";
}
+ public static CharSequence var(boolean kt) {
+ return kt ? "val " : "var ";
+ }
+
public static String indent(int count) {
return " ".repeat(count);
}
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 4481b567a3..9b4fb93bbd 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
@@ -27,7 +27,13 @@ public enum HttpMethod implements AnnotationSupport {
OPTIONS,
PATCH,
POST,
- PUT;
+ PUT,
+ // Special
+ tRPC(
+ List.of(
+ "io.jooby.annotation.Trpc",
+ "io.jooby.annotation.Trpc.Mutation",
+ "io.jooby.annotation.Trpc.Query"));
private final List annotations;
HttpMethod(String... packages) {
@@ -36,6 +42,10 @@ public enum HttpMethod implements AnnotationSupport {
this.annotations = packageList.stream().map(it -> it + "." + name()).toList();
}
+ HttpMethod(List annotations) {
+ this.annotations = annotations;
+ }
+
/**
* Look at path attribute over HTTP method annotation (like io.jooby.annotation.GET) or fallback
* to Path annotation.
@@ -76,6 +86,11 @@ public List produces(Element element) {
return mediaType(element, HttpMediaType.Produces, "produces"::equals);
}
+ public boolean matches(Element element) {
+ return annotations.stream()
+ .anyMatch(it -> AnnotationSupport.findAnnotationByName(element, it) != null);
+ }
+
private List mediaType(
Element element, HttpMediaType mediaType, Predicate filter) {
var path =
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java
index 60e285d9e7..1be305048a 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java
@@ -32,10 +32,14 @@ public List getAnnotations() {
* @return Path or empty list.
*/
public List path(Collection hierarchy) {
+ return path(hierarchy, getAnnotations());
+ }
+
+ private List path(Collection hierarchy, List annotations) {
var prefix = Collections.emptyList();
var it = hierarchy.iterator();
while (prefix.isEmpty() && it.hasNext()) {
- prefix = path(it.next());
+ prefix = path(it.next(), annotations);
}
return prefix;
}
@@ -47,7 +51,17 @@ public List path(Collection hierarchy) {
* @return Path or empty list.
*/
public List path(Element element) {
- return getAnnotations().stream()
+ return path(element, getAnnotations());
+ }
+
+ /**
+ * Find Path from method or class.
+ *
+ * @param element Method or Class.
+ * @return Path or empty list.
+ */
+ private List path(Element element, List annotations) {
+ return annotations.stream()
.map(it -> AnnotationSupport.findAnnotationByName(element, it))
.filter(Objects::nonNull)
.findFirst()
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java
index 6a4a441a9c..3a19fd61f9 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java
@@ -127,6 +127,10 @@ public boolean nonBlocking(TypeMirror returnType) {
return entry != null;
}
+ public ReactiveType getReactiveType(TypeMirror type) {
+ return findMappingHandler(type);
+ }
+
private ReactiveType findMappingHandler(TypeMirror type) {
for (var e : reactiveTypeMap.entrySet()) {
var that = e.getKey();
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java
index 107f177516..6e8038a90d 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java
@@ -37,6 +37,10 @@ public TypeDefinition getType() {
return type;
}
+ public String getName() {
+ return parameter.getSimpleName().toString();
+ }
+
public String generateMapping(boolean kt) {
var strategy =
annotations.entrySet().stream()
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 b50423de28..55f77d60d1 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,10 +7,11 @@
import static io.jooby.internal.apt.AnnotationSupport.*;
import static io.jooby.internal.apt.CodeBlock.*;
-import static java.lang.System.lineSeparator;
+import static java.lang.System.*;
import static java.util.Optional.ofNullable;
import java.util.*;
+import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -31,6 +32,9 @@ public class MvcRoute {
private final boolean suspendFun;
private boolean uncheckedCast;
private final boolean hasBeanValidation;
+ private final Set pending = new HashSet<>();
+ private boolean isTrpc = false;
+ private HttpMethod resolvedTrpcMethod = null;
public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) {
this.context = context;
@@ -72,7 +76,6 @@ public String getProjection() {
.findFirst()
.orElse(null);
}
- // look inside the method annotation
var httpMethod = annotationMap.values().iterator().next();
var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals);
return projection.stream().findFirst().orElse(null);
@@ -86,7 +89,6 @@ public boolean isProjection() {
if (isProjection) {
return true;
}
- // look inside the method annotation
var httpMethod = annotationMap.values().iterator().next();
var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals);
return !projection.isEmpty();
@@ -138,45 +140,62 @@ public List generateMapping(boolean kt) {
+ context.generateRouterName(router.getTargetType().getSimpleName().toString())
+ "::"
: "this::";
+
for (var e : entries) {
var lastHttpMethod = lastRoute && entries.get(entries.size() - 1).equals(e);
var annotation = e.getKey();
var httpMethod = HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString());
+ var dslMethod = annotation.getSimpleName().toString().toLowerCase();
var paths = context.path(router.getTargetType(), method, annotation);
+ var targetMethod = methodName;
+
+ if (httpMethod == HttpMethod.tRPC) {
+ resolvedTrpcMethod = trpcMethod(method);
+ if (resolvedTrpcMethod == null) {
+ throw new IllegalArgumentException(
+ "tRPC method not found: "
+ + method.getSimpleName()
+ + "() in "
+ + router.getTargetType());
+ }
+ dslMethod = resolvedTrpcMethod.name().toLowerCase();
+ paths = List.of(trpcPath(method));
+ targetMethod =
+ "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1);
+ this.isTrpc = true;
+ }
+
+ pending.add(targetMethod);
+
for (var path : paths) {
- var lastLine = lastHttpMethod && paths.get(paths.size() - 1).equals(path);
+ var lastLine = lastHttpMethod && paths.getLast().equals(path);
block.add(javadocLink);
block.add(
statement(
isSuspendFun() ? "" : "app.",
- annotation.getSimpleName().toString().toLowerCase(),
+ dslMethod,
"(",
string(leadingSlash(path)),
", ",
context.pipeline(
- getReturnTypeHandler(), methodReference(kt, thisRef, methodName))));
+ getReturnTypeHandler(), methodReference(kt, thisRef, targetMethod))));
if (context.nonBlocking(getReturnTypeHandler()) || isSuspendFun()) {
block.add(statement(indent(2), ".setNonBlocking(true)"));
}
- /* consumes */
mediaType(httpMethod::consumes)
.ifPresent(consumes -> block.add(statement(indent(2), ".setConsumes(", consumes, ")")));
- /* produces */
mediaType(httpMethod::produces)
.ifPresent(produces -> block.add(statement(indent(2), ".setProduces(", produces, ")")));
- /* dispatch */
dispatch()
.ifPresent(
dispatch ->
block.add(statement(indent(2), ".setExecutorKey(", string(dispatch), ")")));
- /* attributes */
attributeGenerator
.toSourceCode(kt, this, 2)
.ifPresent(
attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")")));
var lineSep = lastLine ? lineSeparator() : lineSeparator() + lineSeparator();
if (context.generateMvcMethod()) {
- /* mvcMethod */
block.add(
CodeBlock.of(
indent(2),
@@ -218,12 +237,6 @@ private String methodReference(boolean kt, String thisRef, String methodName) {
return thisRef + methodName + ")";
}
- /**
- * Ensure path start with a /(leading slash).
- *
- * @param path Path to process.
- * @return Path with leading slash.
- */
static String leadingSlash(String path) {
if (path == null || path.isEmpty() || path.equals("/")) {
return "/";
@@ -233,30 +246,402 @@ static String leadingSlash(String path) {
public List generateHandlerCall(boolean kt) {
var buffer = new ArrayList();
- /* Parameters */
- var paramList = new StringJoiner(", ", "(", ")");
- for (var parameter : getParameters(true)) {
- String generatedParameter = parameter.generateMapping(kt);
- if (parameter.isRequireBeanValidation()) {
- generatedParameter =
- CodeBlock.of(
- "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")");
+ var methodName =
+ isTrpc
+ ? "trpc"
+ + getGeneratedName().substring(0, 1).toUpperCase()
+ + getGeneratedName().substring(1)
+ : getGeneratedName();
+
+ if (pending.contains(methodName)) {
+ var paramList = new StringJoiner(", ", "(", ")");
+ var returnTypeGenerics =
+ getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR));
+ var returnTypeString = type(kt, getReturnType().toString());
+ var customReturnType = getReturnType();
+
+ if (customReturnType.isProjection()) {
+ returnTypeGenerics = "";
+ returnTypeString = Types.PROJECTED + "<" + returnType + ">";
}
- paramList.add(generatedParameter);
- }
- var throwsException = !method.getThrownTypes().isEmpty();
- var returnTypeGenerics =
- getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR));
- var returnTypeString = type(kt, getReturnType().toString());
- var customReturnType = getReturnType();
- if (customReturnType.isProjection()) {
- // Override for projection
- returnTypeGenerics = "";
- returnTypeString = Types.PROJECTED + "<" + returnType + ">";
+ var reactive = isTrpc ? context.getReactiveType(returnType.getRawType()) : null;
+ var isReactiveVoid = false;
+ var innerReactiveType = "Object";
+
+ // 1. Resolve Target Signature
+ var methodReturnTypeString = returnTypeString;
+ if (isTrpc) {
+ if (reactive != null) {
+ var rawReactiveType = type(kt, returnType.getRawType().toString());
+ if (!returnType.getArguments().isEmpty()) {
+ innerReactiveType = type(kt, returnType.getArguments().get(0).getRawType().toString());
+ if (innerReactiveType.equals("java.lang.Void") || innerReactiveType.equals("Void")) {
+ isReactiveVoid = true;
+ innerReactiveType = kt ? "Unit" : "Void";
+ }
+ } else if (rawReactiveType.contains("Completable")) {
+ isReactiveVoid = true;
+ innerReactiveType = kt ? "Unit" : "Void";
+ }
+ methodReturnTypeString =
+ rawReactiveType + ">";
+ } else {
+ methodReturnTypeString =
+ "io.jooby.trpc.TrpcResponse<"
+ + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString)
+ + ">";
+ }
+ }
+
+ var nullable =
+ methodCallHeader(
+ kt,
+ "ctx",
+ methodName,
+ buffer,
+ returnTypeGenerics,
+ methodReturnTypeString,
+ isTrpc || !method.getThrownTypes().isEmpty());
+
+ int controllerIndent = 2;
+
+ if (isTrpc && !parameters.isEmpty()) {
+ controllerIndent = 4;
+ buffer.add(
+ statement(
+ indent(2),
+ var(kt),
+ "parser = ctx.require(io.jooby.trpc.TrpcParser",
+ clazz(kt),
+ ")",
+ semicolon(kt)));
+
+ // Calculate actual tRPC payload parameters (ignore Context and Coroutines)
+ long trpcPayloadCount =
+ parameters.stream()
+ .filter(
+ p -> {
+ String type = p.getType().getRawType().toString();
+ return !type.equals("io.jooby.Context")
+ && !p.getType().is("kotlin.coroutines.Continuation");
+ })
+ .count();
+ boolean isTuple = trpcPayloadCount > 1;
+
+ if (resolvedTrpcMethod == HttpMethod.GET) {
+ buffer.add(
+ statement(
+ indent(2),
+ var(kt),
+ "input = ctx.query(",
+ string("input"),
+ ").value()",
+ semicolon(kt)));
+
+ if (isTuple) { // <-- Use calculated isTuple
+ if (kt) {
+ buffer.add(
+ statement(
+ indent(2),
+ "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)"
+ + " throw IllegalArgumentException(",
+ string("tRPC input for multiple arguments must be a JSON array (tuple)"),
+ ")"));
+ } else {
+ buffer.add(
+ statement(
+ indent(2),
+ "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||"
+ + " input.charAt(input.length() - 1) != ']') throw new"
+ + " IllegalArgumentException(",
+ string("tRPC input for multiple arguments must be a JSON array (tuple)"),
+ ");"));
+ }
+ }
+ } else {
+ buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt)));
+
+ if (isTuple) { // <-- Use calculated isTuple
+ if (kt) {
+ buffer.add(
+ statement(
+ indent(2),
+ "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]"
+ + " != ']'.code.toByte()) throw IllegalArgumentException(",
+ string("tRPC body for multiple arguments must be a JSON array (tuple)"),
+ ")"));
+ } else {
+ buffer.add(
+ statement(
+ indent(2),
+ "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')"
+ + " throw new IllegalArgumentException(",
+ string("tRPC body for multiple arguments must be a JSON array (tuple)"),
+ ");"));
+ }
+ }
+ }
+
+ if (kt) {
+ buffer.add(
+ statement(
+ indent(2),
+ "parser.reader(input, ",
+ String.valueOf(isTuple),
+ ").use { reader -> "));
+ } else {
+ buffer.add(
+ statement(
+ indent(2),
+ "try (var reader = parser.reader(input, ",
+ String.valueOf(isTuple),
+ ")) {"));
+ }
+
+ buffer.addAll(generateTrpcParameter(kt, paramList::add));
+ } else if (!isTrpc) {
+ for (var parameter : getParameters(true)) {
+ String generatedParameter = parameter.generateMapping(kt);
+ if (parameter.isRequireBeanValidation()) {
+ generatedParameter =
+ CodeBlock.of(
+ "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")");
+ }
+ paramList.add(generatedParameter);
+ }
+ }
+
+ controllerVar(kt, buffer, controllerIndent);
+
+ // 2. Resolve Return Flow
+ if (returnType.isVoid()) {
+ String statusCode =
+ annotationMap.size() == 1
+ && annotationMap
+ .keySet()
+ .iterator()
+ .next()
+ .getSimpleName()
+ .toString()
+ .equals("DELETE")
+ ? "NO_CONTENT"
+ : "OK";
+
+ if (annotationMap.size() == 1) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "ctx.setResponseCode(io.jooby.StatusCode.",
+ statusCode,
+ ")",
+ semicolon(kt)));
+ } else {
+ if (kt) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(",
+ string("DELETE"),
+ ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)"));
+ } else {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "ctx.setResponseCode(ctx.getRoute().getMethod().equals(",
+ string("DELETE"),
+ ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)",
+ semicolon(false)));
+ }
+ }
+
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "c.",
+ this.method.getSimpleName(),
+ paramList.toString(),
+ semicolon(kt)));
+
+ if (isTrpc) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return io.jooby.trpc.TrpcResponse.empty()",
+ semicolon(kt)));
+ } else {
+ buffer.add(
+ statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt)));
+ }
+ } else if (returnType.is("io.jooby.StatusCode")) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ kt ? "val" : "var",
+ " statusCode = c.",
+ this.method.getSimpleName(),
+ paramList.toString(),
+ semicolon(kt)));
+ buffer.add(
+ statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt)));
+
+ if (isTrpc) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return io.jooby.trpc.TrpcResponse.of(statusCode)",
+ semicolon(kt)));
+ } else {
+ buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt)));
+ }
+ } else {
+ var castStr =
+ customReturnType.isProjection()
+ ? ""
+ : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR));
+
+ var needsCast =
+ !castStr.isEmpty()
+ || (kt
+ && !customReturnType.isProjection()
+ && !customReturnType.getArguments().isEmpty());
+
+ var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : "";
+ var call =
+ of(
+ "c.",
+ this.method.getSimpleName(),
+ kotlinNotEnoughTypeInformation,
+ paramList.toString());
+
+ if (needsCast) {
+ setUncheckedCast(true);
+ call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call;
+ }
+
+ if (customReturnType.isProjection()) {
+ var projected =
+ of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")");
+ if (isTrpc) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return io.jooby.trpc.TrpcResponse.of(",
+ projected,
+ ")",
+ semicolon(kt)));
+ } else {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ projected,
+ kt && nullable ? "!!" : "",
+ semicolon(kt)));
+ }
+ } else {
+
+ 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(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ ".then(reactor.core.publisher.Mono.just(io.jooby.trpc.TrpcResponse.empty()))",
+ semicolon(kt)));
+ } else if (handler.contains("Mutiny")) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ ".replaceWith(io.jooby.trpc.TrpcResponse.empty())",
+ semicolon(kt)));
+ } else if (handler.contains("ReactiveSupport")) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ ".thenApply(x -> io.jooby.trpc.TrpcResponse.empty())",
+ semicolon(kt)));
+ } else if (handler.contains("Reactivex")) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ ".toSingleDefault(io.jooby.trpc.TrpcResponse.empty())",
+ semicolon(kt)));
+ } else {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ ".map(x -> io.jooby.trpc.TrpcResponse.empty())",
+ semicolon(kt)));
+ }
+ } else {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ reactive.mapOperator(),
+ semicolon(kt)));
+ }
+ } else if (isTrpc) {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return io.jooby.trpc.TrpcResponse.of(",
+ call,
+ kt && nullable ? "!!" : "",
+ ")",
+ semicolon(kt)));
+ } else {
+ buffer.add(
+ statement(
+ indent(controllerIndent),
+ "return ",
+ call,
+ kt && nullable ? "!!" : "",
+ semicolon(kt)));
+ }
+ }
+ }
+
+ if (isTrpc && !parameters.isEmpty()) {
+ buffer.add(statement(indent(2), "}"));
+ }
+
+ buffer.add(statement("}", System.lineSeparator()));
+
+ if (uncheckedCast) {
+ if (kt) {
+ buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")"));
+ } else {
+ buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")"));
+ }
+ }
}
+ return buffer;
+ }
- boolean nullable = false;
+ private boolean methodCallHeader(
+ boolean kt,
+ String contextVarname,
+ String methodName,
+ ArrayList buffer,
+ String returnTypeGenerics,
+ String returnTypeString,
+ boolean throwsException) {
+ var nullable = false;
if (kt) {
nullable =
method.getAnnotationMirrors().stream()
@@ -272,18 +657,20 @@ public List generateHandlerCall(boolean kt) {
"suspend ",
"fun ",
returnTypeGenerics,
- getGeneratedName(),
+ methodName,
"(handler: io.jooby.kt.HandlerContext): ",
returnTypeString,
" {"));
- buffer.add(statement(indent(2), "val ctx = handler.ctx"));
+ buffer.add(statement(indent(2), "val ", contextVarname, " = handler.ctx"));
} else {
buffer.add(
statement(
"fun ",
returnTypeGenerics,
- getGeneratedName(),
- "(ctx: io.jooby.Context): ",
+ methodName,
+ "(",
+ contextVarname,
+ ": io.jooby.Context): ",
returnTypeString,
" {"));
}
@@ -294,104 +681,276 @@ public List generateHandlerCall(boolean kt) {
returnTypeGenerics,
returnTypeString,
" ",
- getGeneratedName(),
- "(io.jooby.Context ctx) ",
+ methodName,
+ "(io.jooby.Context ",
+ contextVarname,
+ ") ",
throwsException ? "throws Exception {" : "{"));
}
- if (returnType.isVoid()) {
- String statusCode;
- if (annotationMap.size() == 1) {
- statusCode =
- annotationMap.keySet().iterator().next().getSimpleName().toString().equals("DELETE")
- ? "NO_CONTENT"
- : "OK";
- } else {
- statusCode = null;
- }
- if (statusCode != null) {
- buffer.add(
- statement(
- indent(2),
- "ctx.setResponseCode(io.jooby.StatusCode.",
- statusCode,
- ")",
- semicolon(kt)));
- } else {
- if (kt) {
- buffer.add(
- statement(
- indent(2),
- "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(",
- string("DELETE"),
- ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)"));
- } else {
- buffer.add(
- statement(
- indent(2),
- "ctx.setResponseCode(ctx.getRoute().getMethod().equals(",
- string("DELETE"),
- ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)",
- semicolon(false)));
- }
- }
- controllerVar(kt, buffer);
- buffer.add(
- statement(
- indent(2), "c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt)));
- buffer.add(statement(indent(2), "return ctx.getResponseCode()", semicolon(kt)));
- } else if (returnType.is("io.jooby.StatusCode")) {
- controllerVar(kt, buffer);
- buffer.add(
- statement(
- indent(2),
- kt ? "val" : "var",
- " statusCode = c.",
- this.method.getSimpleName(),
- paramList.toString(),
- semicolon(kt)));
- buffer.add(statement(indent(2), "ctx.setResponseCode(statusCode)", semicolon(kt)));
- buffer.add(statement(indent(2), "return statusCode", semicolon(kt)));
- } else {
- controllerVar(kt, buffer);
- var cast =
- customReturnType.isProjection()
- ? ""
- : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR));
- var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : "";
- var call =
- of(
- "c.",
- this.method.getSimpleName(),
- kotlinNotEnoughTypeInformation,
- paramList.toString());
- if (!cast.isEmpty()) {
- setUncheckedCast(true);
- call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call;
- }
- if (customReturnType.isProjection()) {
- var projected =
- of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")");
- buffer.add(
- statement(indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt)));
- } else {
- buffer.add(
- statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt)));
- }
- }
- buffer.add(statement("}", System.lineSeparator()));
- if (uncheckedCast) {
- if (kt) {
- buffer.add(0, statement("@Suppress(\"UNCHECKED_CAST\")"));
- } else {
- buffer.add(0, statement("@SuppressWarnings(\"unchecked\")"));
+ return nullable;
+ }
+
+ private List generateTrpcParameter(boolean kt, Consumer arguments) {
+ var statements = new ArrayList();
+ for (var parameter : parameters) {
+ var paramenterName = parameter.getName();
+ var type = type(kt, parameter.getType().toString());
+ boolean isNullable = parameter.isNullable(kt);
+
+ switch (parameter.getType().getRawType().toString()) {
+ case "io.jooby.Context":
+ arguments.accept("ctx");
+ break;
+ case "int",
+ "long",
+ "double",
+ "boolean",
+ "java.lang.String",
+ "java.lang.Integer",
+ "java.lang.Long",
+ "java.lang.Double",
+ "java.lang.Boolean":
+ var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type;
+ if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int";
+ var readName =
+ "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1);
+
+ if (isNullable) {
+ if (kt) {
+ statements.add(
+ statement(
+ indent(4),
+ "val ",
+ paramenterName,
+ " = if (reader.nextIsNull(",
+ string(paramenterName),
+ ")) null else reader.",
+ readName,
+ "(",
+ string(paramenterName),
+ ")"));
+ } else {
+ statements.add(
+ statement(
+ indent(4),
+ var(kt),
+ paramenterName,
+ " = reader.nextIsNull(",
+ string(paramenterName),
+ ") ? null : reader.",
+ readName,
+ "(",
+ string(paramenterName),
+ ")",
+ semicolon(kt)));
+ }
+ } else {
+ statements.add(
+ statement(
+ indent(4),
+ var(kt),
+ paramenterName,
+ " = reader.",
+ readName,
+ "(",
+ string(paramenterName),
+ ")",
+ semicolon(kt)));
+ }
+ arguments.accept(paramenterName);
+ break;
+ case "byte",
+ "short",
+ "float",
+ "char",
+ "java.lang.Byte",
+ "java.lang.Short",
+ "java.lang.Float",
+ "java.lang.Character":
+ var isChar = type.equals("char") || type.equals("java.lang.Character");
+ var isFloat = type.equals("float") || type.equals("java.lang.Float");
+ var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt");
+
+ if (isNullable) {
+ if (kt) {
+ var ktCast =
+ isChar
+ ? "?.get(0)"
+ : "?.to"
+ + Character.toUpperCase(type.replace("java.lang.", "").charAt(0))
+ + type.replace("java.lang.", "").substring(1)
+ + "()";
+ statements.add(
+ statement(
+ indent(4),
+ "val ",
+ paramenterName,
+ " = if (reader.nextIsNull(",
+ string(paramenterName),
+ ")) null else reader.",
+ readMethod,
+ "(",
+ string(paramenterName),
+ ")",
+ ktCast));
+ } else {
+ var targetType = type.replace("java.lang.", "");
+ var javaPrefix = isChar ? "" : "(" + targetType + ") ";
+ var javaSuffix = isChar ? ".charAt(0)" : "";
+ statements.add(
+ statement(
+ indent(4),
+ var(kt),
+ paramenterName,
+ " = reader.nextIsNull(",
+ string(paramenterName),
+ ") ? null : ",
+ javaPrefix,
+ "reader.",
+ readMethod,
+ "(",
+ string(paramenterName),
+ ")",
+ javaSuffix,
+ semicolon(kt)));
+ }
+ } else {
+ if (kt) {
+ var ktCast =
+ isChar
+ ? "[0]"
+ : ".to"
+ + Character.toUpperCase(type.replace("java.lang.", "").charAt(0))
+ + type.replace("java.lang.", "").substring(1)
+ + "()";
+ statements.add(
+ statement(
+ indent(4),
+ var(kt),
+ paramenterName,
+ " = reader.",
+ readMethod,
+ "(",
+ string(paramenterName),
+ ")",
+ ktCast,
+ semicolon(kt)));
+ } else {
+ var targetType = type.replace("java.lang.", "");
+ var javaPrefix = isChar ? "" : "(" + targetType + ") ";
+ var javaSuffix = isChar ? ".charAt(0)" : "";
+ statements.add(
+ statement(
+ indent(4),
+ var(kt),
+ paramenterName,
+ " = ",
+ javaPrefix,
+ "reader.",
+ readMethod,
+ "(",
+ string(paramenterName),
+ ")",
+ javaSuffix,
+ semicolon(kt)));
+ }
+ }
+ arguments.accept(paramenterName);
+ break;
+ default:
+ if (kt) {
+ statements.add(
+ statement(
+ indent(4),
+ "val ",
+ paramenterName,
+ "Decoder: io.jooby.trpc.TrpcDecoder<",
+ type,
+ "> = parser.decoder(",
+ parameter.getType().toSourceCode(kt),
+ ")",
+ semicolon(kt)));
+ if (isNullable) {
+ statements.add(
+ statement(
+ indent(4),
+ "val ",
+ paramenterName,
+ " = if (reader.nextIsNull(",
+ string(paramenterName),
+ ")) null else reader.nextObject(",
+ string(paramenterName),
+ ", ",
+ paramenterName,
+ "Decoder)"));
+ } else {
+ statements.add(
+ statement(
+ indent(4),
+ "val ",
+ paramenterName,
+ " = reader.nextObject(",
+ string(paramenterName),
+ ", ",
+ paramenterName,
+ "Decoder)",
+ semicolon(kt)));
+ }
+ } else {
+ statements.add(
+ statement(
+ indent(4),
+ "io.jooby.trpc.TrpcDecoder<",
+ type,
+ "> ",
+ paramenterName,
+ "Decoder = parser.decoder(",
+ parameter.getType().toSourceCode(kt),
+ ")",
+ semicolon(kt)));
+ if (isNullable) {
+ statements.add(
+ statement(
+ indent(4),
+ parameter.getType().toString(),
+ " ",
+ paramenterName,
+ " = reader.nextIsNull(",
+ string(paramenterName),
+ ") ? null : reader.nextObject(",
+ string(paramenterName),
+ ", ",
+ paramenterName,
+ "Decoder)",
+ semicolon(kt)));
+ } else {
+ statements.add(
+ statement(
+ indent(4),
+ parameter.getType().toString(),
+ " ",
+ paramenterName,
+ " = reader.nextObject(",
+ string(paramenterName),
+ ", ",
+ paramenterName,
+ "Decoder)",
+ semicolon(kt)));
+ }
+ }
+ arguments.accept(paramenterName);
+ break;
}
}
- return buffer;
+ return statements;
}
private void controllerVar(boolean kt, List buffer) {
- buffer.add(
- statement(indent(2), kt ? "val" : "var", " c = this.factory.apply(ctx)", semicolon(kt)));
+ controllerVar(kt, buffer, 2);
+ }
+
+ private void controllerVar(boolean kt, List buffer, int indent) {
+ buffer.add(statement(indent(indent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt)));
}
public String getGeneratedName() {
@@ -407,6 +966,12 @@ public MvcRoute addHttpMethod(TypeElement annotation) {
ofNullable(findAnnotationByName(this.method, annotation.getQualifiedName().toString()))
.orElseThrow(() -> 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) {
+ this.isTrpc = true;
+ }
return this;
}
@@ -438,7 +1003,6 @@ public List getJavaMethodSignature(boolean kt) {
.map(
it -> {
var type = it.getType();
- // Kotlin requires his own types for primitives
if (kt && type.isPrimitive()) {
return type(kt, type.getRawType().toString());
}
@@ -454,13 +1018,13 @@ public String getMethodName() {
@Override
public int hashCode() {
- return method.toString().hashCode();
+ return Objects.hash(method.toString(), isTrpc);
}
@Override
public boolean equals(Object obj) {
if (obj instanceof MvcRoute that) {
- return this.method.toString().equals(that.method.toString());
+ return this.method.toString().equals(that.method.toString()) && this.isTrpc == that.isTrpc;
}
return false;
}
@@ -513,11 +1077,6 @@ private Optional mediaType(Function> lookup) {
.collect(Collectors.joining(", ", "java.util.List.of(", ")")));
}
- /**
- * Kotlin suspend function has a kotlin.coroutines.Continuation as last parameter.
- *
- * @return True for Kotlin suspend function.
- */
public boolean isSuspendFun() {
return suspendFun;
}
@@ -544,4 +1103,66 @@ public void setUncheckedCast(boolean value) {
public boolean hasBeanValidation() {
return 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;
+ }
+ if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation")
+ != null) {
+ 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.");
+ }
+ return null;
+ }
+
+ public String trpcPath(Element element) {
+ var namespace =
+ Optional.ofNullable(
+ AnnotationSupport.findAnnotationByName(
+ element.getEnclosingElement(), "io.jooby.annotation.Trpc"))
+ .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst())
+ .map(it -> it + ".")
+ .orElse("");
+
+ var procedure =
+ Stream.of(
+ "io.jooby.annotation.Trpc.Query",
+ "io.jooby.annotation.Trpc.Mutation",
+ "io.jooby.annotation.Trpc")
+ .map(it -> AnnotationSupport.findAnnotationByName(element, it))
+ .filter(Objects::nonNull)
+ .findFirst()
+ .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst())
+ .orElse(element.getSimpleName().toString());
+ return Stream.of("trpc", namespace + procedure)
+ .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment)
+ .collect(Collectors.joining("/", "/", ""));
+ }
}
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 5a2005dd60..ebbe396fe4 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
@@ -66,8 +66,12 @@ public String getGeneratedFilename() {
}
public MvcRouter put(TypeElement httpMethod, ExecutableElement route) {
- var routeKey = route.toString();
+ var isTrpc =
+ HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString())
+ == HttpMethod.tRPC;
+ var routeKey = (isTrpc ? "trpc" : "") + route.toString();
var existing = routes.get(routeKey);
+
if (existing == null) {
routes.put(routeKey, new MvcRoute(context, this, route).addHttpMethod(httpMethod));
} else {
@@ -100,7 +104,7 @@ public String getPackageName() {
*
* {@code
* public class Controller_ implements MvcExtension {
- * ....
+ * ....
* }
*
* }
diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java
index e30366b2fc..6bf2ba4c89 100644
--- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java
+++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java
@@ -13,11 +13,14 @@ public class ReactiveType {
private final String handlerType;
private final String handler;
private final Set reactiveTypes;
+ private final String mapOperator;
- private ReactiveType(String handlerType, String handler, Set reactiveTypes) {
+ private ReactiveType(
+ String handlerType, String handler, Set reactiveTypes, String mapOperator) {
this.handlerType = handlerType;
this.handler = handler;
this.reactiveTypes = reactiveTypes;
+ this.mapOperator = mapOperator;
}
public Set reactiveTypes() {
@@ -32,27 +35,35 @@ public String handler() {
return handler;
}
+ public String mapOperator() {
+ return mapOperator;
+ }
+
public static List supportedTypes() {
return List.of(
new ReactiveType(
"io.jooby.ReactiveSupport",
"concurrent",
- Set.of("java.util.concurrent.Flow", "java.util.concurrent.CompletionStage")),
+ Set.of("java.util.concurrent.Flow", "java.util.concurrent.CompletionStage"),
+ ".thenApply(io.jooby.trpc.TrpcResponse::of)"),
// Vertx
new ReactiveType(
"io.jooby.vertx.VertxHandler",
"vertx",
- Set.of("io.vertx.core.Future", "io.vertx.core.Promise", "io.vertx.core.buffer.Buffer")),
+ Set.of("io.vertx.core.Future", "io.vertx.core.Promise", "io.vertx.core.buffer.Buffer"),
+ ".map(io.jooby.trpc.TrpcResponse::of)"),
// Mutiny
new ReactiveType(
"io.jooby.mutiny.Mutiny",
"mutiny",
- Set.of("io.smallrye.mutiny.Uni", "io.smallrye.mutiny.Multi")),
+ Set.of("io.smallrye.mutiny.Uni", "io.smallrye.mutiny.Multi"),
+ ".map(io.jooby.trpc.TrpcResponse::of)"),
// Reactor
new ReactiveType(
"io.jooby.reactor.Reactor",
"reactor",
- Set.of("reactor.core.publisher.Flux", "reactor.core.publisher.Mono")),
+ Set.of("reactor.core.publisher.Flux", "reactor.core.publisher.Mono"),
+ ".map(io.jooby.trpc.TrpcResponse::of)"),
// Rxjava
new ReactiveType(
"io.jooby.rxjava3.Reactivex",
@@ -60,8 +71,9 @@ public static List supportedTypes() {
Set.of(
"io.reactivex.rxjava3.core.Flowable",
"io.reactivex.rxjava3.core.Maybe",
- "io.reactivex.rxjava3.core.Observable",
"io.reactivex.rxjava3.core.Single",
- "io.reactivex.rxjava3.disposables.Disposable")));
+ "io.reactivex.rxjava3.core.Observable",
+ "io.reactivex.rxjava3.core.Completable"),
+ ".map(io.jooby.trpc.TrpcResponse::of)"));
}
}
diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java
new file mode 100644
index 0000000000..e83bcaed0e
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java
@@ -0,0 +1,48 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.i3863;
+
+import io.jooby.annotation.*;
+
+@Trpc("users")
+public class C3863 {
+
+ @Trpc.Query
+ public String ping(Integer year) {
+ return null;
+ }
+
+ // @Trpc.Query
+ // public String ping() {
+ // return null;
+ // }
+ //
+ // @Trpc.Query
+ // public void clear() {
+ //
+ // }
+ //
+ // @Trpc.Query
+ // public U3863 findUser(Context ctx, @PathParam long id) {
+ // return null;
+ // }
+ //
+ // @Trpc.Query
+ // public List multipleSimpleArgs(String q, byte type) {
+ // return null;
+ // }
+ //
+ // @Trpc.Query
+ // public List multipleComplexArguments(U3863 current, List users) {
+ // return null;
+ // }
+ //
+ // @Trpc.Mutation
+ // public U3863 updateUser(String id, U3863 payload) {
+ // return null;
+ // }
+
+}
diff --git a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java
new file mode 100644
index 0000000000..958185266b
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.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.i3863;
+
+import org.junit.jupiter.api.Test;
+
+import io.jooby.apt.ProcessorRunner;
+
+public class Issue3863 {
+ @Test
+ public void shouldGenerateTrpcService() throws Exception {
+ new ProcessorRunner(new C3863())
+ .withSourceCode(
+ source -> {
+ System.out.println(source);
+ });
+ }
+}
diff --git a/modules/jooby-apt/src/test/java/tests/i3863/U3863.java b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java
new file mode 100644
index 0000000000..d8a96d53d7
--- /dev/null
+++ b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java
@@ -0,0 +1,8 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package tests.i3863;
+
+public record U3863(long id, String name) {}
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 e5e10ef7ff..a1e97b2ff2 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
@@ -10,12 +10,18 @@
import java.util.*;
import edu.umd.cs.findbugs.annotations.NonNull;
+import io.avaje.json.JsonDataException;
import io.avaje.json.JsonWriter;
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.output.Output;
+import io.jooby.trpc.TrpcErrorCode;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcResponse;
/**
* JSON module using Avaje-JsonB: projected) {
var view = (JsonView) jsonbType.view(viewString);
view.toJson(value, writer);
}
+
+ /**
+ * Custom adapter for {@link TrpcResponse}.
+ *
+ * @return Custom adapter for {@link TrpcResponse}.
+ */
+ public static Jsonb.AdapterBuilder trpcResponseAdapter() {
+ return AvajeTrpcResponseAdapter::new;
+ }
}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java
new file mode 100644
index 0000000000..bc09e67a35
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java
@@ -0,0 +1,28 @@
+/*
+ * 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.jsonb.JsonType;
+import io.jooby.trpc.TrpcDecoder;
+
+public class AvajeTrpcDecoder implements TrpcDecoder {
+
+ final JsonType typeAdapter;
+
+ public AvajeTrpcDecoder(JsonType typeAdapter) {
+ this.typeAdapter = typeAdapter;
+ }
+
+ @Override
+ public T decode(String name, byte[] payload) {
+ return typeAdapter.fromJson(payload);
+ }
+
+ @Override
+ public T decode(String name, String payload) {
+ return typeAdapter.fromJson(payload);
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java
new file mode 100644
index 0000000000..d7af242681
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java
@@ -0,0 +1,38 @@
+/*
+ * 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.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcReader;
+
+public class AvajeTrpcParser implements TrpcParser {
+
+ private final Jsonb jsonb;
+
+ public AvajeTrpcParser(Jsonb jsonb) {
+ this.jsonb = jsonb;
+ }
+
+ @Override
+ public TrpcDecoder decoder(Type type) {
+ // Avaje resolves the AOT-generated adapter here
+ return new AvajeTrpcDecoder<>(jsonb.type(type));
+ }
+
+ @Override
+ public TrpcReader reader(byte[] payload, boolean isTuple) {
+ return new AvajeTrpcReader(jsonb.reader(payload), isTuple);
+ }
+
+ @Override
+ public TrpcReader reader(String payload, boolean isTuple) {
+ return new AvajeTrpcReader(jsonb.reader(payload), isTuple);
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java
new file mode 100644
index 0000000000..708ccc8f40
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java
@@ -0,0 +1,118 @@
+/*
+ * 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.JsonReader;
+import io.jooby.exception.MissingValueException;
+import io.jooby.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcReader;
+
+public class AvajeTrpcReader implements TrpcReader {
+ private final JsonReader reader;
+ private boolean hasPeeked = false;
+ private final boolean isTuple;
+ private boolean isFirstRead = true;
+
+ public AvajeTrpcReader(JsonReader reader, boolean isTuple) {
+ this.reader = reader;
+ this.isTuple = isTuple;
+ if (isTuple) {
+ reader.beginArray();
+ }
+ }
+
+ @Override
+ public boolean nextIsNull(String name) {
+ if (!hasPeeked) {
+ ensureNextState(name);
+ hasPeeked = true;
+ }
+
+ if (reader.isNullValue()) {
+ reader.skipValue();
+ hasPeeked = false;
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ensureNextState(String name) {
+ if (isTuple) {
+ if (!reader.hasNextElement()) {
+ throw new MissingValueException(name);
+ }
+ } else {
+ if (!isFirstRead) {
+ throw new MissingValueException(name);
+ }
+ isFirstRead = false;
+ }
+ }
+
+ private void ensureNext(String name) {
+ if (hasPeeked) {
+ hasPeeked = false;
+ return;
+ }
+ ensureNextState(name);
+ }
+
+ private void ensureNonNull(String name) {
+ if (reader.isNullValue()) throw new MissingValueException(name);
+ }
+
+ @Override
+ public int nextInt(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return reader.readInt();
+ }
+
+ @Override
+ public long nextLong(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return reader.readLong();
+ }
+
+ @Override
+ public boolean nextBoolean(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return reader.readBoolean();
+ }
+
+ @Override
+ public double nextDouble(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return reader.readDouble();
+ }
+
+ @Override
+ public String nextString(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return reader.readString();
+ }
+
+ @Override
+ public T nextObject(String name, TrpcDecoder decoder) {
+ ensureNext(name);
+ ensureNonNull(name);
+ AvajeTrpcDecoder avajeDecoder = (AvajeTrpcDecoder) decoder;
+ return avajeDecoder.typeAdapter.fromJson(reader);
+ }
+
+ @Override
+ public void close() {
+ if (isTuple) {
+ reader.endArray();
+ }
+ reader.close();
+ }
+}
diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java
new file mode 100644
index 0000000000..5734a537ff
--- /dev/null
+++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java
@@ -0,0 +1,79 @@
+/*
+ * 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.Map;
+
+import io.avaje.json.JsonAdapter;
+import io.avaje.json.JsonReader;
+import io.avaje.json.JsonWriter;
+import io.avaje.jsonb.Jsonb;
+import io.jooby.trpc.TrpcResponse;
+
+public class AvajeTrpcResponseAdapter implements JsonAdapter {
+
+ private final Jsonb jsonb;
+
+ public AvajeTrpcResponseAdapter(Jsonb jsonb) {
+ this.jsonb = jsonb;
+ }
+
+ @Override
+ public void toJson(JsonWriter writer, TrpcResponse envelope) {
+ writer.beginObject();
+ writer.name("result");
+
+ writer.beginObject();
+ var data = envelope.data();
+
+ if (data != null) {
+ writer.name("data");
+ if (data instanceof Iterable> iterable) {
+ writer.beginArray();
+ for (var item : iterable) {
+ if (item == null) {
+ writer.nullValue();
+ } else {
+ writePOJO(writer, item);
+ }
+ }
+ writer.endArray();
+ } else if (data instanceof Map, ?> map) {
+ writer.beginObject();
+ for (var entry : map.entrySet()) {
+ // JSON keys must be strings
+ writer.name(String.valueOf(entry.getKey()));
+
+ var value = entry.getValue();
+ if (value == null) {
+ writer.nullValue();
+ } else {
+ writePOJO(writer, value);
+ }
+ }
+ writer.endObject();
+ } else {
+ writePOJO(writer, data);
+ }
+ }
+
+ writer.endObject();
+ writer.endObject();
+ }
+
+ private void writePOJO(JsonWriter writer, Object item) {
+ // Look up the adapter for the specific element (e.g., Movie.class)
+ @SuppressWarnings("unchecked")
+ JsonAdapter itemAdapter = (JsonAdapter) jsonb.adapter(item.getClass());
+ itemAdapter.toJson(writer, item);
+ }
+
+ @Override
+ public TrpcResponse> fromJson(JsonReader reader) {
+ // We only serialize out, tRPC clients don't send this envelope to the server
+ throw new UnsupportedOperationException("Deserialization of TrpcEnvelope is not required");
+ }
+}
diff --git a/modules/jooby-gradle-plugin/build.gradle b/modules/jooby-gradle-plugin/build.gradle
index 15e01f2317..70f69d5f3e 100644
--- a/modules/jooby-gradle-plugin/build.gradle
+++ b/modules/jooby-gradle-plugin/build.gradle
@@ -26,6 +26,7 @@ group = "io.jooby"
dependencies {
implementation "io.jooby:jooby-run:$version"
implementation "io.jooby:jooby-openapi:$version"
+ implementation "io.jooby:jooby-trpc:$version"
implementation "com.github.spotbugs:spotbugs-annotations:4.7.2"
}
@@ -33,9 +34,9 @@ dependencies {
gradlePlugin {
website = 'https://jooby.io'
vcsUrl = 'https://github.com/jooby-project/jooby'
- description = 'Jooby is a modern, performant and easy to use web framework for Java and Kotlin ' +
- 'built on top of your favorite web server. The joobyRun task allows to restart your ' +
- 'application on code changes without exiting the JVM'
+ description = 'Jooby is a modular, high-performance web framework for Java and Kotlin. Designed ' +
+ 'for simplicity and speed, it gives you the freedom to build on your favorite server with a ' +
+ 'clean, modern API.'
plugins {
joobyRun {
id = 'io.jooby.run'
@@ -51,5 +52,12 @@ gradlePlugin {
tags = ['jooby', 'openAPI']
description = 'Generates an Open-API compatible output from your application'
}
+ trpc {
+ id = 'io.jooby.trpc'
+ implementationClass = 'io.jooby.gradle.JoobyPlugin'
+ displayName = 'Jooby tRPC plugin'
+ tags = ['jooby', 'trpc', 'typescript']
+ description = 'Generates a tRPC-compatible TypeScript API definition from your application'
+ }
}
}
diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java
index 8145c88d97..786a0956ec 100644
--- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java
+++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java
@@ -28,6 +28,7 @@ public JoobyPlugin() {
@Override public void apply(Project project) {
openAPI(project);
+ trpc(project);
joobyRun(project);
joobyTestRun(project);
@@ -64,4 +65,16 @@ private void openAPI(Project project) {
project.getTasks().create(openAPIOptions);
}
+
+ private void trpc(Project project) {
+ Map options = new HashMap<>();
+ options.put(Task.TASK_TYPE, OpenAPITask.class);
+ options.put(Task.TASK_DEPENDS_ON, "classes");
+ options.put(Task.TASK_NAME, "tRPC");
+ options
+ .put(Task.TASK_DESCRIPTION, "tRPC Generator");
+ options.put(Task.TASK_GROUP, "jooby");
+
+ project.getTasks().create(options);
+ }
}
diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java
new file mode 100644
index 0000000000..74c2442954
--- /dev/null
+++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java
@@ -0,0 +1,166 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.gradle;
+
+import cz.habarta.typescript.generator.DateMapping;
+import cz.habarta.typescript.generator.EnumMapping;
+import cz.habarta.typescript.generator.JsonLibrary;
+import io.jooby.trpc.TrpcGenerator;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.TaskAction;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Generate a tRpc script file from a jooby application.
+ *
+ * Usage: https://jooby.io/modules/trpc
+ *
+ * @author edgar
+ * @since 4.0.17
+ */
+public class TrpcTask extends BaseTask {
+
+ private Map customTypeMappings;
+
+ private Map customTypeNaming;
+
+ private JsonLibrary jsonLibrary = JsonLibrary.jackson2;
+
+ private DateMapping mapDate = DateMapping.asString;
+
+ private EnumMapping mapEnum = EnumMapping.asInlineUnion;
+
+ private File outputDir;
+
+ private String outputFile = "trpc.d.ts";
+
+ private List importDeclarations;
+
+ /**
+ * Creates a tRPC task.
+ */
+ public TrpcTask() {}
+
+ /**
+ * Generate tRPC files from Jooby application.
+ *
+ * @throws Throwable If something goes wrong.
+ */
+ @TaskAction
+ public void generate() throws Throwable {
+ var projects = getProjects();
+ var classLoader = createClassLoader(projects);
+
+ // Default to the compiled classes directory if the user hasn't overridden outputDir
+ var outDir = outputDir != null ? outputDir.toPath() : classes(getProject(), false);
+
+ var generator = new TrpcGenerator();
+ generator.setClassLoader(classLoader);
+ generator.setOutputDir(outDir);
+ generator.setOutputFile(outputFile);
+
+ if (customTypeMappings != null) {
+ generator.setCustomTypeMappings(customTypeMappings);
+ }
+ generator.setJsonLibrary(jsonLibrary);
+ generator.setMapDate(mapDate);
+ generator.setMapEnum(mapEnum);
+ if (importDeclarations != null) {
+ generator.setImportDeclarations(importDeclarations);
+ }
+ if (customTypeNaming != null) {
+ generator.setCustomTypeNaming(customTypeNaming);
+ }
+
+ getLogger().info("Generating: " + outDir.resolve(outputFile));
+
+ generator.generate();
+ }
+
+ @Input
+ @Optional
+ public Map getCustomTypeMappings() {
+ return customTypeMappings;
+ }
+
+ public void setCustomTypeMappings(Map customTypeMappings) {
+ this.customTypeMappings = customTypeMappings;
+ }
+
+ @Input
+ @Optional
+ public Map getCustomTypeNaming() {
+ return customTypeNaming;
+ }
+
+ public void setCustomTypeNaming(Map customTypeNaming) {
+ this.customTypeNaming = customTypeNaming;
+ }
+
+ @Input
+ @Optional
+ public JsonLibrary getJsonLibrary() {
+ return jsonLibrary;
+ }
+
+ public void setJsonLibrary(JsonLibrary jsonLibrary) {
+ this.jsonLibrary = jsonLibrary;
+ }
+
+ @Input
+ @Optional
+ public DateMapping getMapDate() {
+ return mapDate;
+ }
+
+ public void setMapDate(DateMapping mapDate) {
+ this.mapDate = mapDate;
+ }
+
+ @Input
+ @Optional
+ public EnumMapping getMapEnum() {
+ return mapEnum;
+ }
+
+ public void setMapEnum(EnumMapping mapEnum) {
+ this.mapEnum = mapEnum;
+ }
+
+ @Input
+ @Optional
+ public File getOutputDir() {
+ return outputDir;
+ }
+
+ public void setOutputDir(File outputDir) {
+ this.outputDir = outputDir;
+ }
+
+ @Input
+ @Optional
+ public String getOutputFile() {
+ return outputFile;
+ }
+
+ public void setOutputFile(String outputFile) {
+ this.outputFile = outputFile;
+ }
+
+ @Input
+ @Optional
+ public List getImportDeclarations() {
+ return importDeclarations;
+ }
+
+ public void setImportDeclarations(List importDeclarations) {
+ this.importDeclarations = importDeclarations;
+ }
+}
diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml
index e1eeea18d6..ab61531b36 100644
--- a/modules/jooby-jackson/pom.xml
+++ b/modules/jooby-jackson/pom.xml
@@ -71,6 +71,10 @@
1.37
test
+
+ jakarta.json.bind
+ jakarta.json.bind-api
+
diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java
new file mode 100644
index 0000000000..54f7c19806
--- /dev/null
+++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java
@@ -0,0 +1,39 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.databind.ObjectReader;
+import io.jooby.SneakyThrows;
+import io.jooby.trpc.TrpcDecoder;
+
+public class JacksonTrpcDecoder implements TrpcDecoder {
+
+ final ObjectReader reader;
+
+ public JacksonTrpcDecoder(ObjectReader reader) {
+ this.reader = reader;
+ }
+
+ @Override
+ public T decode(String name, byte[] payload) {
+ try {
+ return reader.readValue(payload);
+ } catch (IOException x) {
+ throw SneakyThrows.propagate(x);
+ }
+ }
+
+ @Override
+ public T decode(String name, String payload) {
+ try {
+ return reader.readValue(payload);
+ } catch (IOException x) {
+ throw SneakyThrows.propagate(x);
+ }
+ }
+}
diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java
new file mode 100644
index 0000000000..9a733f930b
--- /dev/null
+++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.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.internal.jackson;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.jooby.SneakyThrows;
+import io.jooby.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcReader;
+
+public class JacksonTrpcParser implements TrpcParser {
+
+ private final ObjectMapper mapper;
+
+ public JacksonTrpcParser(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public TrpcDecoder decoder(Type type) {
+ // Resolve the type exactly once at startup
+ var javaType = mapper.constructType(type);
+ var reader = mapper.readerFor(javaType).without(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ return new JacksonTrpcDecoder<>(reader);
+ }
+
+ @Override
+ public TrpcReader reader(byte[] payload, boolean isTuple) {
+ try {
+ return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public TrpcReader reader(String payload, boolean isTuple) {
+ try {
+ return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+}
diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java
new file mode 100644
index 0000000000..c265810520
--- /dev/null
+++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java
@@ -0,0 +1,153 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import io.jooby.SneakyThrows;
+import io.jooby.exception.MissingValueException;
+import io.jooby.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcReader;
+
+public class JacksonTrpcReader implements TrpcReader {
+ private final JsonParser parser;
+ private boolean hasPeeked = false;
+ private final boolean isTuple;
+ private boolean isFirstRead = true;
+
+ public JacksonTrpcReader(JsonParser parser, boolean isTuple) {
+ this.parser = parser;
+ this.isTuple = isTuple;
+ var token = nextToken();
+ if (isTuple && token != JsonToken.START_ARRAY) {
+ throw new IllegalArgumentException("Expected tRPC tuple array");
+ }
+ }
+
+ private JsonToken nextToken() {
+ try {
+ return parser.nextToken();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public boolean nextIsNull(String name) {
+ if (!hasPeeked) {
+ advance(name);
+ hasPeeked = true;
+ }
+
+ if (parser.currentToken() == JsonToken.VALUE_NULL) {
+ hasPeeked = false; // Consume the null token
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ensureNext(String name) {
+ if (hasPeeked) {
+ hasPeeked = false;
+ return;
+ }
+ advance(name);
+ }
+
+ private void ensureNonNull(String name) {
+ if (parser.currentToken() == JsonToken.VALUE_NULL) throw new MissingValueException(name);
+ }
+
+ private void advance(String name) {
+ // If it's a seamless raw value, we are ALREADY on the token. Do not advance.
+ if (!isTuple) {
+ if (!isFirstRead) throw new MissingValueException(name);
+ isFirstRead = false;
+ // The constructor already positioned us on the root token. Do not advance.
+ return;
+ }
+
+ var token = nextToken();
+ if (token == JsonToken.END_ARRAY || token == null) {
+ throw new MissingValueException(name);
+ }
+ }
+
+ @Override
+ public int nextInt(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ try {
+ return parser.getIntValue();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public long nextLong(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ try {
+ return parser.getLongValue();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public boolean nextBoolean(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ try {
+ return parser.getBooleanValue();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public double nextDouble(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ try {
+ return parser.getDoubleValue();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public String nextString(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ try {
+ return parser.getText();
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public T nextObject(String name, TrpcDecoder decoder) {
+ try {
+ ensureNext(name);
+ ensureNonNull(name);
+ JacksonTrpcDecoder jacksonDecoder = (JacksonTrpcDecoder) decoder;
+ return jacksonDecoder.reader.readValue(parser);
+ } catch (IOException e) {
+ throw SneakyThrows.propagate(e);
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ parser.close();
+ }
+}
diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java
new file mode 100644
index 0000000000..d7dc2552d1
--- /dev/null
+++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java
@@ -0,0 +1,38 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+import io.jooby.trpc.TrpcResponse;
+
+public class JacksonTrpcResponseSerializer extends StdSerializer {
+ public JacksonTrpcResponseSerializer() {
+ super(TrpcResponse.class);
+ }
+
+ @Override
+ public void serialize(TrpcResponse value, JsonGenerator gen, SerializerProvider provider)
+ throws IOException {
+ gen.writeStartObject(); // {
+
+ gen.writeFieldName("result");
+ gen.writeStartObject(); // "result": {
+
+ var data = value.data();
+ // Only write the "data" key if the method actually returned something (not void/Unit)
+ if (data != null) {
+ gen.writeFieldName("data");
+ gen.writePOJO(data);
+ }
+
+ gen.writeEndObject();
+ gen.writeEndObject();
+ }
+}
diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java
index fd8e960342..30ebbd86b2 100644
--- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java
+++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java
@@ -18,10 +18,9 @@
import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.core.JsonParseException;
-import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.Module;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@@ -31,7 +30,11 @@
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import edu.umd.cs.findbugs.annotations.NonNull;
import io.jooby.*;
+import io.jooby.internal.jackson.JacksonTrpcParser;
+import io.jooby.internal.jackson.JacksonTrpcResponseSerializer;
import io.jooby.output.Output;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcResponse;
/**
* JSON module using Jackson: https://jooby.io/modules/jackson2.
@@ -149,6 +152,10 @@ public void install(@NonNull Jooby application) {
// Parsing exception as 400
application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST);
+ application.errorCode(MismatchedInputException.class, StatusCode.BAD_REQUEST);
+
+ // tRPC
+ services.put(TrpcParser.class, new JacksonTrpcParser(mapper));
// Filter
var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false);
@@ -221,6 +228,10 @@ public Object decode(Context ctx, Type type) throws Exception {
.addModule(new JavaTimeModule());
Stream.of(modules).forEach(builder::addModule);
+ // tRPC
+ var trpcModule = new SimpleModule();
+ trpcModule.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer());
+ builder.addModule(trpcModule);
return builder.build();
}
diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java
new file mode 100644
index 0000000000..eeb1958bb6
--- /dev/null
+++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java
@@ -0,0 +1,28 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson3;
+
+import io.jooby.trpc.TrpcDecoder;
+import tools.jackson.databind.ObjectReader;
+
+public class JacksonTrpcDecoder implements TrpcDecoder {
+
+ final ObjectReader reader;
+
+ public JacksonTrpcDecoder(ObjectReader reader) {
+ this.reader = reader;
+ }
+
+ @Override
+ public T decode(String name, byte[] payload) {
+ return reader.readValue(payload);
+ }
+
+ @Override
+ public T decode(String name, String payload) {
+ return reader.readValue(payload);
+ }
+}
diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java
new file mode 100644
index 0000000000..f6e8f90da0
--- /dev/null
+++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java
@@ -0,0 +1,43 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson3;
+
+import java.lang.reflect.Type;
+
+import io.jooby.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcReader;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.ObjectMapper;
+
+public class JacksonTrpcParser implements TrpcParser {
+
+ private final ObjectMapper mapper;
+
+ public JacksonTrpcParser(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public TrpcDecoder decoder(Type type) {
+ // Resolve the type exactly once at startup
+ var javaType = mapper.constructType(type);
+ // FIX: Tell Jackson not to panic when it sees the ']' or ',' immediately following the object
+ // in the stream.
+ var reader = mapper.readerFor(javaType).without(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ return new JacksonTrpcDecoder<>(reader);
+ }
+
+ @Override
+ public TrpcReader reader(byte[] payload, boolean isTuple) {
+ return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
+ }
+
+ @Override
+ public TrpcReader reader(String payload, boolean isTuple) {
+ return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
+ }
+}
diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java
new file mode 100644
index 0000000000..5f19573242
--- /dev/null
+++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java
@@ -0,0 +1,118 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson3;
+
+import io.jooby.exception.MissingValueException;
+import io.jooby.trpc.TrpcDecoder;
+import io.jooby.trpc.TrpcReader;
+import tools.jackson.core.JsonParser;
+import tools.jackson.core.JsonToken;
+
+public class JacksonTrpcReader implements TrpcReader {
+ private final JsonParser parser;
+ private boolean hasPeeked = false;
+ private final boolean isTuple;
+ private boolean isFirstRead = true;
+
+ public JacksonTrpcReader(JsonParser parser, boolean isTuple) {
+ this.parser = parser;
+ this.isTuple = isTuple;
+ var token = parser.nextToken();
+ if (isTuple && token != tools.jackson.core.JsonToken.START_ARRAY) {
+ throw new IllegalArgumentException("Expected tRPC tuple array");
+ }
+ }
+
+ @Override
+ public boolean nextIsNull(String name) {
+ if (!hasPeeked) {
+ advance(name);
+ hasPeeked = true;
+ }
+
+ if (parser.currentToken() == JsonToken.VALUE_NULL) {
+ hasPeeked = false; // Consume the null token
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ensureNext(String name) {
+ if (hasPeeked) {
+ hasPeeked = false;
+ return;
+ }
+ advance(name);
+ }
+
+ private void ensureNonNull(String name) {
+ if (parser.currentToken() == JsonToken.VALUE_NULL) throw new MissingValueException(name);
+ }
+
+ private void advance(String name) {
+ // If it's a seamless raw value, we are ALREADY on the token. Do not advance.
+ if (!isTuple) {
+ if (!isFirstRead) throw new MissingValueException(name);
+ isFirstRead = false;
+ // The constructor already positioned us on the root token. Do not advance.
+ return;
+ }
+
+ var token = parser.nextToken();
+ if (token == tools.jackson.core.JsonToken.END_ARRAY || token == null) {
+ throw new MissingValueException(name);
+ }
+ }
+
+ @Override
+ public int nextInt(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return parser.getIntValue();
+ }
+
+ @Override
+ public long nextLong(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return parser.getLongValue();
+ }
+
+ @Override
+ public boolean nextBoolean(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return parser.getBooleanValue();
+ }
+
+ @Override
+ public double nextDouble(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return parser.getDoubleValue();
+ }
+
+ @Override
+ public String nextString(String name) {
+ ensureNext(name);
+ ensureNonNull(name);
+ return parser.getString();
+ }
+
+ @Override
+ public T nextObject(String name, TrpcDecoder decoder) {
+ ensureNext(name);
+ ensureNonNull(name);
+ JacksonTrpcDecoder jacksonDecoder = (JacksonTrpcDecoder) decoder;
+ return jacksonDecoder.reader.readValue(parser);
+ }
+
+ @Override
+ public void close() {
+ parser.close();
+ }
+}
diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java
new file mode 100644
index 0000000000..91bd5e72d9
--- /dev/null
+++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java
@@ -0,0 +1,37 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.internal.jackson3;
+
+import io.jooby.trpc.TrpcResponse;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ser.std.StdSerializer;
+
+public class JacksonTrpcResponseSerializer extends StdSerializer {
+ public JacksonTrpcResponseSerializer() {
+ super(TrpcResponse.class);
+ }
+
+ @Override
+ public void serialize(TrpcResponse value, JsonGenerator gen, SerializationContext provider)
+ throws JacksonException {
+ gen.writeStartObject(); // {
+
+ gen.writeName("result");
+ gen.writeStartObject(); // "result": {
+
+ var data = value.data();
+ // Only write the "data" key if the method actually returned something (not void/Unit)
+ if (data != null) {
+ gen.writeName("data");
+ gen.writePOJO(data);
+ }
+
+ gen.writeEndObject();
+ gen.writeEndObject();
+ }
+}
diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java
index 32b92b5884..b7149a65da 100644
--- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java
+++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java
@@ -17,13 +17,17 @@
import com.fasterxml.jackson.annotation.JsonFilter;
import edu.umd.cs.findbugs.annotations.NonNull;
import io.jooby.*;
+import io.jooby.internal.jackson3.JacksonTrpcParser;
+import io.jooby.internal.jackson3.JacksonTrpcResponseSerializer;
import io.jooby.output.Output;
+import io.jooby.trpc.TrpcErrorCode;
+import io.jooby.trpc.TrpcParser;
+import io.jooby.trpc.TrpcResponse;
import tools.jackson.core.exc.StreamReadException;
-import tools.jackson.databind.JacksonModule;
-import tools.jackson.databind.JsonNode;
-import tools.jackson.databind.ObjectMapper;
-import tools.jackson.databind.ObjectWriter;
+import tools.jackson.databind.*;
+import tools.jackson.databind.exc.MismatchedInputException;
import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.module.SimpleModule;
import tools.jackson.databind.ser.std.SimpleFilterProvider;
import tools.jackson.databind.type.TypeFactory;
@@ -141,9 +145,15 @@ public void install(@NonNull Jooby application) {
Class mapperType = mapper.getClass();
services.put(mapperType, mapper);
services.put(ObjectMapper.class, mapper);
+ // tRPC
+ services.put(TrpcParser.class, new JacksonTrpcParser(mapper));
// Parsing exception as 400
application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST);
+ services
+ .mapOf(Class.class, TrpcErrorCode.class)
+ .put(StreamReadException.class, TrpcErrorCode.BAD_REQUEST)
+ .put(MismatchedInputException.class, TrpcErrorCode.BAD_REQUEST);
application.onStarting(() -> onStarting(application, services, mapperType));
@@ -217,6 +227,9 @@ public static ObjectMapper create(JacksonModule... modules) {
JsonMapper.Builder builder = JsonMapper.builder();
Stream.of(modules).forEach(builder::addModule);
+ var trpcModule = new SimpleModule();
+ trpcModule.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer());
+ builder.addModule(trpcModule);
return builder.build();
}
diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml
index af499970ce..e564790411 100644
--- a/modules/jooby-maven-plugin/pom.xml
+++ b/modules/jooby-maven-plugin/pom.xml
@@ -29,6 +29,12 @@
${jooby.version}
+
+ io.jooby
+ jooby-trpc
+ ${jooby.version}
+
+
org.apache.maven
maven-plugin-api
diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java
new file mode 100644
index 0000000000..bd767ff292
--- /dev/null
+++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java
@@ -0,0 +1,143 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.maven;
+
+import static org.apache.maven.plugins.annotations.LifecyclePhase.PROCESS_CLASSES;
+import static org.apache.maven.plugins.annotations.ResolutionScope.COMPILE_PLUS_RUNTIME;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.project.MavenProject;
+
+import cz.habarta.typescript.generator.DateMapping;
+import cz.habarta.typescript.generator.EnumMapping;
+import cz.habarta.typescript.generator.JsonLibrary;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jooby.trpc.TrpcGenerator;
+
+/**
+ * Generate a tRpc script file from a jooby application.
+ *
+ * Usage: https://jooby.io/modules/trpc
+ *
+ * @author edgar
+ * @since 4.0.17
+ */
+@Mojo(
+ name = "tRPC",
+ threadSafe = true,
+ requiresDependencyResolution = COMPILE_PLUS_RUNTIME,
+ aggregator = true,
+ defaultPhase = PROCESS_CLASSES)
+public class TrpcMojo extends BaseMojo {
+
+ /** Custom mapping overrides translating Java types to raw TypeScript strings. */
+ @Parameter private Map customTypeMappings;
+
+ @Parameter private Map customTypeNaming;
+
+ /** The target JSON library used to parse field annotations. Defaults to jackson2. */
+ @Parameter(defaultValue = "jackson2", property = "jooby.trpc.jsonLibrary")
+ private JsonLibrary jsonLibrary = JsonLibrary.jackson2;
+
+ @Parameter(defaultValue = "asString", property = "jooby.trpc.mapDate")
+ private DateMapping mapDate = DateMapping.asString;
+
+ @Parameter(defaultValue = "asInlineUnion", property = "jooby.trpc.mapEnum")
+ private EnumMapping mapEnum = EnumMapping.asInlineUnion;
+
+ @Parameter(defaultValue = "${project.build.outputDirectory}", property = "jooby.trpc.outputDir")
+ private File outputDir;
+
+ @Parameter(defaultValue = "trpc.d.ts", property = "jooby.trpc.outputFile")
+ private String outputFile;
+
+ @Parameter private List importDeclarations;
+
+ @Override
+ protected void doExecute(@NonNull List projects, @NonNull String mainClass)
+ throws Exception {
+ var classLoader = createClassLoader(projects);
+
+ var generator = new TrpcGenerator();
+ generator.setClassLoader(classLoader);
+ generator.setOutputDir(outputDir.toPath());
+ generator.setOutputFile(outputFile);
+
+ if (customTypeMappings != null) {
+ generator.setCustomTypeMappings(customTypeMappings);
+ }
+ generator.setJsonLibrary(jsonLibrary);
+ generator.setMapDate(mapDate);
+ generator.setMapEnum(mapEnum);
+ if (importDeclarations != null) {
+ generator.setImportDeclarations(importDeclarations);
+ }
+ if (customTypeNaming != null) {
+ generator.setCustomTypeNaming(customTypeNaming);
+ }
+
+ getLog().info("Generating: " + outputDir.toPath().resolve(outputFile));
+
+ generator.generate();
+ }
+
+ public String getOutputFile() {
+ return outputFile;
+ }
+
+ public void setOutputFile(String outputFile) {
+ this.outputFile = outputFile;
+ }
+
+ public File getOutputDir() {
+ return outputDir;
+ }
+
+ public void setOutputDir(File outputDir) {
+ this.outputDir = outputDir;
+ }
+
+ public Map getCustomTypeMappings() {
+ return customTypeMappings;
+ }
+
+ public void setCustomTypeMappings(Map customTypeMappings) {
+ this.customTypeMappings = customTypeMappings;
+ }
+
+ public Map getCustomTypeNaming() {
+ return customTypeNaming;
+ }
+
+ public void setCustomTypeNaming(Map customTypeNaming) {
+ this.customTypeNaming = customTypeNaming;
+ }
+
+ public JsonLibrary getJsonLibrary() {
+ return jsonLibrary;
+ }
+
+ public DateMapping getMapDate() {
+ return mapDate;
+ }
+
+ public EnumMapping getMapEnum() {
+ return mapEnum;
+ }
+
+ public List getImportDeclarations() {
+ return importDeclarations;
+ }
+
+ public void setImportDeclarations(List importDeclarations) {
+ this.importDeclarations = importDeclarations;
+ }
+}
diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java
index 8f9046e07f..5b65339e9c 100644
--- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java
+++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java
@@ -142,7 +142,7 @@ public List write(
}
}
- private Logger log = LoggerFactory.getLogger(getClass());
+ private final Logger log = LoggerFactory.getLogger(getClass());
private Set debug;
diff --git a/modules/jooby-trpc/pom.xml b/modules/jooby-trpc/pom.xml
new file mode 100644
index 0000000000..3978b4b6d1
--- /dev/null
+++ b/modules/jooby-trpc/pom.xml
@@ -0,0 +1,142 @@
+
+
+
+ 4.0.0
+
+
+ io.jooby
+ modules
+ 4.0.17-SNAPSHOT
+
+ jooby-trpc
+ jooby-trpc
+
+
+
+ io.jooby
+ jooby
+ ${jooby.version}
+
+
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+
+
+
+ io.github.classgraph
+ classgraph
+ 4.8.184
+
+
+
+ cz.habarta.typescript-generator
+ typescript-generator-core
+ 3.2.1263
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+ org.jacoco
+ org.jacoco.agent
+ runtime
+ test
+
+
+
+ org.slf4j
+ slf4j-simple
+ ${slf4j.version}
+ test
+
+
+
+ io.jooby
+ jooby-kotlin
+ ${jooby.version}
+ test
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+ test
+
+
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ test
+
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ io.projectreactor
+ reactor-core
+ 3.8.3
+
+
+ org.assertj
+ assertj-core
+ 3.27.7
+ test
+
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+
+
+ compile
+ none
+
+
+ test-compile
+
+
+ ${project.basedir}/src/test/kotlin
+ ${project.basedir}/src/test/java
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven-compiler-plugin.version}
+
+ none
+
+
+
+ default-testCompile
+ none
+
+
+ java-test-compile
+
+ testCompile
+
+ test-compile
+
+
+
+
+
+
diff --git a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java
new file mode 100644
index 0000000000..0052180766
--- /dev/null
+++ b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java
@@ -0,0 +1,748 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc;
+
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import cz.habarta.typescript.generator.DateMapping;
+import cz.habarta.typescript.generator.EnumMapping;
+import cz.habarta.typescript.generator.Input;
+import cz.habarta.typescript.generator.JsonLibrary;
+import cz.habarta.typescript.generator.Output;
+import cz.habarta.typescript.generator.Settings;
+import cz.habarta.typescript.generator.TypeScriptFileType;
+import cz.habarta.typescript.generator.TypeScriptGenerator;
+import cz.habarta.typescript.generator.TypeScriptOutputKind;
+import io.github.classgraph.ClassGraph;
+
+/**
+ * A generator that orchestrates {@code typescript-generator} to produce a tRPC-compatible
+ * TypeScript API definition from compiled Jooby controllers.
+ *
+ * This tool bypasses the standard REST scanner of {@code typescript-generator}. Instead, it:
+ *
+ *
+ * Scans the classpath via the provided ClassLoader for controllers marked with {@code @Trpc}.
+ * Extracts only the input and return types (DTOs) of the matching methods.
+ * Feeds those data models to the generator to produce clean TypeScript interfaces.
+ * Uses a fast, recursive type resolver to accurately map Java methods to tRPC {@code { input,
+ * output }} shapes.
+ * Appends a strict {@code AppRouter} definition to the generated file.
+ *
+ */
+public class TrpcGenerator {
+
+ private static final Set WRAPPERS =
+ Set.of(
+ // Protocol Envelope
+ "io.jooby.trpc.TrpcResponse",
+ // JDK Async
+ "java.util.concurrent.CompletableFuture",
+ "java.util.concurrent.CompletionStage",
+ "java.util.concurrent.Future",
+ // Reactor
+ "reactor.core.publisher.Mono",
+ "reactor.core.publisher.Flux",
+ // Mutiny
+ "io.smallrye.mutiny.Uni",
+ "io.smallrye.mutiny.Multi",
+ // RxJava 2 & 3
+ "io.reactivex.Single",
+ "io.reactivex.Maybe",
+ "io.reactivex.Observable",
+ "io.reactivex.Flowable",
+ "io.reactivex.Completable",
+ "io.reactivex.rxjava3.core.Single",
+ "io.reactivex.rxjava3.core.Maybe",
+ "io.reactivex.rxjava3.core.Observable",
+ "io.reactivex.rxjava3.core.Flowable",
+ "io.reactivex.rxjava3.core.Completable");
+
+ private final Logger log = LoggerFactory.getLogger(getClass());
+
+ private ClassLoader classLoader = getClass().getClassLoader();
+ private Path outputDir;
+ private String outputFile = "trpc.d.ts";
+
+ private final Set> manualControllers = new LinkedHashSet<>();
+
+ private JsonLibrary jsonLibrary = JsonLibrary.jackson2;
+ private Map customTypeMappings = new LinkedHashMap<>();
+ private Map customTypeNaming = new LinkedHashMap<>();
+ private List importDeclarations = new ArrayList<>();
+ private DateMapping mapDate = DateMapping.asString;
+ private EnumMapping mapEnum = EnumMapping.asInlineUnion;
+
+ /**
+ * Executes the full TypeScript and tRPC generation pipeline.
+ *
+ * @throws IOException If an I/O error occurs reading classes or writing the output file.
+ * @throws IllegalStateException If {@code outputDir} is not configured or if no controllers are
+ * found.
+ */
+ public void generate() throws IOException {
+ if (outputDir == null) {
+ throw new IllegalStateException("outputDir is required to generate the TypeScript file.");
+ }
+
+ var finalOutput = outputDir.resolve(outputFile);
+ if (!Files.exists(outputDir)) {
+ Files.createDirectories(outputDir);
+ }
+
+ var controllers = discoverControllers();
+ controllers.addAll(manualControllers);
+
+ if (controllers.isEmpty()) {
+ throw new IllegalStateException(
+ "No controllers were found to generate. "
+ + "Ensure the 'classLoader' has access to compiled classes, "
+ + "or use 'addController(Class)' to manually register controllers for unit testing.");
+ }
+
+ // 1. Extract ONLY the Data Models (Inputs/Outputs)
+ var typesToGenerate = new LinkedHashSet();
+ for (var controller : controllers) {
+ for (var method : controller.getDeclaredMethods()) {
+ if (isTrpcAnnotated(method)) {
+ addTypeToGenerate(typesToGenerate, method.getGenericReturnType());
+ for (var param : method.getGenericParameterTypes()) {
+ String typeName = param.getTypeName();
+ if (!typeName.equals("io.jooby.Context")
+ && !typeName.startsWith("kotlin.coroutines.Continuation")) {
+ addTypeToGenerate(typesToGenerate, param);
+ }
+ }
+ }
+ }
+ }
+
+ var settings = new Settings();
+ settings.outputFileType = TypeScriptFileType.declarationFile;
+ settings.outputKind = TypeScriptOutputKind.module;
+ settings.classLoader = classLoader;
+ settings.jsonLibrary = this.jsonLibrary;
+ settings.mapDate = this.mapDate;
+ settings.mapEnum = this.mapEnum;
+ if (customTypeMappings != null) settings.customTypeMappings.putAll(customTypeMappings);
+ if (customTypeNaming != null) settings.customTypeNaming.putAll(customTypeNaming);
+ if (importDeclarations != null) settings.importDeclarations.addAll(importDeclarations);
+
+ // 2. Generate standard interfaces (DTOs only)
+ if (!typesToGenerate.isEmpty()) {
+ TypeScriptGenerator.setLogger(asSlf4j(log));
+
+ /// FIREWALL: Force the generator to ignore all reactive wrappers if discovered indirectly.
+ // typescript-generator strictly demands exact generic signatures (e.g. Future).
+ // We dynamically append the type variables using reflection to satisfy its parser.
+ for (var wrapper : WRAPPERS) {
+ try {
+ var clazz = Class.forName(wrapper, false, classLoader);
+ var key = new StringBuilder(wrapper);
+ var typeParams = clazz.getTypeParameters();
+
+ if (typeParams.length > 0) {
+ key.append("<");
+ for (int i = 0; i < typeParams.length; i++) {
+ if (i > 0) key.append(", ");
+ key.append(typeParams[i].getName());
+ }
+ key.append(">");
+ }
+
+ settings.customTypeMappings.put(key.toString(), "any");
+ } catch (ClassNotFoundException | NoClassDefFoundError ignored) {
+ // Class not on classpath, safe to ignore
+ }
+ }
+
+ var generator = new TypeScriptGenerator(settings);
+ var input = Input.from(typesToGenerate.toArray(new Type[0]));
+ generator.generateTypeScript(input, Output.to(finalOutput.toFile()));
+ }
+
+ // Safety net: If typescript-generator skipped generation (e.g., only primitive types), create
+ // the base file.
+ if (!Files.exists(finalOutput)) {
+ Files.writeString(finalOutput, "/* tslint:disable */\n/* eslint-disable */\n\n");
+ }
+
+ // 3. Append the exact tRPC AppRouter
+ appendTrpcRouter(finalOutput, controllers);
+ }
+
+ private static cz.habarta.typescript.generator.Logger asSlf4j(Logger log) {
+ return new cz.habarta.typescript.generator.Logger() {
+ @Override
+ protected void write(Level level, String message) {
+ switch (level) {
+ case Info -> log.info(message);
+ case Warning -> log.warn(message.replace("Warning: ", ""));
+ case Error -> log.error(message.replace("Error: ", ""));
+ case Debug -> log.debug(message.replace("Debug: ", ""));
+ case Verbose -> log.trace(message);
+ }
+ }
+ };
+ }
+
+ private void addTypeToGenerate(Set types, Type type) {
+ if (type == void.class || type == Void.class) return;
+
+ if (type instanceof ParameterizedType pt) {
+ var rawName = pt.getRawType().getTypeName();
+
+ // Unwrap async and protocol wrapper types
+ if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) {
+ addTypeToGenerate(types, pt.getActualTypeArguments()[0]);
+ return;
+ }
+
+ // Decompose standard generic types to ensure inner DTOs are discovered safely
+ addTypeToGenerate(types, pt.getRawType());
+ for (Type arg : pt.getActualTypeArguments()) {
+ addTypeToGenerate(types, arg);
+ }
+ return;
+ } else if (type instanceof Class> clazz) {
+ // Ignore bare async types (like RxJava Completable) that signify void
+ if (WRAPPERS.contains(clazz.getName())) return;
+
+ if (clazz.isArray()) {
+ addTypeToGenerate(types, clazz.getComponentType());
+ return;
+ }
+ } else if (type instanceof java.lang.reflect.WildcardType wt) {
+ for (Type bound : wt.getUpperBounds()) {
+ if (bound != Object.class) addTypeToGenerate(types, bound);
+ }
+ return;
+ } else if (type instanceof java.lang.reflect.GenericArrayType gat) {
+ addTypeToGenerate(types, gat.getGenericComponentType());
+ return;
+ }
+
+ types.add(type);
+ }
+
+ /**
+ * Constructs and appends the tRPC {@code AppRouter} mapping to the bottom of the generated file.
+ *
+ * @param finalOutput The path to the generated output file.
+ * @param controllers The set of validated controller classes.
+ * @throws IOException If file writing fails.
+ */
+ private void appendTrpcRouter(Path finalOutput, Set> controllers) throws IOException {
+ var ts = new StringBuilder();
+
+ ts.append("\n// --- tRPC Router Mapping ---\n\n");
+ ts.append("export type AppRouter = {\n");
+
+ // 1. Group by namespace using a TreeMap to guarantee deterministic alphabetical order
+ Map> namespaces = new java.util.TreeMap<>();
+
+ for (var controller : controllers) {
+ var namespace = extractNamespace(controller);
+ String key = namespace == null ? "" : namespace;
+
+ namespaces.computeIfAbsent(key, k -> new ArrayList<>());
+
+ for (var method : controller.getDeclaredMethods()) {
+ if (isTrpcAnnotated(method)) {
+ namespaces.get(key).add(method);
+ }
+ }
+ }
+
+ // 2. Generate the TypeScript output
+ for (var entry : namespaces.entrySet()) {
+ String namespace = entry.getKey();
+ List methods = entry.getValue();
+
+ if (methods.isEmpty()) continue;
+
+ String indent = " ";
+ if (!namespace.isEmpty()) {
+ ts.append(" ").append(namespace).append(": {\n");
+ indent = " ";
+ }
+
+ // Separate into queries and mutations for deterministic grouping
+ List queries = new ArrayList<>();
+ List mutations = new ArrayList<>();
+
+ for (Method m : methods) {
+ if (isMutation(m)) {
+ mutations.add(m);
+ } else {
+ queries.add(m);
+ }
+ }
+
+ // Sort alphabetically by procedure name to prevent reflection-order test flakiness
+ java.util.Comparator byName = java.util.Comparator.comparing(this::getProcedureName);
+ queries.sort(byName);
+ mutations.sort(byName);
+
+ if (!queries.isEmpty()) {
+ ts.append(indent).append("// queries\n");
+ for (Method method : queries) {
+ appendProcedure(ts, indent, method);
+ }
+ }
+
+ if (!mutations.isEmpty()) {
+ if (!queries.isEmpty()) ts.append("\n"); // Add a blank line between groups if both exist
+ ts.append(indent).append("// mutations\n");
+ for (Method method : mutations) {
+ appendProcedure(ts, indent, method);
+ }
+ }
+
+ if (!namespace.isEmpty()) {
+ ts.append(" };\n");
+ }
+ }
+
+ ts.append("};\n");
+ Files.writeString(finalOutput, ts.toString(), StandardOpenOption.APPEND);
+ }
+
+ private void appendProcedure(StringBuilder ts, String indent, Method method) {
+ // Filter out framework parameters so they don't appear in the TypeScript signature
+ var payloadParams = new ArrayList();
+ for (var p : method.getGenericParameterTypes()) {
+ String typeName = p.getTypeName();
+ if (!typeName.equals("io.jooby.Context")
+ && !typeName.startsWith("kotlin.coroutines.Continuation")) {
+ payloadParams.add(p);
+ }
+ }
+
+ String tsInput = "void";
+ if (payloadParams.size() == 1) {
+ tsInput = resolveTsType(payloadParams.get(0));
+ } else if (payloadParams.size() > 1) {
+ var tuple = new ArrayList();
+ for (var p : payloadParams) tuple.add(resolveTsType(p));
+ tsInput = "[" + String.join(", ", tuple) + "]";
+ }
+
+ String tsOutput = resolveTsType(method.getGenericReturnType());
+ String procedureName = getProcedureName(method);
+
+ ts.append(indent)
+ .append(procedureName)
+ .append(": { input: ")
+ .append(tsInput)
+ .append("; ")
+ .append("output: ")
+ .append(tsOutput)
+ .append(" };\n");
+ }
+
+ private boolean isMutation(Method method) {
+ // 1. Explicit tRPC mutation annotation
+ if (getAnnotation(method, "io.jooby.annotation.Trpc$Mutation") != null) {
+ return true;
+ }
+ // 2. Explicit tRPC query annotation
+ if (getAnnotation(method, "io.jooby.annotation.Trpc$Query") != null) {
+ return false;
+ }
+
+ // 3. Base @Trpc combined with standard HTTP mutation annotations
+ String[] httpMutations = {
+ "io.jooby.annotation.POST", "io.jooby.annotation.PUT",
+ "io.jooby.annotation.PATCH", "io.jooby.annotation.DELETE",
+ "jakarta.ws.rs.POST", "jakarta.ws.rs.PUT",
+ "jakarta.ws.rs.PATCH", "jakarta.ws.rs.DELETE",
+ "javax.ws.rs.POST", "javax.ws.rs.PUT",
+ "javax.ws.rs.PATCH", "javax.ws.rs.DELETE"
+ };
+
+ for (String ann : httpMutations) {
+ if (getAnnotation(method, ann) != null) {
+ return true;
+ }
+ }
+
+ return false; // Default to query
+ }
+
+ /**
+ * Fast, recursive type resolver to map Java types directly to TypeScript signatures. Understands
+ * Jooby async types, standard collections, and primitive mappings.
+ *
+ * @param type The Java type to evaluate.
+ * @return A valid TypeScript string representation of the type.
+ */
+ private String resolveTsType(Type type) {
+ if (type == void.class || type == Void.class) return "void";
+
+ if (type instanceof ParameterizedType pt) {
+ var raw = pt.getRawType();
+ var rawName = raw.getTypeName();
+
+ // Unwrap async and protocol wrapper types
+ if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) {
+ return resolveTsType(pt.getActualTypeArguments()[0]);
+ }
+
+ if (raw instanceof Class> clazz) {
+ if (java.util.Collection.class.isAssignableFrom(clazz)) {
+ return resolveTsType(pt.getActualTypeArguments()[0]) + "[]";
+ }
+ if (java.util.Map.class.isAssignableFrom(clazz)) {
+ return "{ [index: string]: " + resolveTsType(pt.getActualTypeArguments()[1]) + " }";
+ }
+ if (java.util.Optional.class.isAssignableFrom(clazz)) {
+ return resolveTsType(pt.getActualTypeArguments()[0]) + " | null";
+ }
+
+ // Handle generic DTOs
+ var args = pt.getActualTypeArguments();
+ var tsArgs = new ArrayList();
+ for (var arg : args) tsArgs.add(resolveTsType(arg));
+ return getClassName(clazz) + "<" + String.join(", ", tsArgs) + ">";
+ }
+ }
+
+ if (type instanceof java.lang.reflect.WildcardType wt) {
+ if (wt.getUpperBounds().length > 0 && wt.getUpperBounds()[0] != Object.class) {
+ return resolveTsType(wt.getUpperBounds()[0]);
+ }
+ return "any";
+ }
+
+ if (type instanceof java.lang.reflect.GenericArrayType gat) {
+ return resolveTsType(gat.getGenericComponentType()) + "[]";
+ }
+
+ if (type instanceof Class> clazz) {
+ if (clazz.isArray()) {
+ if (clazz.getComponentType() == byte.class)
+ return "string"; // Common byte[] to base64 string
+ return resolveTsType(clazz.getComponentType()) + "[]";
+ }
+
+ // Handle bare async types (like RxJava Completable) as void returns
+ if (WRAPPERS.contains(clazz.getName())) return "void";
+
+ if (clazz == String.class
+ || clazz == char.class
+ || clazz == Character.class
+ || clazz.getName().equals("java.util.UUID")) return "string";
+ if (clazz == boolean.class || clazz == Boolean.class) return "boolean";
+ if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) return "number";
+
+ if (java.util.Date.class.isAssignableFrom(clazz)
+ || clazz.getName().startsWith("java.time.")) {
+ return mapDate == DateMapping.asString
+ ? "string"
+ : (mapDate == DateMapping.asNumber ? "number" : "Date");
+ }
+
+ return getClassName(clazz);
+ }
+
+ return "any";
+ }
+
+ /**
+ * Evaluates the custom mappings to determine the appropriate TypeScript interface name.
+ *
+ * @param clazz The Java class being resolved.
+ * @return The target TypeScript interface name.
+ */
+ private String getClassName(Class> clazz) {
+ var fqn = clazz.getName();
+ if (customTypeMappings != null && customTypeMappings.containsKey(fqn)) {
+ return customTypeMappings.get(fqn);
+ }
+ if (customTypeNaming != null && customTypeNaming.containsKey(fqn)) {
+ return customTypeNaming.get(fqn);
+ }
+ return clazz.getSimpleName();
+ }
+
+ /**
+ * Scans the classloader to load and validate controller classes.
+ *
+ * @return A set of valid controller classes found on disk.
+ */
+ private Set> discoverControllers() {
+ var controllers = new LinkedHashSet>();
+
+ var classGraph =
+ new ClassGraph()
+ .enableClassInfo()
+ .enableAnnotationInfo()
+ .enableMethodInfo()
+ .ignoreClassVisibility();
+
+ if (classLoader != null) {
+ classGraph.overrideClassLoaders(classLoader);
+ }
+
+ try (var scanResult = classGraph.scan()) {
+ for (var classInfo : scanResult.getAllClasses()) {
+ try {
+ var clazz = classInfo.loadClass(false); // loads without initializing!
+
+ if (isTrpcAnnotated(clazz)) {
+ controllers.add(clazz);
+ } else {
+ // Check methods if the class itself isn't annotated
+ for (var method : clazz.getDeclaredMethods()) {
+ if (isTrpcAnnotated(method)) {
+ controllers.add(clazz);
+ break;
+ }
+ }
+ }
+ } catch (Throwable ignored) {
+ // Safely ignore classes that throw LinkageError or NoClassDefFoundError
+ }
+ }
+ }
+
+ return controllers;
+ }
+
+ /** Retrieves an annotation safely, supporting nested annotation syntax differences. */
+ private Annotation getAnnotation(AnnotatedElement element, String annotationName) {
+ for (Annotation a : element.getAnnotations()) {
+ String name = a.annotationType().getName();
+ if (name.equals(annotationName)
+ || name.replace('$', '.').equals(annotationName.replace('$', '.'))) {
+ return a;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * ClassLoader-agnostic check to see if an element has a Trpc annotation. Matches the APT
+ * precedence.
+ *
+ * @param element The class or method to inspect.
+ * @return True if annotated with `@Trpc`, `@Trpc.Query`, or `@Trpc.Mutation`.
+ */
+ private boolean isTrpcAnnotated(AnnotatedElement element) {
+ return getAnnotation(element, "io.jooby.annotation.Trpc") != null
+ || getAnnotation(element, "io.jooby.annotation.Trpc$Query") != null
+ || getAnnotation(element, "io.jooby.annotation.Trpc$Mutation") != null;
+ }
+
+ /**
+ * Evaluates the exact tRPC procedure name based on annotation values, defaulting to the method
+ * name. Respects the precedence hierarchy: .Query / .Mutation > Base @Trpc.
+ */
+ private String getProcedureName(Method method) {
+ String[] procedureAnnotations = {
+ "io.jooby.annotation.Trpc$Query",
+ "io.jooby.annotation.Trpc$Mutation",
+ "io.jooby.annotation.Trpc"
+ };
+
+ for (String annName : procedureAnnotations) {
+ Annotation a = getAnnotation(method, annName);
+ if (a != null) {
+ try {
+ var valueMethod = a.annotationType().getMethod("value");
+ var value = (String) valueMethod.invoke(a);
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ return method.getName();
+ }
+
+ /**
+ * Extracts the target namespace for the tRPC router based on the controller. If the class is not
+ * annotated with @Trpc, or if its value is empty, it returns null (indicating the methods belong
+ * to the root namespace).
+ *
+ * @param controller The controller class.
+ * @return The determined namespace string, or null for root-level.
+ */
+ private String extractNamespace(Class> controller) {
+ Annotation trpc = getAnnotation(controller, "io.jooby.annotation.Trpc");
+ if (trpc != null) {
+ try {
+ var method = trpc.annotationType().getMethod("value");
+ var value = (String) method.invoke(trpc);
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ return null; // Root namespace
+ }
+
+ // --- Configuration API (Getters, Setters, and Builders) ---
+
+ /**
+ * Explicitly adds a controller class to the generation pipeline. Highly recommended for unit
+ * testing to avoid classpath scanning issues.
+ *
+ * @param controller The controller class to analyze.
+ */
+ public void addController(Class> controller) {
+ this.manualControllers.add(controller);
+ }
+
+ /**
+ * @return The class loader used to load compiled controllers.
+ */
+ public ClassLoader getClassLoader() {
+ return classLoader;
+ }
+
+ /**
+ * @param classLoader The class loader used to load compiled controllers. Defaults to context
+ * class loader.
+ */
+ public void setClassLoader(ClassLoader classLoader) {
+ if (classLoader != null) this.classLoader = classLoader;
+ }
+
+ /**
+ * @return The destination directory for the generated TypeScript file.
+ */
+ public Path getOutputDir() {
+ return outputDir;
+ }
+
+ /**
+ * @param outputDir The destination directory for the generated TypeScript file.
+ */
+ public void setOutputDir(Path outputDir) {
+ this.outputDir = outputDir;
+ }
+
+ /**
+ * @return The name of the generated TypeScript file.
+ */
+ public String getOutputFile() {
+ return outputFile;
+ }
+
+ /**
+ * @param outputFile The name of the generated TypeScript file. Defaults to {@code trpc.d.ts}.
+ */
+ public void setOutputFile(String outputFile) {
+ if (outputFile != null && !outputFile.isBlank()) this.outputFile = outputFile;
+ }
+
+ /**
+ * @return The target JSON library for data model generation.
+ */
+ public JsonLibrary getJsonLibrary() {
+ return jsonLibrary;
+ }
+
+ /**
+ * @param jsonLibrary The target JSON library used to parse field annotations. Defaults to Jackson
+ * 2.
+ */
+ public void setJsonLibrary(JsonLibrary jsonLibrary) {
+ if (jsonLibrary != null) this.jsonLibrary = jsonLibrary;
+ }
+
+ /**
+ * @return Custom mapping overrides translating Java types to raw TypeScript strings.
+ */
+ public Map getCustomTypeMappings() {
+ return customTypeMappings;
+ }
+
+ /**
+ * @param customTypeMappings Custom mapping overrides translating Java types to raw TypeScript
+ * strings.
+ */
+ public void setCustomTypeMappings(Map customTypeMappings) {
+ this.customTypeMappings = customTypeMappings;
+ }
+
+ /**
+ * @return Custom overrides for generating specific TypeScript interface names.
+ */
+ public Map getCustomTypeNaming() {
+ return customTypeNaming;
+ }
+
+ /**
+ * @param customTypeNaming Custom overrides for generating specific TypeScript interface names.
+ */
+ public void setCustomTypeNaming(Map customTypeNaming) {
+ this.customTypeNaming = customTypeNaming;
+ }
+
+ /**
+ * @return Raw import statements appended to the top of the generated file.
+ */
+ public List getImportDeclarations() {
+ return importDeclarations;
+ }
+
+ /**
+ * @param importDeclarations Raw import statements appended to the top of the generated file.
+ */
+ public void setImportDeclarations(List importDeclarations) {
+ this.importDeclarations = importDeclarations;
+ }
+
+ /**
+ * @return The mapping strategy applied to Java date types.
+ */
+ public DateMapping getMapDate() {
+ return mapDate;
+ }
+
+ /**
+ * @param mapDate The mapping strategy applied to Java date types.
+ */
+ public void setMapDate(DateMapping mapDate) {
+ this.mapDate = mapDate;
+ }
+
+ /**
+ * @return The mapping strategy applied to Java enum types.
+ */
+ public EnumMapping getMapEnum() {
+ return mapEnum;
+ }
+
+ /**
+ * @param mapEnum The mapping strategy applied to Java enum types.
+ */
+ public void setMapEnum(EnumMapping mapEnum) {
+ this.mapEnum = mapEnum;
+ }
+}
diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java
new file mode 100644
index 0000000000..1c7347cfa2
--- /dev/null
+++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java
@@ -0,0 +1,45 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc.i3863;
+
+import java.util.concurrent.CompletableFuture;
+
+import io.jooby.Context;
+import io.jooby.annotation.*;
+import reactor.core.publisher.Mono;
+
+@Path("/users")
+@Trpc("users") // Custom namespace
+public class C3863 {
+
+ @GET("/{id}")
+ @Trpc
+ public U3863 getUser(Context ctx, String id) {
+ return new U3863(id, "user");
+ }
+
+ @POST
+ @Trpc
+ public U3863 createUser(U3863 user) {
+ return user;
+ }
+
+ @PUT
+ @Trpc
+ public CompletableFuture createFuture(U3863 user) {
+ return CompletableFuture.completedFuture(user);
+ }
+
+ @Trpc.Mutation
+ public Mono createMono(U3863 user) {
+ return Mono.just(user);
+ }
+
+ @GET("/internal")
+ public String internalEndpoint() {
+ return "This should not be exposed to tRPC";
+ }
+}
diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java
new file mode 100644
index 0000000000..2d4174e373
--- /dev/null
+++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java
@@ -0,0 +1,71 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc.i3863;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+import org.junit.jupiter.api.Test;
+
+import io.jooby.trpc.TrpcGenerator;
+
+class TrpcGeneratorTest {
+
+ @Test
+ void shouldGenerateTrpcRouterAndModels() throws Exception {
+ var generator = new TrpcGenerator();
+ var outputDir = Paths.get("target");
+
+ // Dynamically locate the test-classes directory where the sample is compiled
+ var testClassesDir =
+ Paths.get(C3863.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+
+ generator.setOutputDir(outputDir);
+ generator.setOutputFile("api.d.ts");
+
+ generator.generate();
+
+ var outputFile = outputDir.resolve("api.d.ts");
+ assertTrue(Files.exists(outputFile), "TypeScript file should be generated");
+
+ var actualContent = Files.readString(outputFile);
+
+ var expectedContent =
+ """
+ /* tslint:disable */
+ /* eslint-disable */
+
+ export interface U3863 {
+ id: string;
+ name: string;
+ }
+
+ // --- tRPC Router Mapping ---
+
+ export type AppRouter = {
+ users: {
+ // queries
+ getUser: { input: string; output: U3863 };
+
+ // mutations
+ createFuture: { input: U3863; output: U3863 };
+ createMono: { input: U3863; output: U3863 };
+ createUser: { input: U3863; output: U3863 };
+ };
+ };
+ """;
+
+ // Strip out the dynamic timestamp comment line
+ var cleanActual =
+ actualContent.replaceAll("// Generated using typescript-generator.*\\r?\\n", "");
+
+ // Assert with normalized newlines to avoid \r\n vs \n test flakes
+ assertThat(cleanActual).isEqualToNormalizingNewlines(expectedContent);
+ }
+}
diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java
new file mode 100644
index 0000000000..55d1467b37
--- /dev/null
+++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java
@@ -0,0 +1,8 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.trpc.i3863;
+
+public record U3863(String id, String name) {}
diff --git a/modules/pom.xml b/modules/pom.xml
index 2e05b5bcce..be324dbec9 100644
--- a/modules/pom.xml
+++ b/modules/pom.xml
@@ -32,6 +32,9 @@
jooby-swagger-ui
jooby-redoc
+
+ jooby-trpc
+
jooby-hikari
jooby-jdbi
diff --git a/tests/pom.xml b/tests/pom.xml
index a8c930a1b8..beb8c743e1 100644
--- a/tests/pom.xml
+++ b/tests/pom.xml
@@ -201,7 +201,12 @@
assertj-core
test
-
+
+ com.jayway.jsonpath
+ json-path
+ 3.0.0
+ test
+
org.mockito
mockito-core
diff --git a/tests/src/test/java/io/jooby/i3863/Metadata.java b/tests/src/test/java/io/jooby/i3863/Metadata.java
new file mode 100644
index 0000000000..8e8996ba47
--- /dev/null
+++ b/tests/src/test/java/io/jooby/i3863/Metadata.java
@@ -0,0 +1,11 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.i3863;
+
+import io.avaje.jsonb.Json;
+
+@Json
+public record Metadata(String releaseDate) {}
diff --git a/tests/src/test/java/io/jooby/i3863/Movie.java b/tests/src/test/java/io/jooby/i3863/Movie.java
new file mode 100644
index 0000000000..a5c4238799
--- /dev/null
+++ b/tests/src/test/java/io/jooby/i3863/Movie.java
@@ -0,0 +1,11 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.i3863;
+
+import io.avaje.jsonb.Json;
+
+@Json
+public record Movie(int id, String title, int year) {}
diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java
new file mode 100644
index 0000000000..55e00837db
--- /dev/null
+++ b/tests/src/test/java/io/jooby/i3863/MovieService.java
@@ -0,0 +1,94 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.i3863;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jooby.annotation.*;
+import io.jooby.exception.NotFoundException;
+import reactor.core.publisher.Mono;
+
+@Trpc("movies")
+@Path("/api/movies")
+public class MovieService {
+
+ private final List database =
+ List.of(new Movie(1, "The Godfather", 1972), new Movie(2, "Pulp Fiction", 1994));
+
+ /** Procedure: movies.create Takes a single complex object. */
+ @Trpc.Mutation
+ @POST("/create")
+ public Movie create(Movie movie) {
+ // In a real app, save to DB. For now, just return it.
+ return movie;
+ }
+
+ /** Procedure: movies.create Takes a single complex object. */
+ @Trpc.Mutation
+ public Mono createMono(Movie movie) {
+ // In a real app, save to DB. For now, just return it.
+ return Mono.just(movie);
+ }
+
+ @Trpc.Mutation
+ @PUT
+ public void resetIndex() {}
+
+ /** Procedure: movies.bulkCreate Takes a List of complex objects. */
+ @Trpc.Query
+ public List bulkCreate(List movies) {
+ return movies.stream().map(m -> "Created: " + m.title()).collect(Collectors.toList());
+ }
+
+ /** Procedure: movies.ping */
+ @Trpc.Query
+ public String ping() {
+ return "pong";
+ }
+
+ /** Procedure: movies.getById Single primitive argument */
+ @Trpc
+ @GET("/{id}")
+ public @NonNull Movie getById(@PathParam int id) {
+ return database.stream()
+ .filter(m -> m.id() == id)
+ .findFirst()
+ .orElseThrow(() -> new NotFoundException("Movie not found: " + id));
+ }
+
+ /** Procedure: movies.search Multi-argument (Tuple) */
+ @Trpc.Query
+ public List search(String title, Integer year) {
+ return database.stream()
+ .filter(m -> m.title().contains(title) && (year == null || m.year() == year))
+ .toList();
+ }
+
+ /** Procedure: movies.addReview Mix of String and int (Mutation) */
+ @Trpc.Mutation
+ public Map addReview(String movieTitle, int stars, String comment) {
+ // Business logic...
+ return Map.of(
+ "title", movieTitle,
+ "rating", stars,
+ "status", "published");
+ }
+
+ /** Procedure: movies.addReview Mix of String and int (Mutation) */
+ @Trpc
+ @PUT("/{id}/metadata")
+ public Metadata updateMetadata(@PathParam int id, Metadata metadata) {
+ // Business logic...
+ return metadata;
+ }
+
+ @Trpc
+ @DELETE("/{id}")
+ public void deleteMovie(@PathParam int id) {}
+}
diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java
new file mode 100644
index 0000000000..3ee57fddbc
--- /dev/null
+++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java
@@ -0,0 +1,228 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package io.jooby.i3863;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Map;
+
+import com.jayway.jsonpath.JsonPath;
+import io.jooby.Extension;
+import io.jooby.avaje.jsonb.AvajeJsonbModule;
+import io.jooby.jackson.JacksonModule;
+import io.jooby.jackson3.Jackson3Module;
+import io.jooby.junit.ServerTest;
+import io.jooby.junit.ServerTestRunner;
+import io.jooby.test.WebClient;
+import io.jooby.trpc.TrpcModule;
+
+public class TrpcProtocolTest {
+
+ @ServerTest
+ void shouldTalkTrpcUsingJackson3(ServerTestRunner runner) {
+ shouldTalkTrpc(runner, new Jackson3Module());
+ }
+
+ @ServerTest
+ void shouldTalkTrpcUsingJackson2(ServerTestRunner runner) {
+ shouldTalkTrpc(runner, new JacksonModule());
+ }
+
+ @ServerTest
+ void shouldTalkTrpcUsingAvajeJsonB(ServerTestRunner runner) {
+ shouldTalkTrpc(runner, new AvajeJsonbModule());
+ }
+
+ void shouldTalkTrpc(ServerTestRunner runner, Extension jsonExtension) {
+ runner
+ .define(
+ app -> {
+ app.install(jsonExtension);
+ app.install(new TrpcModule());
+ app.mvc(new MovieService_());
+ })
+ .ready(this::assertProtocolData);
+ }
+
+ void assertProtocolData(WebClient http) {
+ // --- 1. Basic & Multi-Argument Calls ---
+
+ // Ping (No Arguments)
+ http.get(
+ "/trpc/movies.ping",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string())
+ .isEqualToIgnoringNewLines("{\"result\":{\"data\":\"pong\"}}");
+ });
+
+ // GetById (Single Primitive Argument - Seamless)
+ http.get(
+ "/trpc/movies.getById?input=1",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string())
+ .isEqualToIgnoringNewLines(
+ "{\"result\":{\"data\":{\"id\":1,\"title\":\"The Godfather\",\"year\":1972}}}");
+ });
+
+ // GetById (Not found)
+ http.get(
+ "/trpc/movies.getById?input=13",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(404);
+ assertThat(rsp.body().string())
+ .isEqualToIgnoringNewLines(
+ """
+ {"error":{"message":"Movie not found: 13","code":-32004,"data":{"code":"NOT_FOUND","httpStatus":404,"path":"movies.getById"}}}
+ """);
+ });
+
+ // Search (Multi-Argument Tuple)
+ http.get(
+ "/trpc/movies.search?input=[\"Pulp Fiction\", 1994]",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string())
+ .isEqualToIgnoringNewLines(
+ "{\"result\":{\"data\":[{\"id\":2,\"title\":\"Pulp Fiction\",\"year\":1994}]}}");
+ });
+
+ // AddReview (Multi-Argument Mutation)
+ http.postJson(
+ "/trpc/movies.addReview",
+ "[\"The Godfather\", 5, \"Amazing\"]",
+ rsp -> {
+ var json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(JsonPath.read(json, "$.result.data.status")).isEqualTo("published");
+ assertThat(JsonPath.read(json, "$.result.data.rating")).isEqualTo(5);
+ });
+
+ // --- 2. Seamless vs. Tuple Wrappers ---
+ // Create (Strict Seamless Payload for single argument)
+ http.postJson(
+ "/trpc/movies.create",
+ "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(JsonPath.read(json, "$.result.data.title")).isEqualTo("The Matrix");
+ });
+
+ // BulkCreate (Single argument that is inherently an array)
+ // Ensures the parser doesn't get confused by the leading '[' on a seamless payload
+ http.get(
+ "/trpc/movies.bulkCreate",
+ Map.of("input", "[{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}]"),
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(JsonPath.read(json, "$.result.data[0]"))
+ .isEqualTo("Created: The Matrix");
+ });
+
+ // --- 3. Reactive & Void Types ---
+
+ // CreateMono (Reactive Pipeline)
+ http.postJson(
+ "/trpc/movies.createMono",
+ "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(JsonPath.read(json, "$.result.data.id")).isEqualTo(1);
+ });
+
+ // ResetIndex (Void return type)
+ http.post(
+ "/trpc/movies.resetIndex",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string()).contains("\"result\"");
+ });
+
+ // --- 4. Error Handling & Edge Cases ---
+
+ // Error: Type Mismatch
+ http.postJson(
+ "/trpc/movies.addReview",
+ "[\"The Godfather\", \"FIVE_STARS\", \"Amazing\"]",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(400);
+ assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST");
+ assertThat(JsonPath.read(json, "$.error.message")).isNotEmpty();
+ });
+
+ // Error: Missing Tuple Wrapper (Multi-argument method given a raw string)
+ http.get(
+ "/trpc/movies.search?input=\"Pulp Fiction\"",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(400);
+ assertThat(JsonPath.read(json, "$.error.message"))
+ .contains("tRPC input for multiple arguments must be a JSON array (tuple)");
+ });
+
+ // Error: Procedure Not Found (404)
+ http.get(
+ "/trpc/movies.doesNotExist",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(404);
+ assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("NOT_FOUND");
+ });
+
+ // --- 5. Nullability Validation ---
+
+ // Validating a nullable parameter is accepted (Integer)
+ http.get(
+ "/trpc/movies.search?input=[\"The Godfather\", null]",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string()).contains("\"The Godfather\"");
+ });
+
+ // Validating a required (primitive) parameter rejects null (Seamless path)
+ http.get(
+ "/trpc/movies.getById?input=null",
+ rsp -> {
+ String json = rsp.body().string();
+ System.out.println(json);
+ assertThat(rsp.code()).isEqualTo(400);
+ assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST");
+ });
+
+ // Validating MissingValueException (Not enough arguments in the tuple)
+ http.postJson(
+ "/trpc/movies.addReview",
+ "[\"The Godfather\", 5]",
+ rsp -> {
+ String json = rsp.body().string();
+ assertThat(rsp.code()).isEqualTo(400);
+ assertThat(JsonPath.read(json, "$.error.message"))
+ .containsIgnoringCase("comment");
+ });
+
+ // Validating explicit null on an Object/POJO
+ http.postJson(
+ "/trpc/movies.updateMetadata",
+ "[1, null]",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string()).contains("\"result\"");
+ });
+ // Delete Movide
+ http.postJson(
+ "/trpc/movies.deleteMovie",
+ "1",
+ rsp -> {
+ assertThat(rsp.code()).isEqualTo(200);
+ assertThat(rsp.body().string()).contains("\"result\"");
+ });
+ }
+}
diff --git a/tests/src/test/java/io/jooby/test/WebClient.java b/tests/src/test/java/io/jooby/test/WebClient.java
index 8236117b5f..8ed789c3c9 100644
--- a/tests/src/test/java/io/jooby/test/WebClient.java
+++ b/tests/src/test/java/io/jooby/test/WebClient.java
@@ -5,6 +5,8 @@
*/
package io.jooby.test;
+import static okhttp3.RequestBody.create;
+
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
@@ -14,6 +16,7 @@
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
@@ -225,10 +228,16 @@ public Request invoke(String method, String path) {
}
public Request invoke(String method, String path, RequestBody body) {
- okhttp3.Request.Builder req = new okhttp3.Request.Builder();
+ return invoke(method, path, Map.of(), body);
+ }
+
+ public Request invoke(String method, String path, Map query, RequestBody body) {
+ var req = new okhttp3.Request.Builder();
req.method(method, body);
setRequestHeaders(req);
- req.url(scheme + "://localhost:" + port + path);
+ var url = HttpUrl.parse(scheme + "://localhost:" + port + path).newBuilder();
+ query.forEach((name, value) -> url.addQueryParameter(name, value.toString()));
+ req.url(url.build());
return new Request(req);
}
@@ -245,7 +254,11 @@ private void setRequestHeaders(okhttp3.Request.Builder req) {
}
public Request get(String path) {
- return invoke("GET", path, null);
+ return get(path, Map.of());
+ }
+
+ public Request get(String path, Map query) {
+ return invoke("GET", path, query, null);
}
public ServerSentMessageIterator sse(String path) {
@@ -294,6 +307,11 @@ public void get(String path, SneakyThrows.Consumer callback) {
get(path).execute(callback);
}
+ public void get(
+ String path, Map query, SneakyThrows.Consumer callback) {
+ get(path, query).execute(callback);
+ }
+
public void syncWebSocket(String path, SneakyThrows.Consumer consumer) {
okhttp3.Request.Builder req = new okhttp3.Request.Builder();
req.url("ws://localhost:" + port + path);
@@ -359,6 +377,10 @@ public void post(String path, RequestBody form, SneakyThrows.Consumer
post(path, form).execute(callback);
}
+ public void postJson(String path, String json, SneakyThrows.Consumer callback) {
+ post(path, create(json, MediaType.parse("application/json"))).execute(callback);
+ }
+
public Request put(String path) {
return invoke("put", path, EMPTY_BODY);
}
diff --git a/tests/src/test/kotlin/i3863/KMovieService.kt b/tests/src/test/kotlin/i3863/KMovieService.kt
new file mode 100644
index 0000000000..2f6de68d1f
--- /dev/null
+++ b/tests/src/test/kotlin/i3863/KMovieService.kt
@@ -0,0 +1,71 @@
+/*
+ * Jooby https://jooby.io
+ * Apache License Version 2.0 https://jooby.io/LICENSE.txt
+ * Copyright 2014 Edgar Espina
+ */
+package i3863
+
+import io.jooby.annotation.Trpc
+import io.jooby.annotation.Trpc.Mutation
+import io.jooby.i3863.Metadata
+import io.jooby.i3863.Movie
+import java.util.stream.Collectors
+
+@Trpc("movies")
+class KMovieService {
+ private val database =
+ listOf(Movie(1, "The Godfather", 1972), Movie(2, "Pulp Fiction", 1994))
+
+ /** Procedure: movies.create Takes a single complex object. */
+ @Mutation
+ fun create(movie: Movie?): Movie? {
+ // In a real app, save to DB. For now, just return it.
+ return movie
+ }
+
+ @Mutation fun resetIndex() {}
+
+ /** Procedure: movies.bulkCreate Takes a List of complex objects. */
+ @Trpc.Query
+ fun bulkCreate(movies: List): List {
+ return movies
+ .stream()
+ .map { m: Movie -> "Created: " + m.title }
+ .collect(Collectors.toList())
+ }
+
+ /** Procedure: movies.ping */
+ @Trpc.Query
+ fun ping(): String {
+ return "pong"
+ }
+
+ /** Procedure: movies.getById Single primitive argument */
+ @Trpc.Query
+ fun getById(id: Int): Movie? {
+ return database.stream().filter { m: Movie? -> m!!.id == id }.findFirst().orElse(null)
+ }
+
+ /** Procedure: movies.search Multi-argument (Tuple) */
+ @Trpc.Query
+ fun search(title: String, year: Int?): List {
+ return database
+ .stream()
+ .filter { m: Movie? -> m!!.title.contains(title) && (year == null || m.year == year) }
+ .toList()
+ }
+
+ /** Procedure: movies.addReview Mix of String and int (Mutation) */
+ @Mutation
+ fun addReview(movieTitle: String, stars: Int, comment: String?): Map {
+ // Business logic...
+ return mapOf("title" to movieTitle, "rating" to stars, "status" to "published")
+ }
+
+ /** Procedure: movies.addReview Mix of String and int (Mutation) */
+ @Mutation
+ fun updateMetadata(id: Int, metadata: Metadata?): Metadata? {
+ // Business logic...
+ return metadata
+ }
+}