diff --git a/docs/asciidoc/tRPC.adoc b/docs/asciidoc/tRPC.adoc new file mode 100644 index 0000000000..15973342dc --- /dev/null +++ b/docs/asciidoc/tRPC.adoc @@ -0,0 +1,184 @@ +=== tRPC + +The tRPC module provides end-to-end type safety by integrating the https://trpc.io/[tRPC] protocol directly into Jooby. + +Because the `io.jooby.trpc` package is included in Jooby core, there are no extra dependencies to add to your project. This integration allows you to write standard Java/Kotlin controllers and consume them directly in the browser using the official `@trpc/client`—complete with 100% type safety, autocomplete, and zero manual client generation. + +==== Usage + +Because tRPC relies heavily on JSON serialization to communicate with the frontend client, a JSON module **must** be installed prior to the `TrpcModule`. + +NOTE: Currently, Jooby only provides the required `TrpcParser` SPI implementation for two JSON engines: **Jackson 2/3** and **AvajeJsonbModule** . Using other JSON modules (like Gson) will result in a missing service exception at startup. + +[source, java] +---- +import io.jooby.Jooby; +import io.jooby.json.JacksonModule; +import io.jooby.trpc.TrpcModule; + +public class App extends Jooby { + { + install(new JacksonModule()); // <1> + + install(new TrpcModule()); // <2> + + install(new MovieService_()); // <3> + } +} +---- + +1. Install a supported JSON engine (Jackson or Avaje) +2. Install the tRPC extension +3. Register your @Trpc annotated controllers (using the APT generated route) + +==== Writing a Service + +You can define your procedures using explicit tRPC annotations or a hybrid approach combining tRPC with standard HTTP methods: + +* **Explicit Annotations:** Use `@Trpc.Query` (maps to `GET`) and `@Trpc.Mutation` (maps to `POST`). +* **Hybrid Annotations:** Combine the base `@Trpc` annotation with Jooby's standard HTTP annotations. A `@GET` resolves to a tRPC query, while state-changing methods (`@POST`, `@PUT`, `@DELETE`) resolve to tRPC mutations. + +.MovieService +[source, java] +---- +import io.jooby.annotation.Trpc; +import io.jooby.annotation.DELETE; + +public record Movie(int id, String title, int year) {} + +@Trpc("movies") // Defines the 'movies' namespace +public class MovieService { + + // 1. Explicit tRPC Query + @Trpc.Query + public Movie getById(int id) { + return new Movie(id, "Pulp Fiction", 1994); + } + + // 2. Explicit tRPC Mutation + @Trpc.Mutation + public Movie create(Movie movie) { + // Save to database logic here + return movie; + } + + // 3. Hybrid Mutation + @Trpc + @DELETE + public void delete(int id) { + // Delete from database + } +} +---- + +==== Build Tool Configuration + +To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby build plugin for your project. The generator parses your source code and emits the definitions during the compilation phase. + +.pom.xml +[source, xml, role = "primary", subs="verbatim,attributes"] +---- + + io.jooby + jooby-maven-plugin + ${jooby.version} + + + + trpc + + + + + jackson2 + ${project.build.outputDirectory} + + +---- + +.gradle.build +[source, groovy, role = "secondary", subs="verbatim,attributes"] +---- +plugins { + id 'io.jooby.trpc' version "${joobyVersion}" +} + +trpc { + // Optional settings + jsonLibrary = 'jackson2' +} +---- + +==== Consuming the API (Frontend) + +Once the project is compiled, the build plugin generates a `trpc.d.ts` file containing your exact `AppRouter` shape. You can then use the official client in your TypeScript frontend: + +[source, bash] +---- +npm install @trpc/client +---- + +[source, typescript] +---- +import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import type { AppRouter } from './target/classes/trpc'; // Path to generated file + +// Initialize the strongly-typed client +export const trpc = createTRPCProxyClient({ + 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: + * + *

+ * + *

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: + * + *

    + *
  1. Scans the classpath via the provided ClassLoader for controllers marked with {@code @Trpc}. + *
  2. Extracts only the input and return types (DTOs) of the matching methods. + *
  3. Feeds those data models to the generator to produce clean TypeScript interfaces. + *
  4. Uses a fast, recursive type resolver to accurately map Java methods to tRPC {@code { input, + * output }} shapes. + *
  5. 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 + } +}