diff --git a/docs/asciidoc/json-rpc.adoc b/docs/asciidoc/json-rpc.adoc new file mode 100644 index 0000000000..03ad8fe020 --- /dev/null +++ b/docs/asciidoc/json-rpc.adoc @@ -0,0 +1,142 @@ +==== JSON-RPC + +Jooby provides full support for the JSON-RPC 2.0 specification, allowing you to build robust RPC APIs using standard Java and Kotlin controllers. + +The implementation leverages Jooby's Annotation Processing Tool (APT) to generate highly optimized, reflection-free dispatchers at compile time. + +===== Usage + +To expose a JSON-RPC endpoint, annotate your controller or service with `@JsonRpc`. You can optionally provide a namespace to the annotation, which will prefix all generated method names for that class. + +.JSON-RPC +[source,java,role="primary"] +---- +import io.jooby.jsonrpc.JsonRpc; + +@JsonRpc("movies") +public class MovieService { + + public Movie getById(int id) { + return database.stream() + .filter(m -> m.id() == id) + .findFirst() + .orElseThrow(() -> new NotFoundException("Movie not found: " + id)); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.jsonrpc.JsonRpc + +@JsonRpc("movies") +class MovieService { + + fun getById(id: Int): Movie { + return database.asSequence() + .filter { it.id == id } + .firstOrNull() ?: throw NotFoundException("Movie not found: $id") + } +} +---- + +When the Jooby APT detects the `@JsonRpc` annotation, it generates a class ending in `Rpc_` (e.g., `MovieServiceRpc_`) that implements the `io.jooby.jsonrpc.JsonRpcService` interface. + +The annotation dictates how the protocol methods are named and exposed: + +* **Class Level:** Placing `@JsonRpc` on a class treats its public methods as JSON-RPC endpoints. An optional string value defines a namespace prefix for all methods within that class (e.g., `@JsonRpc("movies")`). If no value is provided, no namespace is applied. +* **Method Level:** By default, the generated JSON-RPC method name is exactly the name of the Java/Kotlin method, prefixed by the class-level namespace if one is present (e.g., `movies.getById` or just `getById` if no namespace was set). You can also place `@JsonRpc("customName")` directly on specific methods to explicitly override this default naming convention. + +**Mixing Annotations:** You can freely mix standard REST annotations (like `@GET`, `@POST`) and `@JsonRpc` on the same class. The APT handles this by generating two entirely separate dispatchers: one standard MVC extension (e.g., `MovieService_`) and one JSON-RPC service (e.g., `MovieServiceRpc_`). They do not interfere with each other, allowing you to expose the exact same business logic over both REST and JSON-RPC simultaneously. + +===== Registration + +Register the generated `JsonRpcService` in your application using the `jsonrpc` method. You must also install a supported JSON engine. + +.JSON-RPC +[source,java,role="primary"] +---- +import io.jooby.Jooby; +import io.jooby.jackson.JacksonModule; + +public class App extends Jooby { + { + install(new JacksonModule()); // <1> + + jsonrpc(new MovieServiceRpc_()); // <2> + + // Alternatively, you can override the default path: + // jsonrpc("/json-rpc", new MovieServiceRpc_()); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.kt.Kooby +import io.jooby.jackson.JacksonModule + +class App : Kooby({ + + install(JacksonModule()) // <1> + + jsonrpc(MovieServiceRpc_()) // <2> + + // Custom endpoint: + // jsonrpc("/json-rpc", MovieServiceRpc_()) +}) +---- + +1. Install a JSON engine +2. Register the generated JSON-RPC service + +===== JSON Engine Support + +The JSON-RPC extension delegates payload parsing and serialization to Jooby's standard JSON modules while enforcing strict JSON-RPC 2.0 compliance (such as the mutual exclusivity of `result` and `error` fields). + +Supported engines include: + +* **Jackson 2** (`JacksonModule`) +* **Jackson 3** (`Jackson3Module`) +* **Avaje JSON-B** (`AvajeJsonbModule`) + +No additional configuration is required. The generated dispatcher automatically hooks into the installed engine using the `JsonRpcParser` and `JsonRpcDecoder` interfaces, ensuring primitive types are strictly validated and parsed. + +===== Error Mapping + +Jooby seamlessly bridges standard Java application exceptions and HTTP status codes into the JSON-RPC 2.0 format using the `JsonRpcErrorCode` mapping. You do not need to throw custom protocol exceptions for standard failures. + +When an application exception is thrown (like a `NotFoundException` with an HTTP 404 status), the dispatcher catches it and translates it into a compliant JSON-RPC error. The standard HTTP status defines the error `code`, while the specific exception message is safely passed into the `data` field: + +[source,json] +---- +{ + "jsonrpc": "2.0", + "error": { + "code": -32004, + "message": "Not found", + "data": "Movie not found: 99" + }, + "id": 1 +} +---- + +====== Standard Protocol Errors + +The engine handles core JSON-RPC 2.0 protocol errors automatically, returning HTTP 200 OK with the corresponding error payload: + +* `-32700`: Parse error (Malformed JSON) +* `-32600`: Invalid Request (Missing version, ID, or malformed envelope) +* `-32601`: Method not found +* `-32602`: Invalid params (Missing arguments, type mismatches) +* `-32603`: Internal error + +Application-defined errors map standard HTTP status codes to the `-32000` to `-32099` range (e.g., an HTTP 404 maps to `-32004`, an HTTP 401 maps to `-32001`). + +===== Batch Processing + +Batch processing is natively supported. Clients can send an array of JSON-RPC request objects, and the dispatcher will process them and return an array of corresponding response objects. + +In accordance with the specification, notifications (requests lacking an `id` field) are processed normally but generate no response payload, leaving no trace in the returned batch array. diff --git a/docs/asciidoc/rpc.adoc b/docs/asciidoc/rpc.adoc new file mode 100644 index 0000000000..b8cfee1986 --- /dev/null +++ b/docs/asciidoc/rpc.adoc @@ -0,0 +1,5 @@ +=== RPC + +include::json-rpc.adoc[] + +include::tRPC.adoc[] diff --git a/docs/asciidoc/tRPC.adoc b/docs/asciidoc/tRPC.adoc index 15973342dc..4ff4cac5ef 100644 --- a/docs/asciidoc/tRPC.adoc +++ b/docs/asciidoc/tRPC.adoc @@ -1,16 +1,17 @@ -=== tRPC +==== 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 +===== 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. +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] +.Java +[source,java,role="primary"] ---- import io.jooby.Jooby; import io.jooby.json.JacksonModule; @@ -27,19 +28,35 @@ public class App extends Jooby { } ---- +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.kt.Kooby +import io.jooby.json.JacksonModule +import io.jooby.trpc.TrpcModule + +class App : Kooby({ + install(JacksonModule()) // <1> + + install(TrpcModule()) // <2> + + install(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 +===== 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] +.Java +[source,java,role="primary"] ---- import io.jooby.annotation.Trpc; import io.jooby.annotation.DELETE; @@ -71,12 +88,45 @@ public class MovieService { } ---- -==== Build Tool Configuration +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.annotation.Trpc +import io.jooby.annotation.DELETE + +data class Movie(val id: Int, val title: String, val year: Int) + +@Trpc("movies") // Defines the 'movies' namespace +class MovieService { + + // 1. Explicit tRPC Query + @Trpc.Query + fun getById(id: Int): Movie { + return Movie(id, "Pulp Fiction", 1994) + } + + // 2. Explicit tRPC Mutation + @Trpc.Mutation + fun create(movie: Movie): Movie { + // Save to database logic here + return movie + } + + // 3. Hybrid Mutation + @Trpc + @DELETE + fun delete(id: Int) { + // Delete from database + } +} +---- + +===== Build 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"] +[source,xml,role="primary",subs="verbatim,attributes"] ---- io.jooby @@ -96,8 +146,8 @@ To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby ---- -.gradle.build -[source, groovy, role = "secondary", subs="verbatim,attributes"] +.build.gradle +[source,groovy,role="secondary",subs="verbatim,attributes"] ---- plugins { id 'io.jooby.trpc' version "${joobyVersion}" @@ -109,16 +159,16 @@ trpc { } ---- -==== Consuming the API (Frontend) +===== 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] +[source,bash] ---- npm install @trpc/client ---- -[source, typescript] +[source,typescript] ---- import { createTRPCProxyClient, httpLink } from '@trpc/client'; import type { AppRouter } from './target/classes/trpc'; // Path to generated file @@ -137,14 +187,15 @@ const movie = await trpc.movies.getById.query(1); console.log(`Fetched: ${movie.title} (${movie.year})`); ---- -==== Advanced Configuration +===== Advanced Configuration -===== Custom Exception Mapping +====== 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] +.Java +[source,java,role="primary"] ---- import io.jooby.trpc.TrpcErrorCode; @@ -158,11 +209,28 @@ import io.jooby.trpc.TrpcErrorCode; } ---- -===== Custom TypeScript Mappings +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.kt.Kooby +import io.jooby.trpc.TrpcModule +import io.jooby.trpc.TrpcErrorCode + +class App : Kooby({ + install(TrpcModule()) + + // Map your custom business exception to a standard tRPC error code + services.mapOf(Class::class.java, TrpcErrorCode::class.java) + .put(IllegalArgumentException::class.java, TrpcErrorCode.BAD_REQUEST) + .put(MovieNotFoundException::class.java, 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] +.Maven +[source,xml,role="primary"] ---- @@ -172,8 +240,8 @@ Sometimes you have custom Java types (like `java.util.UUID` or `java.math.BigDec ---- -**Gradle:** -[source, groovy] +.Gradle +[source,groovy,role="secondary"] ---- trpc { customTypeMappings = [ diff --git a/docs/asciidoc/web.adoc b/docs/asciidoc/web.adoc index ffd262d3ce..3ea42db322 100644 --- a/docs/asciidoc/web.adoc +++ b/docs/asciidoc/web.adoc @@ -10,6 +10,6 @@ include::session.adoc[] include::server-sent-event.adoc[] -include::tRPC.adoc[] +include::rpc.adoc[] include::websocket.adoc[] diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index 44e833d3d0..465ced7fb7 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -35,6 +35,8 @@ import io.jooby.internal.MutedServer; import io.jooby.internal.RegistryRef; import io.jooby.internal.RouterImpl; +import io.jooby.jsonrpc.JsonRpcModule; +import io.jooby.jsonrpc.JsonRpcService; import io.jooby.output.OutputFactory; import io.jooby.problem.ProblemDetailsHandler; import io.jooby.value.ValueFactory; @@ -102,6 +104,8 @@ public class Jooby implements Router, Registry { private List locales; + private Map dispatchers; + private boolean lateInit; private String name; @@ -383,7 +387,7 @@ public String getContextPath() { * @param factory Application factory. * @return This application. */ - @NonNull public Jooby install( + public Jooby install( @NonNull String path, @NonNull Predicate predicate, @NonNull SneakyThrows.Supplier factory) { @@ -426,7 +430,7 @@ public String getContextPath() { * @param factory Application factory. * @return This application. */ - @NonNull public Jooby install( + public Jooby install( @NonNull Predicate predicate, @NonNull SneakyThrows.Supplier factory) { return install("/", predicate, factory); } @@ -478,8 +482,7 @@ public Route.Set mount(@NonNull Predicate predicate, @NonNull Router su @Override public Route.Set mount(@NonNull String path, @NonNull Router router) { var rs = this.router.mount(path, router); - if (router instanceof Jooby) { - Jooby child = (Jooby) router; + if (router instanceof Jooby child) { child.registry = this.registry; } return rs; @@ -490,19 +493,46 @@ public Route.Set mount(@NonNull Router router) { return mount("/", router); } + public Jooby jsonRpc(String path, @NonNull JsonRpcService service) { + if (dispatchers == null) { + dispatchers = new HashMap<>(); + } + dispatchers + .computeIfAbsent( + Router.normalizePath(path), + normalizedPath -> { + var dispatcher = new JsonRpcModule(normalizedPath); + install(dispatcher); + return dispatcher; + }) + .add(service); + return this; + } + + public Jooby jsonRpc(@NonNull JsonRpcService service) { + return jsonRpc("/rpc", service); + } + /** * Add controller routes. * * @param router Mvc extension. * @return Route set. */ - @NonNull public Route.Set mvc(@NonNull Extension router) { - try { - int start = this.router.getRoutes().size(); - router.install(this); - return new Route.Set(this.router.getRoutes().subList(start, this.router.getRoutes().size())); - } catch (Exception cause) { - throw SneakyThrows.propagate(cause); + public Route.Set mvc(@NonNull Extension router) { + if (router instanceof JsonRpcService jsonRpcService) { + jsonRpc(jsonRpcService); + // NOOP + return new Route.Set(Collections.emptyList()); + } else { + try { + int start = this.router.getRoutes().size(); + router.install(this); + return new Route.Set( + this.router.getRoutes().subList(start, this.router.getRoutes().size())); + } catch (Exception cause) { + throw SneakyThrows.propagate(cause); + } } } @@ -1455,5 +1485,6 @@ private static void copyState(Jooby source, Jooby dest) { dest.readyCallbacks = source.readyCallbacks; dest.startingCallbacks = source.startingCallbacks; dest.stopCallbacks = source.stopCallbacks; + dest.dispatchers = source.dispatchers; } } diff --git a/jooby/src/main/java/io/jooby/Router.java b/jooby/src/main/java/io/jooby/Router.java index 802b9618d5..b07d64ef5a 100644 --- a/jooby/src/main/java/io/jooby/Router.java +++ b/jooby/src/main/java/io/jooby/Router.java @@ -908,7 +908,7 @@ static String leadingSlash(@Nullable String path) { * @param path Path to process. * @return Path without trailing slashes. */ - static @NonNull String noTrailingSlash(@NonNull String path) { + static String noTrailingSlash(@NonNull String path) { StringBuilder buff = new StringBuilder(path); int i = buff.length() - 1; while (i > 0 && buff.charAt(i) == '/') { @@ -927,7 +927,7 @@ static String leadingSlash(@Nullable String path) { * @param path Path to process. * @return Safe path pattern. */ - static @NonNull String normalizePath(@Nullable String path) { + static String normalizePath(@Nullable String path) { if (path == null || path.length() == 0 || path.equals("/")) { return "/"; } diff --git a/jooby/src/main/java/io/jooby/annotation/JsonRpc.java b/jooby/src/main/java/io/jooby/annotation/JsonRpc.java new file mode 100644 index 0000000000..e0f0c0d681 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/JsonRpc.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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class or its methods as a JSON-RPC 2.0 endpoint. + * + *

Discovery Rules:

+ * + *
    + *
  • Implicit Mapping: If a class is annotated with {@code @JsonRpc} and no + * methods are explicitly annotated, all public methods are automatically exposed as + * JSON-RPC endpoints. + *
  • Explicit Mapping: If at least one method in the class is explicitly annotated + * with {@code @JsonRpc}, implicit mapping is disabled. Only the annotated methods will + * be exposed, requiring you to map any other desired endpoints one by one. + *
+ * + *

Naming & Routing Rules:

+ * + *
    + *
  • Class Level (Namespace): When applied to a class, the {@link #value()} defines the + * namespace for all JSON-RPC methods within that class. If the annotation is present but the + * value is empty (the default), no namespace is applied. + *
  • Method Level (Method Name): When applied to a method, the {@link #value()} defines + * the exact RPC method name. If the value is empty, the actual Java/Kotlin method name is + * used. + *
+ * + *

The final JSON-RPC method string expected by the dispatcher is formatted as {@code + * "namespace.methodName"} (or just {@code "methodName"} if no namespace exists). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface JsonRpc { + + /** + * The explicit namespace (when used on a class) or the explicit method name (when used on a + * method). * @return The overridden name, or an empty string to use the defaults (no namespace + * for classes, actual method name for methods). + */ + String value() default ""; +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java new file mode 100644 index 0000000000..232ea3b3b9 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcDecoder.java @@ -0,0 +1,35 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +/** + * A pre-resolved decoder used at runtime to deserialize JSON-RPC parameter nodes into complex Java + * objects. + * + *

This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling + * {@code @JsonRpc}-annotated controllers, the APT generates highly optimized routing code that + * resolves the appropriate {@code JsonRpcDecoder} for each method argument. By pre-resolving these + * decoders, Jooby efficiently parses incoming JSON arguments 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 {@link JsonRpcReader} rather than requiring a + * dedicated decoder. + * + * @param The target Java type this decoder produces. + */ +public interface JsonRpcDecoder { + + /** + * Decodes a generic JSON node object into the target Java object. + * + * @param name The name of the parameter being decoded (useful for error reporting or wrapping). + * @param node The generic JSON node (e.g., a Map, List, or library-specific AST node) + * representing this specific argument. + * @return The fully deserialized Java object. + */ + T decode(String name, Object node); +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java new file mode 100644 index 0000000000..b0af276159 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcErrorCode.java @@ -0,0 +1,141 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import io.jooby.StatusCode; + +/** + * Standard JSON-RPC 2.0 Error Codes mapped to HTTP status codes. + * + *

The JSON-RPC 2.0 specification defines a specific set of integer codes for standard errors. + * This enumeration provides the canonical mapping between those JSON-RPC errors and Jooby's {@link + * StatusCode} for HTTP transport bindings. + */ +public enum JsonRpcErrorCode { + + // --- Core JSON-RPC 2.0 Errors --- + + /** The JSON sent is not a valid Request object. */ + INVALID_REQUEST(-32600, "Invalid Request", StatusCode.BAD_REQUEST, true), + + /** + * Invalid JSON was received by the server. An error occurred on the server while parsing the JSON + * text. + */ + PARSE_ERROR(-32700, "Parse error", StatusCode.BAD_REQUEST, true), + + /** The method does not exist / is not available. */ + METHOD_NOT_FOUND(-32601, "Method not found", StatusCode.NOT_FOUND, true), + + /** Invalid method parameter(s). */ + INVALID_PARAMS(-32602, "Invalid params", StatusCode.BAD_REQUEST, true), + + /** Internal JSON-RPC error. */ + INTERNAL_ERROR(-32603, "Internal error", StatusCode.SERVER_ERROR, true), + + // --- Implementation-defined Server Errors (-32000 to -32099) --- + + /** Missing or invalid authentication. */ + UNAUTHORIZED(-32001, "Unauthorized", StatusCode.UNAUTHORIZED, false), + + /** Authenticated user lacks required permissions. */ + FORBIDDEN(-32003, "Forbidden", StatusCode.FORBIDDEN, false), + + /** The requested resource or procedure was not found (Business Logic). */ + NOT_FOUND_ERROR(-32004, "Not found", StatusCode.NOT_FOUND, false), + + /** State conflict, such as a duplicate database entry. */ + CONFLICT(-32009, "Conflict", StatusCode.CONFLICT, false), + + /** The client's preconditions were not met. */ + PRECONDITION_FAILED(-32012, "Precondition failed", StatusCode.PRECONDITION_FAILED, false), + + /** + * The payload format is valid, but the content is semantically incorrect (e.g., validation + * failed). + */ + UNPROCESSABLE_CONTENT(-32022, "Unprocessable content", StatusCode.UNPROCESSABLE_ENTITY, false), + + /** Rate limiting applied. */ + TOO_MANY_REQUESTS(-32029, "Too many requests", StatusCode.TOO_MANY_REQUESTS, false); + + private final int code; + private final String message; + private final StatusCode statusCode; + private final boolean protocol; + + /** + * Defines a JSON-RPC error code mapping. + * + * @param code The JSON-RPC 2.0 integer code. + * @param message The standard error message. + * @param statusCode The HTTP status code to associate with this error. + * @param protocol True if this is a strict JSON-RPC 2.0 protocol error, false if + * implementation-defined. + */ + JsonRpcErrorCode(int code, String message, StatusCode statusCode, boolean protocol) { + this.code = code; + this.message = message; + this.statusCode = statusCode; + this.protocol = protocol; + } + + /** + * Retrieves the JSON-RPC integer code. + * + * @return The integer code (e.g., -32600). + */ + public int getCode() { + return code; + } + + /** + * Retrieves the standard JSON-RPC error message. + * + * @return The error message. + */ + public String getMessage() { + return message; + } + + /** + * Retrieves the corresponding HTTP status code. + * + * @return The Jooby {@link StatusCode}. + */ + public StatusCode getStatusCode() { + return statusCode; + } + + /** + * Indicates if this error is a core JSON-RPC 2.0 protocol error. + * + * @return True for strict protocol errors (-32700 to -32603), false for implementation-defined + * errors. + */ + public boolean isProtocol() { + return protocol; + } + + /** + * Resolves the closest JSON-RPC 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_ERROR}. + * + * @param status The Jooby HTTP status code. + * @return The corresponding {@code JsonRpcErrorCode}, or {@code INTERNAL_ERROR} if no match + * exists. + */ + public static JsonRpcErrorCode of(StatusCode status) { + for (var errorCode : values()) { + if (!errorCode.protocol && errorCode.statusCode.value() == status.value()) { + return errorCode; + } + } + return INTERNAL_ERROR; + } +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java new file mode 100644 index 0000000000..31c8c7279b --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcException.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +/** + * Exception thrown when a JSON-RPC error occurs during routing, parsing, or execution. + * + *

Contains standard JSON-RPC 2.0 error codes. When caught by the dispatcher, this exception + * should be transformed into a {@link JsonRpcResponse} containing the error details. + */ +public class JsonRpcException extends RuntimeException { + private final JsonRpcErrorCode code; + + private final Object data; + + /** + * Constructs a new JSON-RPC exception. + * + * @param code The integer error code (preferably one of the standard constants). + * @param message A short description of the error. + */ + public JsonRpcException(JsonRpcErrorCode code, String message) { + super(message); + this.code = code; + this.data = null; + } + + /** + * Constructs a new JSON-RPC exception. + * + * @param code The integer error code (preferably one of the standard constants). + * @param message A short description of the error. + * @param cause The underlying cause of the error. + */ + public JsonRpcException(JsonRpcErrorCode code, String message, Throwable cause) { + super(message, cause); + this.code = code; + this.data = null; + } + + /** + * Constructs a new JSON-RPC exception with additional error data. + * + * @param code The integer error code. + * @param message A short description of the error. + * @param data Additional data about the error (e.g., stack trace or validation messages). + */ + public JsonRpcException(JsonRpcErrorCode code, String message, Object data) { + super(message); + this.code = code; + this.data = data; + } + + /** + * Returns the JSON-RPC error code. + * + * @return The JSON-RPC error code. + */ + public JsonRpcErrorCode getCode() { + return code; + } + + /** + * Returns additional data regarding the error. + * + * @return Additional data regarding the error, or null if none was provided. + */ + public Object getData() { + return data; + } +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java new file mode 100644 index 0000000000..23810fcce7 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcModule.java @@ -0,0 +1,236 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.jooby.*; +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; + +/** + * Global Tier 1 Dispatcher for JSON-RPC 2.0 requests. * + * + *

This dispatcher acts as the central entry point for all JSON-RPC traffic. It manages the + * lifecycle of a request by: + * + *

    + *
  • Parsing the incoming body into a {@link JsonRpcRequest} (supporting both single and batch + * shapes). + *
  • Iterating through registered {@link JsonRpcService} instances to find a matching namespace. + *
  • Handling Notifications (requests without an {@code id}) by suppressing + * responses. + *
  • Unifying batch results into a single JSON array or a single object response as per the + * spec. + *
+ * + *

* + * + *

Usage: + * + *

{@code
+ * install(new JsonRpcDispatcher());
+ * services().put(JsonRpcService.class, new MyServiceRpc(new MyService()));
+ * }
+ * + * @author Edgar Espina + * @since 4.0.17 + */ +public class JsonRpcModule implements Extension { + private final Logger log = LoggerFactory.getLogger(JsonRpcService.class); + private final Map services = new HashMap<>(); + private final String path; + + public JsonRpcModule(String path) { + this.path = path; + } + + public void add(JsonRpcService service) { + for (var method : service.getMethods()) { + this.services.put(method, service); + } + } + + /** + * Installs the JSON-RPC handler at the default {@code /rpc} endpoint. + * + * @param app The Jooby application instance. + * @throws Exception If registration fails. + */ + @Override + public void install(Jooby app) throws Exception { + app.post(path, this::handle); + + // Initialize the custom exception mapping registry + app.getServices() + .mapOf(Class.class, JsonRpcErrorCode.class) + .put(MissingValueException.class, JsonRpcErrorCode.INVALID_PARAMS) + .put(TypeMismatchException.class, JsonRpcErrorCode.INVALID_PARAMS); + } + + /** + * Main handler for the JSON-RPC protocol. * + * + *

This method implements the flattened iteration logic. Because {@link JsonRpcRequest} + * implements {@code Iterable}, this handler treats single requests and batch requests identically + * during processing. + * + * @param ctx The current Jooby context. + * @return A single {@link JsonRpcResponse}, a {@code List} of responses for batches, or an empty + * string for notifications. + */ + private Object handle(Context ctx) { + JsonRpcRequest input; + try { + input = ctx.body(JsonRpcRequest.class); + } catch (Exception e) { + // Spec: -32700 Parse error if the JSON is physically malformed. + return JsonRpcResponse.error(null, JsonRpcErrorCode.PARSE_ERROR, e); + } + + List responses = new ArrayList<>(); + + // Look up all generated *Rpc classes registered in the service registry + + for (var request : input) { + var fullMethod = request.getMethod(); + + // Spec: -32600 Invalid Request if the method member is missing or null + if (fullMethod == null) { + responses.add( + JsonRpcResponse.error(request.getId(), JsonRpcErrorCode.INVALID_REQUEST, null)); + continue; + } + + try { + var targetService = services.get(fullMethod); + if (targetService != null) { + var result = targetService.execute(ctx, request); + // Spec: If the "id" is missing, it is a notification and no response is returned. + if (request.getId() != null) { + responses.add(JsonRpcResponse.success(request.getId(), result)); + } + } else { + // Spec: -32601 Method not found + if (request.getId() != null) { + responses.add( + JsonRpcResponse.error( + request.getId(), + JsonRpcErrorCode.METHOD_NOT_FOUND, + "Method not found: " + fullMethod)); + } + } + } catch (JsonRpcException cause) { + log(ctx, request, cause); + // Domain-specific or protocol-level exceptions (e.g., -32602 Invalid Params) + if (request.getId() != null) { + responses.add(JsonRpcResponse.error(request.getId(), cause.getCode(), cause.getCause())); + } + } catch (Exception cause) { + log(ctx, request, cause); + // Spec: -32603 Internal error for unhandled application exceptions + if (request.getId() != null) { + responses.add( + JsonRpcResponse.error(request.getId(), computeErrorCode(ctx, cause), cause)); + } + } + } + + // Handle the case where all requests in a batch were notifications + if (responses.isEmpty()) { + ctx.setResponseCode(StatusCode.NO_CONTENT); + return ""; + } + + // Spec: Return an array only if the original request was a batch + return input.isBatch() ? responses : responses.getFirst(); + } + + private void log(Context ctx, JsonRpcRequest request, Throwable cause) { + JsonRpcErrorCode code; + boolean hasCause = true; + if (cause instanceof JsonRpcException rpcException) { + code = rpcException.getCode(); + hasCause = false; + } else { + code = computeErrorCode(ctx, cause); + } + var type = code == JsonRpcErrorCode.INTERNAL_ERROR ? "server" : "client"; + var message = "JSON-RPC {} error [{} {}] on method '{}' (id: {})"; + switch (code) { + case INTERNAL_ERROR -> + log.error( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + case UNAUTHORIZED, FORBIDDEN, NOT_FOUND_ERROR -> + log.debug( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + default -> { + if (hasCause) { + log.warn( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId(), + cause); + } else { + log.debug( + message, + type, + code.getCode(), + code.getMessage(), + request.getMethod(), + request.getId()); + } + } + } + } + + private JsonRpcErrorCode computeErrorCode(Context ctx, Throwable cause) { + JsonRpcErrorCode code; + // Attempt to look up any user-defined exception mappings from the registry + Map, JsonRpcErrorCode> customMapping = + ctx.require(Reified.map(Class.class, JsonRpcErrorCode.class)); + code = + errorCode(customMapping, cause) + .orElseGet(() -> JsonRpcErrorCode.of(ctx.getRouter().errorCode(cause))); + return code; + } + + /** + * 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, JsonRpcErrorCode> 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/jsonrpc/JsonRpcParser.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcParser.java new file mode 100644 index 0000000000..3c557113f7 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcParser.java @@ -0,0 +1,50 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.lang.reflect.Type; + +/** + * The core JSON parsing SPI (Service Provider Interface) for JSON-RPC. + * + *

This factory is implemented by Jooby's JSON modules (such as Jackson or Avaje) and serves as + * the bridge between the parsed JSON-RPC request 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 JsonRpcDecoder}s. This ensures that during actual HTTP requests, + * arguments are deserialized with near-zero reflection overhead. + */ +public interface JsonRpcParser { + + /** + * 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 generic JSON nodes into the target type. + */ + JsonRpcDecoder decoder(Type type); + + /** + * Creates a stateful reader for extracting JSON-RPC arguments from the request parameters. + * + *

Because JSON-RPC 2.0 parameters can be either positional (a JSON Array) or named (a JSON + * Object), this reader manages the extraction context to hand off the correct JSON segment to the + * pre-resolved {@link JsonRpcDecoder}s or extract primitives. + * + * @param params The parsed parameter object (typically a List or Map depending on the underlying + * JSON library) from the {@link JsonRpcRequest}. + * @return A reader for argument extraction. + */ + JsonRpcReader reader(Object params); +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.java new file mode 100644 index 0000000000..e08d34f5b2 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcReader.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.jsonrpc; + +import io.jooby.exception.MissingValueException; + +/** + * A stateful reader used at runtime to extract arguments from JSON-RPC parameters. + * + *

When a JSON-RPC client sends a request, it packs the arguments into the {@code params} field + * as either an Array (positional) or an Object (named). This reader acts as a cursor or keyed + * accessor over that structure. The Jooby Annotation Processor (APT) generates routing code that + * calls the {@code next*()} methods on this interface sequentially, perfectly matching the + * 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 to read numbers and booleans without ever boxing them into heap-allocated objects + * like {@code Integer} or {@code Boolean}. + * + *

Because it may hold resources (depending on the JSON library implementation), it implements + * {@link AutoCloseable} and must be closed after the final argument is read. + */ +public interface JsonRpcReader extends AutoCloseable { + + /** + * Evaluates the next parameter to determine if it is a literal {@code null} or missing. + * + * @param name The logical name of the parameter being evaluated (used for named lookup or + * context). + * @return {@code true} if the parameter is null or missing; {@code false} otherwise. + */ + boolean nextIsNull(String name); + + /** + * Asserts that the next parameter 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 parameter evaluates to null or is missing. + */ + default void requireNext(String name) { + if (nextIsNull(name)) { + throw new MissingValueException(name); + } + } + + /** + * Reads the next parameter 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 parameter 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 parameter as a primitive boolean. + * + * @param name The logical name of the parameter. + * @return The extracted boolean value. + */ + boolean nextBoolean(String name); + + /** + * Reads the next parameter 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 parameter as a String. + * + * @param name The logical name of the parameter. + * @return The extracted String value, or {@code null} if the parameter is a JSON null. + */ + String nextString(String name); + + /** + * Reads the next parameter (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 via the provided {@link JsonRpcDecoder}. + * + * @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 parameter is a JSON null. + */ + T nextObject(String name, JsonRpcDecoder decoder); +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java new file mode 100644 index 0000000000..82c6e89f9b --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcRequest.java @@ -0,0 +1,149 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Represents a JSON-RPC 2.0 Request object, and simultaneously acts as an iterable container for + * batch requests. + * + *

Single vs. Batch Processing:
+ * To seamlessly support JSON-RPC batching without complicating the generated routing layer, this + * class implements {@code Iterable}. + * + *

    + *
  • If the payload is a single request, iterating over this object yields exactly one + * element (itself). + *
  • If the payload is a batch request, the underlying JSON library's custom deserializer + * will flag this instance as a batch and populate it with the parsed requests. Iterating over + * it will yield each underlying request. + *
+ * + *

This class is intentionally independent of any specific JSON library. The underlying JSON + * provider (like Jackson or Avaje) is responsible for deserializing the {@code params} field into a + * generic structure (e.g., a List or a Map) and populating the batch state. + */ +public class JsonRpcRequest implements Iterable { + + /** A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0". */ + private String jsonrpc = "2.0"; + + /** A String containing the name of the method to be invoked. */ + private String method; + + /** + * A Structured value that holds the parameter values to be used during the invocation of the + * method. Can be omitted, an Array (positional), or an Object (named). + */ + private Object params; + + /** + * An identifier established by the Client that MUST contain a String, Number, or NULL value if + * included. If it is not included it is assumed to be a notification. + */ + private Object id; + + // --- Batch State --- + private boolean batch; + private List requests; + + public JsonRpcRequest() {} + + public String getJsonrpc() { + return jsonrpc; + } + + public void setJsonrpc(String jsonrpc) { + this.jsonrpc = jsonrpc; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public Object getParams() { + return params; + } + + public void setParams(Object params) { + this.params = params; + } + + public Object getId() { + return id; + } + + public void setId(Object id) { + this.id = id; + } + + /** + * Identifies if this request object is acting as a container for a JSON-RPC batch array. + * + * @return {@code true} if this represents a batch, {@code false} if it is a single request. + */ + public boolean isBatch() { + return batch; + } + + public void setBatch(boolean batch) { + this.batch = batch; + } + + /** + * Returns the underlying requests if this instance represents a batch. + * + * @return A list of requests, or {@code null} if this is a single request. + */ + public List getRequests() { + return requests == null ? List.of() : requests; + } + + /** + * Populates this request as a batch container. + * + * @param requests The list of requests parsed from a JSON array. + */ + public void setRequests(List requests) { + this.requests = requests; + this.batch = true; + } + + /** + * Adds a request to this batch container. If this is the first request added, it automatically + * converts this instance into a batch. + * + * @param request The JSON-RPC request to add. + * @return This instance for method chaining. + */ + public JsonRpcRequest add(JsonRpcRequest request) { + if (this.requests == null) { + this.requests = new ArrayList<>(); + this.batch = true; + } + this.requests.add(request); + return this; + } + + @Override + public @NonNull Iterator iterator() { + if (batch) { + return getRequests().iterator(); + } + // If it's not a batch, it iterates over itself exactly once. + return Collections.singletonList(this).iterator(); + } +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java new file mode 100644 index 0000000000..f0945a42fc --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcResponse.java @@ -0,0 +1,130 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +/** + * Represents a JSON-RPC 2.0 Response object. + * + *

When an RPC call is made, the Server MUST reply with a Response, except in the case of + * Notifications. The Response is expressed as a single JSON Object. + */ +public class JsonRpcResponse { + + private String jsonrpc = "2.0"; + private Object result; + private ErrorDetail error; + private Object id; + + public JsonRpcResponse() {} + + private JsonRpcResponse(Object id, Object result, ErrorDetail error) { + this.id = id; + this.result = result; + this.error = error; + } + + /** + * Creates a successful JSON-RPC response. + * + * @param id The id from the corresponding request. + * @param result The result of the invoked method. + * @return A populated JsonRpcResponse. + */ + public static JsonRpcResponse success(Object id, Object result) { + return new JsonRpcResponse(id, result, null); + } + + /** + * Creates an error JSON-RPC response. + * + * @param id The id from the corresponding request. + * @param code The error code. + * @param data Additional data about the error. + * @return A populated JsonRpcResponse. + */ + public static JsonRpcResponse error(Object id, JsonRpcErrorCode code, Object data) { + return new JsonRpcResponse( + id, null, new ErrorDetail(code.getCode(), code.getMessage(), data(data))); + } + + private static Object data(Object data) { + if (data instanceof Throwable cause) { + return cause.getMessage(); + } + return data; + } + + public String getJsonrpc() { + return jsonrpc; + } + + public void setJsonrpc(String jsonrpc) { + this.jsonrpc = jsonrpc; + } + + public Object getResult() { + return result; + } + + public void setResult(Object result) { + this.result = result; + } + + public ErrorDetail getError() { + return error; + } + + public void setError(ErrorDetail error) { + this.error = error; + } + + public Object getId() { + return id; + } + + public void setId(Object id) { + this.id = id; + } + + /** Represents the error object inside a JSON-RPC response. */ + public static class ErrorDetail { + private int code; + private String message; + private Object data; + + public ErrorDetail() {} + + public ErrorDetail(int code, String message, Object data) { + this.code = code; + this.message = message; + this.data = data; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Object getData() { + return data; + } + + public void setData(Object data) { + this.data = data; + } + } +} diff --git a/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java new file mode 100644 index 0000000000..c538318951 --- /dev/null +++ b/jooby/src/main/java/io/jooby/jsonrpc/JsonRpcService.java @@ -0,0 +1,32 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jsonrpc; + +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; + +/** + * Interface for generated JSON-RPC service glue code (*Rpc classes). + * + *

This interface allows the global {@link JsonRpcModule} to coordinate multiple JSON-RPC + * services on a single endpoint by checking which service supports a specific method namespace. + */ +public interface JsonRpcService { + + List getMethods(); + + /** + * Executes the requested method using the provided context and request data. + * + * @param ctx The current Jooby context. + * @param req The individual JSON-RPC request object. + * @return The result of the method invocation. + * @throws Exception If an error occurs during execution. + */ + Object execute(@NonNull Context ctx, @NonNull JsonRpcRequest req) throws Exception; +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java index c10b92b68f..7226483c34 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java @@ -24,7 +24,7 @@ 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. */ + /** The server received invalid JSON. Mapped to HTTP 400. */ PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), /** Internal server error. Mapped to HTTP 500. */ diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java index a0a2f8de49..c66af74129 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.jsonrpc; exports io.jooby.trpc; uses io.jooby.Server; 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 e67006a707..96d561ab84 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 @@ -129,21 +129,71 @@ public boolean process(Set annotations, RoundEnvironment try { if (roundEnv.processingOver()) { context.debug("Output:"); - context.getRouters().forEach(it -> context.debug(" %s.java", it.getGeneratedType())); + // Print all generated types for both REST and RPC + context + .getRouters() + .forEach( + it -> { + if (it.hasRestRoutes()) { + context.debug(" %s", it.getRestGeneratedType()); + } + if (it.hasJsonRpcRoutes()) { + context.debug(" %s", it.getRpcGeneratedType()); + } + }); return false; } else { var routeMap = buildRouteRegistry(annotations, roundEnv); verifyBeanValidationDependency(routeMap.values()); for (var router : routeMap.values()) { try { + // Track the router unconditionally so routes are available in processingOver context.add(router); - var sourceCode = router.toSourceCode(null); - var sourceLocation = router.getGeneratedFilename(); - onGeneratedSource( - router.getGeneratedType(), toJavaFileObject(sourceLocation, sourceCode)); - context.debug("router %s: %s", router.getTargetType(), router.getGeneratedType()); - router.getRoutes().forEach(it -> context.debug(" %s", it)); - writeSource(router, sourceLocation, sourceCode); + + // 1. Generate Standard REST/tRPC File (e.g., MovieService_.java) + if (router.hasRestRoutes()) { + var restSource = router.getRestSourceCode(null); + if (restSource != null) { + var sourceLocation = router.getRestGeneratedFilename(); + var generatedType = router.getRestGeneratedType(); + onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, restSource)); + + context.debug("router %s: %s", router.getTargetType(), generatedType); + router.getRoutes().stream() + .filter(it -> !it.isJsonRpc()) + .forEach(it -> context.debug(" %s", it)); + + writeSource( + router.isKt(), + generatedType, + sourceLocation, + restSource, + router.getTargetType()); + } + } + + // 2. Generate JSON-RPC File (e.g., MovieServiceRpc_.java) + if (router.hasJsonRpcRoutes()) { + var rpcSource = router.getRpcSourceCode(null); + if (rpcSource != null) { + var sourceLocation = router.getRpcGeneratedFilename(); + var generatedType = router.getRpcGeneratedType(); + onGeneratedSource(generatedType, toJavaFileObject(sourceLocation, rpcSource)); + + context.debug("jsonrpc router %s: %s", router.getTargetType(), generatedType); + router.getRoutes().stream() + .filter(MvcRoute::isJsonRpc) + .forEach(it -> context.debug(" %s", it)); + + writeSource( + router.isKt(), + generatedType, + sourceLocation, + rpcSource, + router.getTargetType()); + } + } + } catch (IOException cause) { throw new RuntimeException("Unable to generate: " + router.getTargetType(), cause); } @@ -157,25 +207,29 @@ public boolean process(Set annotations, RoundEnvironment } } - private void writeSource(MvcRouter router, String sourceLocation, String sourceCode) + private void writeSource( + boolean isKt, + String className, + String sourceLocation, + String sourceCode, + Element... originatingElements) throws IOException { var environment = context.getProcessingEnvironment(); var filer = environment.getFiler(); - if (router.isKt()) { + if (isKt) { var kapt = environment.getOptions().get("kapt.kotlin.generated"); if (kapt != null) { var output = Paths.get(kapt, sourceLocation); Files.createDirectories(output.getParent()); Files.writeString(output, sourceCode); } else { - var ktFile = - filer.createResource(SOURCE_OUTPUT, "", sourceLocation, router.getTargetType()); + var ktFile = filer.createResource(SOURCE_OUTPUT, "", sourceLocation, originatingElements); try (var writer = ktFile.openWriter()) { writer.write(sourceCode); } } } else { - var javaFIle = filer.createSourceFile(router.getGeneratedType(), router.getTargetType()); + var javaFIle = filer.createSourceFile(className, originatingElements); try (var writer = javaFIle.openWriter()) { writer.write(sourceCode); } @@ -223,12 +277,18 @@ private Map buildRouteRegistry( for (var element : elements) { context.debug(" %s", element); if (element instanceof TypeElement typeElement) { + // FORCE INIT: Ensures MvcRouter constructor executes our JsonRpc class-level rules + registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type)); buildRouteRegistry(registry, typeElement); } else if (element instanceof ExecutableElement method) { - buildRouteRegistry(registry, (TypeElement) method.getEnclosingElement()); + TypeElement typeElement = (TypeElement) method.getEnclosingElement(); + // FORCE INIT + registry.computeIfAbsent(typeElement, type -> new MvcRouter(context, type)); + buildRouteRegistry(registry, typeElement); } } } + // Remove all abstract router var abstractTypes = registry.entrySet().stream() @@ -344,7 +404,7 @@ public Set getSupportedOptions() { *

Example usage: * *

public void run() {
-   *     throw sneakyThrow(new IOException("You don't need to catch me!"));
+   * throw sneakyThrow(new IOException("You don't need to catch me!"));
    * }
* *

NB: The exception is not wrapped, ignored, swallowed, or redefined. The JVM actually does 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 9b4fb93bbd..f7d2026edc 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 @@ -33,7 +33,9 @@ public enum HttpMethod implements AnnotationSupport { List.of( "io.jooby.annotation.Trpc", "io.jooby.annotation.Trpc.Mutation", - "io.jooby.annotation.Trpc.Query")); + "io.jooby.annotation.Trpc.Query")), + JSON_RPC(List.of("io.jooby.annotation.JsonRpc")); + private final List annotations; HttpMethod(String... packages) { @@ -46,13 +48,6 @@ public enum HttpMethod implements AnnotationSupport { this.annotations = annotations; } - /** - * Look at path attribute over HTTP method annotation (like io.jooby.annotation.GET) or fallback - * to Path annotation. - * - * @param element Type or Method. - * @return Path. - */ public List path(Element element) { var path = annotations.stream() @@ -64,24 +59,10 @@ public List path(Element element) { return path.isEmpty() ? HttpPath.PATH.path(element) : path; } - /** - * Look at consumes attribute over HTTP method annotation (like io.jooby.annotation.GET) or - * fallback to Consumes annotation. - * - * @param element Type or Method. - * @return Consumes media type. - */ public List consumes(Element element) { return mediaType(element, HttpMediaType.Consumes, "consumes"::equals); } - /** - * Look at produces attribute over HTTP method annotation (like io.jooby.annotation.GET) or - * fallback to Produces annotation. - * - * @param element Type or Method. - * @return Produces media type. - */ public List produces(Element element) { return mediaType(element, HttpMediaType.Produces, "produces"::equals); } 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 55f77d60d1..e9a5024a5d 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,7 +7,7 @@ import static io.jooby.internal.apt.AnnotationSupport.*; import static io.jooby.internal.apt.CodeBlock.*; -import static java.lang.System.*; +import static java.lang.System.lineSeparator; import static java.util.Optional.ofNullable; import java.util.*; @@ -33,7 +33,9 @@ public class MvcRoute { private boolean uncheckedCast; private final boolean hasBeanValidation; private final Set pending = new HashSet<>(); + private boolean isTrpc = false; + private boolean isJsonRpc = false; private HttpMethod resolvedTrpcMethod = null; public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { @@ -125,6 +127,10 @@ public TypeMirror getReturnTypeHandler() { } public List generateMapping(boolean kt) { + if (isJsonRpc) { + return Collections.emptyList(); + } + List block = new ArrayList<>(); var methodName = getGeneratedName(); var returnType = getReturnType(); @@ -244,7 +250,56 @@ static String leadingSlash(String path) { return path.charAt(0) == '/' ? path : "/" + path; } + public List generateJsonRpcDispatchCase(boolean kt) { + var buffer = new ArrayList(); + var paramList = new StringJoiner(", ", "(", ")"); + + // Check if we have any parameters that actually need to be parsed from the JSON payload. + // We ignore Jooby's Context and Kotlin's Continuation since they are provided by the framework. + boolean needsReader = + parameters.stream() + .anyMatch( + p -> { + String type = p.getType().toString(); + return !type.equals("io.jooby.Context") + && !type.startsWith("kotlin.coroutines.Continuation"); + }); + + if (needsReader) { + if (kt) { + buffer.add(statement(indent(8), "parser.reader(req.params).use { reader ->")); + } else { + buffer.add(statement(indent(8), "try (var reader = parser.reader(req.getParams())) {")); + } + } + + // This method will now be responsible for pushing "ctx" directly to paramList + // for Context parameters, instead of reading them from the JSON. + buffer.addAll(generateRpcParameter(kt, paramList::add, true)); + + // Dynamically adjust indentation based on whether the reader block was opened + int callIndent = needsReader ? 10 : 8; + var call = CodeBlock.of("c.", getMethodName(), paramList.toString()); + + if (returnType.isVoid()) { + buffer.add(statement(indent(callIndent), call, semicolon(kt))); + buffer.add(statement(indent(callIndent), kt ? "null" : "return null", semicolon(kt))); + } else { + buffer.add(statement(indent(callIndent), kt ? call : "return " + call, semicolon(kt))); + } + + if (needsReader) { + buffer.add(statement(indent(8), "}")); + } + + return buffer; + } + public List generateHandlerCall(boolean kt) { + if (isJsonRpc) { + return Collections.emptyList(); + } + var buffer = new ArrayList(); var methodName = isTrpc @@ -269,7 +324,6 @@ public List generateHandlerCall(boolean kt) { var isReactiveVoid = false; var innerReactiveType = "Object"; - // 1. Resolve Target Signature var methodReturnTypeString = returnTypeString; if (isTrpc) { if (reactive != null) { @@ -317,7 +371,6 @@ public List generateHandlerCall(boolean kt) { ")", semicolon(kt))); - // Calculate actual tRPC payload parameters (ignore Context and Coroutines) long trpcPayloadCount = parameters.stream() .filter( @@ -339,7 +392,7 @@ public List generateHandlerCall(boolean kt) { ").value()", semicolon(kt))); - if (isTuple) { // <-- Use calculated isTuple + if (isTuple) { if (kt) { buffer.add( statement( @@ -362,7 +415,7 @@ public List generateHandlerCall(boolean kt) { } else { buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); - if (isTuple) { // <-- Use calculated isTuple + if (isTuple) { if (kt) { buffer.add( statement( @@ -399,7 +452,7 @@ public List generateHandlerCall(boolean kt) { ")) {")); } - buffer.addAll(generateTrpcParameter(kt, paramList::add)); + buffer.addAll(generateRpcParameter(kt, paramList::add, false)); } else if (!isTrpc) { for (var parameter : getParameters(true)) { String generatedParameter = parameter.generateMapping(kt); @@ -414,7 +467,6 @@ public List generateHandlerCall(boolean kt) { controllerVar(kt, buffer, controllerIndent); - // 2. Resolve Return Flow if (returnType.isVoid()) { String statusCode = annotationMap.size() == 1 @@ -543,7 +595,6 @@ public List generateHandlerCall(boolean kt) { 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( @@ -620,7 +671,7 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement(indent(2), "}")); } - buffer.add(statement("}", System.lineSeparator())); + buffer.add(statement("}", lineSeparator())); if (uncheckedCast) { if (kt) { @@ -690,8 +741,13 @@ private boolean methodCallHeader( return nullable; } - private List generateTrpcParameter(boolean kt, Consumer arguments) { + private List generateRpcParameter( + boolean kt, Consumer arguments, boolean isJsonRpc) { var statements = new ArrayList(); + var decoderInterface = + isJsonRpc ? "io.jooby.jsonrpc.JsonRpcDecoder" : "io.jooby.trpc.TrpcDecoder"; + int baseIndent = isJsonRpc ? 10 : 4; + for (var parameter : parameters) { var paramenterName = parameter.getName(); var type = type(kt, parameter.getType().toString()); @@ -719,7 +775,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument if (kt) { statements.add( statement( - indent(4), + indent(baseIndent), "val ", paramenterName, " = if (reader.nextIsNull(", @@ -732,7 +788,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument } else { statements.add( statement( - indent(4), + indent(baseIndent), var(kt), paramenterName, " = reader.nextIsNull(", @@ -747,7 +803,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument } else { statements.add( statement( - indent(4), + indent(baseIndent), var(kt), paramenterName, " = reader.", @@ -782,7 +838,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument + "()"; statements.add( statement( - indent(4), + indent(baseIndent), "val ", paramenterName, " = if (reader.nextIsNull(", @@ -799,7 +855,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument var javaSuffix = isChar ? ".charAt(0)" : ""; statements.add( statement( - indent(4), + indent(baseIndent), var(kt), paramenterName, " = reader.nextIsNull(", @@ -825,7 +881,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument + "()"; statements.add( statement( - indent(4), + indent(baseIndent), var(kt), paramenterName, " = reader.", @@ -841,7 +897,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument var javaSuffix = isChar ? ".charAt(0)" : ""; statements.add( statement( - indent(4), + indent(baseIndent), var(kt), paramenterName, " = ", @@ -861,10 +917,12 @@ private List generateTrpcParameter(boolean kt, Consumer argument if (kt) { statements.add( statement( - indent(4), + indent(baseIndent), "val ", paramenterName, - "Decoder: io.jooby.trpc.TrpcDecoder<", + "Decoder: ", + decoderInterface, + "<", type, "> = parser.decoder(", parameter.getType().toSourceCode(kt), @@ -873,7 +931,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument if (isNullable) { statements.add( statement( - indent(4), + indent(baseIndent), "val ", paramenterName, " = if (reader.nextIsNull(", @@ -886,7 +944,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument } else { statements.add( statement( - indent(4), + indent(baseIndent), "val ", paramenterName, " = reader.nextObject(", @@ -899,8 +957,9 @@ private List generateTrpcParameter(boolean kt, Consumer argument } else { statements.add( statement( - indent(4), - "io.jooby.trpc.TrpcDecoder<", + indent(baseIndent), + decoderInterface, + "<", type, "> ", paramenterName, @@ -911,7 +970,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument if (isNullable) { statements.add( statement( - indent(4), + indent(baseIndent), parameter.getType().toString(), " ", paramenterName, @@ -926,7 +985,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument } else { statements.add( statement( - indent(4), + indent(baseIndent), parameter.getType().toString(), " ", paramenterName, @@ -962,16 +1021,27 @@ public void setGeneratedName(String generatedName) { } public MvcRoute addHttpMethod(TypeElement annotation) { - var annotationMirror = - ofNullable(findAnnotationByName(this.method, annotation.getQualifiedName().toString())) - .orElseThrow(() -> new IllegalArgumentException("Annotation not found: " + annotation)); + String annotationName = annotation.getQualifiedName().toString(); + var annotationMirror = findAnnotationByName(this.method, annotationName); + + // Fallback to the class-level annotation if the method isn't explicitly annotated + if (annotationMirror == null) { + annotationMirror = findAnnotationByName(this.method.getEnclosingElement(), annotationName); + } + + if (annotationMirror == null) { + throw 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) { + var httpMethod = HttpMethod.findByAnnotationName(annotationName); + if (httpMethod == HttpMethod.tRPC) { this.isTrpc = true; } + if (httpMethod == HttpMethod.JSON_RPC) { + this.isJsonRpc = true; + } return this; } @@ -1016,15 +1086,31 @@ public String getMethodName() { return getMethod().getSimpleName().toString(); } + public String getJsonRpcMethodName() { + var annotation = AnnotationSupport.findAnnotationByName(method, "io.jooby.jsonrpc.JsonRpc"); + if (annotation != null) { + var val = + AnnotationSupport.findAnnotationValue(annotation, VALUE).stream().findFirst().orElse(""); + if (!val.isEmpty()) return val; + } + return getMethodName(); + } + + public boolean isJsonRpc() { + return isJsonRpc; + } + @Override public int hashCode() { - return Objects.hash(method.toString(), isTrpc); + return Objects.hash(method.toString(), isTrpc, isJsonRpc); } @Override public boolean equals(Object obj) { if (obj instanceof MvcRoute that) { - return this.method.toString().equals(that.method.toString()) && this.isTrpc == that.isTrpc; + return this.method.toString().equals(that.method.toString()) + && this.isTrpc == that.isTrpc + && this.isJsonRpc == that.isJsonRpc; } return false; } @@ -1105,7 +1191,6 @@ public boolean 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; } @@ -1114,30 +1199,23 @@ private HttpMethod trpcMethod(Element element) { 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."); + + " is annotated with @Trpc but lacks a valid HTTP method annotation."); } return null; } 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 ebbe396fe4..0788b0144f 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 @@ -5,9 +5,9 @@ */ package io.jooby.internal.apt; +import static io.jooby.internal.apt.AnnotationSupport.VALUE; import static io.jooby.internal.apt.AnnotationSupport.findAnnotationByName; -import static io.jooby.internal.apt.CodeBlock.indent; -import static io.jooby.internal.apt.CodeBlock.semicolon; +import static io.jooby.internal.apt.CodeBlock.*; import static java.util.Collections.emptyList; import java.io.IOException; @@ -19,7 +19,6 @@ import java.util.stream.Stream; import javax.lang.model.element.*; -import javax.tools.JavaFileObject; public class MvcRouter { private final MvcContext context; @@ -33,6 +32,58 @@ public class MvcRouter { public MvcRouter(MvcContext context, TypeElement clazz) { this.context = context; this.clazz = clazz; + + // JSON-RPC Method Discovery Logic + var classJsonRpcAnno = + AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); + + List explicitlyAnnotated = new ArrayList<>(); + List allPublicMethods = new ArrayList<>(); + + for (var enclosed : clazz.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.METHOD) { + var method = (ExecutableElement) enclosed; + var modifiers = method.getModifiers(); + + // Only consider public, non-static, non-abstract methods + if (modifiers.contains(Modifier.PUBLIC) + && !modifiers.contains(Modifier.STATIC) + && !modifiers.contains(Modifier.ABSTRACT)) { + + // Ignore standard Java Object methods + String methodName = method.getSimpleName().toString(); + if (methodName.equals("toString") + || methodName.equals("hashCode") + || methodName.equals("equals") + || methodName.equals("clone")) { + continue; + } + + allPublicMethods.add(method); + var methodJsonRpcAnno = + AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); + if (methodJsonRpcAnno != null) { + explicitlyAnnotated.add(method); + } + } + } + } + + if (!explicitlyAnnotated.isEmpty()) { + // Rule 2: If one or more methods are explicitly annotated, ONLY expose those methods. + for (var method : explicitlyAnnotated) { + var methodAnno = + AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.JsonRpc"); + TypeElement annoElement = (TypeElement) methodAnno.getAnnotationType().asElement(); + put(annoElement, method); + } + } else if (classJsonRpcAnno != null) { + // Rule 1: Class is annotated, but no specific methods are. Expose ALL public methods. + var annoElement = (TypeElement) classJsonRpcAnno.getAnnotationType().asElement(); + for (var method : allPublicMethods) { + put(annoElement, method); + } + } } public MvcRouter(TypeElement clazz, MvcRouter parent) { @@ -57,19 +108,24 @@ public TypeElement getTargetType() { } public String getGeneratedType() { - return context.generateRouterName(getTargetType().getQualifiedName().toString()); + String baseName = getTargetType().getQualifiedName().toString(); + String name = isJsonRpc() ? baseName + "Rpc" : baseName; + return context.generateRouterName(name); } public String getGeneratedFilename() { - return getGeneratedType().replace('.', '/') - + (isKt() ? ".kt" : JavaFileObject.Kind.SOURCE.extension); + return getGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); } public MvcRouter put(TypeElement httpMethod, ExecutableElement route) { var isTrpc = HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) == HttpMethod.tRPC; - var routeKey = (isTrpc ? "trpc" : "") + route.toString(); + var isJsonRpc = + HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) + == HttpMethod.JSON_RPC; + + var routeKey = (isTrpc ? "trpc" : (isJsonRpc ? "jsonrpc" : "")) + route.toString(); var existing = routes.get(routeKey); if (existing == null) { @@ -99,6 +155,44 @@ public String getPackageName() { return pkgEnd > 0 ? classname.substring(0, pkgEnd) : ""; } + public boolean isJsonRpc() { + return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc); + } + + public String getJsonRpcNamespace() { + var annotation = AnnotationSupport.findAnnotationByName(clazz, "io.jooby.annotation.JsonRpc"); + if (annotation != null) { + return AnnotationSupport.findAnnotationValue(annotation, VALUE).stream() + .findFirst() + .orElse(""); + } + return ""; + } + + public boolean hasRestRoutes() { + return getRoutes().stream().anyMatch(it -> !it.isJsonRpc()); + } + + public boolean hasJsonRpcRoutes() { + return getRoutes().stream().anyMatch(MvcRoute::isJsonRpc); + } + + public String getRestGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString()); + } + + public String getRpcGeneratedType() { + return context.generateRouterName(getTargetType().getQualifiedName().toString() + "Rpc"); + } + + public String getRestGeneratedFilename() { + return getRestGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); + } + + public String getRpcGeneratedFilename() { + return getRpcGeneratedType().replace('.', '/') + (isKt() ? ".kt" : ".java"); + } + /** * Generate the controller extension for MVC controller: * @@ -109,23 +203,29 @@ public String getPackageName() { * * } * - * @return + * @return The source code to write, or null if the controller only contains JSON-RPC routes. */ - public String toSourceCode(Boolean generateKotlin) throws IOException { + public String getRestSourceCode(Boolean generateKotlin) throws IOException { + var mvcRoutes = this.routes.values().stream().filter(it -> !it.isJsonRpc()).toList(); + + if (mvcRoutes.isEmpty()) { + return null; // Safety check if called on a JSON-RPC-only controller + } + var kt = generateKotlin == Boolean.TRUE || isKt(); - var generateTypeName = context.generateRouterName(getTargetType().getSimpleName().toString()); + var generateTypeName = getTargetType().getSimpleName().toString(); + var generatedClass = context.generateRouterName(generateTypeName); + try (var in = getClass().getResourceAsStream("Source" + (kt ? ".kt" : ".java"))) { Objects.requireNonNull(in); - var routes = this.routes.values(); - var suspended = routes.stream().filter(MvcRoute::isSuspendFun).toList(); - var noSuspended = routes.stream().filter(it -> !it.isSuspendFun()).toList(); + var suspended = mvcRoutes.stream().filter(MvcRoute::isSuspendFun).toList(); + var noSuspended = mvcRoutes.stream().filter(it -> !it.isSuspendFun()).toList(); var buffer = new StringBuilder(); context.generateStaticImports( this, (owner, fn) -> buffer.append( - CodeBlock.statement( - "import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); + statement("import ", kt ? "" : "static ", owner, ".", fn, semicolon(kt)))); var imports = buffer.toString(); buffer.setLength(0); @@ -143,13 +243,13 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { .append(System.lineSeparator()); } if (!suspended.isEmpty()) { - buffer.append(CodeBlock.statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); - buffer.append(CodeBlock.statement(indent(6), "kooby.coroutine {")); + buffer.append(statement(indent(6), "val kooby = app as io.jooby.kt.Kooby")); + buffer.append(statement(indent(6), "kooby.coroutine {")); suspended.stream() .flatMap(it -> it.generateMapping(kt).stream()) .forEach(line -> buffer.append(CodeBlock.indent(8)).append(line)); trimr(buffer); - buffer.append(System.lineSeparator()).append(CodeBlock.statement(indent(6), "}")); + buffer.append(System.lineSeparator()).append(statement(indent(6), "}")); } noSuspended.stream() .flatMap(it -> it.generateMapping(kt).stream()) @@ -163,19 +263,274 @@ public String toSourceCode(Boolean generateKotlin) throws IOException { .append(System.lineSeparator()); // end install - routes.stream() + mvcRoutes.stream() .flatMap(it -> it.generateHandlerCall(kt).stream()) .forEach(line -> buffer.append(CodeBlock.indent(4)).append(line)); + return new String(in.readAllBytes(), StandardCharsets.UTF_8) .replace("${packageName}", getPackageName()) .replace("${imports}", imports) - .replace("${className}", getTargetType().getSimpleName()) - .replace("${generatedClassName}", generateTypeName) - .replace("${constructors}", constructors(generateTypeName, kt)) + .replace("${className}", generateTypeName) + .replace("${generatedClassName}", generatedClass) + .replace("${constructors}", constructors(generatedClass, kt)) .replace("${methods}", trimr(buffer)); } } + public String getRpcSourceCode(Boolean generateKotlin) { + if (!hasJsonRpcRoutes()) { + return null; + } + return generateJsonRpcService(generateKotlin == Boolean.TRUE || isKt()); + } + + private String generateJsonRpcService(boolean kt) { + var buffer = new StringBuilder(); + var generateTypeName = getTargetType().getSimpleName().toString(); + var rpcClassName = context.generateRouterName(generateTypeName + "Rpc"); + var namespace = getJsonRpcNamespace(); + var packageName = getPackageName(); + + var rpcRoutes = getRoutes().stream().filter(MvcRoute::isJsonRpc).toList(); + + List fullMethods = new ArrayList<>(); + for (MvcRoute route : rpcRoutes) { + String routeName = route.getJsonRpcMethodName(); + fullMethods.add(namespace.isEmpty() ? routeName : namespace + "." + routeName); + } + + String methodListString = + fullMethods.stream().map(m -> "\"" + m + "\"").collect(Collectors.joining(", ")); + + buffer.append(statement("package ", packageName, semicolon(kt))); + buffer.append(System.lineSeparator()); + + buffer.append(statement("@io.jooby.annotation.Generated(", generateTypeName, clazz(kt), ")")); + + if (kt) { + buffer.append( + statement( + "class ", rpcClassName, " : io.jooby.jsonrpc.JsonRpcService, io.jooby.Extension {")); + buffer.append( + statement( + indent(2), + "protected lateinit var factory: (io.jooby.Context) -> ", + generateTypeName)); + + String ktConstructors = constructors(rpcClassName, true).toString().replaceAll("(?m)^ ", ""); + buffer.append(ktConstructors); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "constructor(instance: ", generateTypeName, ") {")); + buffer.append(statement(indent(4), "setup { ctx -> instance }")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), "constructor(provider: javax.inject.Provider<", generateTypeName, ">) {")); + buffer.append(statement(indent(4), "setup { ctx -> provider.get() }")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), + "constructor(provider: java.util.function.Function, ", + generateTypeName, + ">) {")); + buffer.append( + statement( + indent(4), "setup { ctx -> provider.apply(", generateTypeName, "::class.java) }")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), + "private fun setup(factory: (io.jooby.Context) -> ", + generateTypeName, + ") {")); + buffer.append(statement(indent(4), "this.factory = factory")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "override fun install(app: io.jooby.Jooby) {")); + buffer.append( + statement( + indent(4), + "app.services.listOf(io.jooby.jsonrpc.JsonRpcService::class.java).add(this)")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "override fun getMethods(): List {")); + buffer.append(statement(indent(4), "return listOf(", methodListString, ")")); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "@Throws(Exception::class)")); + buffer.append( + statement( + indent(2), + "override fun execute(ctx: io.jooby.Context, req: io.jooby.jsonrpc.JsonRpcRequest):" + + " Any? {")); + buffer.append(statement(indent(4), "val c = factory(ctx)")); + buffer.append(statement(indent(4), "val method = req.method")); + buffer.append( + statement( + indent(4), + var(kt), + "parser = ctx.require(io.jooby.jsonrpc.JsonRpcParser", + clazz(kt), + ")", + semicolon(kt))); + buffer.append(statement(indent(4), "return when(method) {")); + + for (int i = 0; i < rpcRoutes.size(); i++) { + buffer.append(statement(indent(6), "\"", fullMethods.get(i), "\" -> {")); + rpcRoutes.get(i).generateJsonRpcDispatchCase(true).forEach(buffer::append); + buffer.append(statement(indent(6), "}")); + } + + buffer.append( + statement( + indent(6), + "else -> throw" + + " io.jooby.jsonrpc.JsonRpcException(io.jooby.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: $method\")")); + buffer.append(statement(indent(4), "}")); + buffer.append(statement(indent(2), "}")); + + } else { + buffer.append( + statement( + "public class ", + rpcClassName, + " implements io.jooby.jsonrpc.JsonRpcService, io.jooby.Extension {")); + buffer.append( + statement( + indent(2), + "protected java.util.function.Function factory", + semicolon(kt))); + + String javaConstructors = + constructors(rpcClassName, false).toString().replaceAll("(?m)^ ", ""); + buffer.append(javaConstructors); + buffer.append(System.lineSeparator()); + + buffer.append( + statement(indent(2), "public ", rpcClassName, "(", generateTypeName, " instance) {")); + buffer.append(statement(indent(4), "setup(ctx -> instance)", semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), + "public ", + rpcClassName, + "(io.jooby.SneakyThrows.Supplier<", + generateTypeName, + "> provider) {")); + buffer.append( + statement( + indent(4), "setup(ctx -> (", generateTypeName, ") provider.get())", semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), + "public ", + rpcClassName, + "(io.jooby.SneakyThrows.Function, ", + generateTypeName, + "> provider) {")); + buffer.append( + statement( + indent(4), + "setup(ctx -> provider.apply(", + generateTypeName, + ".class))", + semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append( + statement( + indent(2), + "private void setup(java.util.function.Function factory) {")); + buffer.append(statement(indent(4), "this.factory = factory", semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "@Override")); + buffer.append( + statement(indent(2), "public void install(io.jooby.Jooby app) throws Exception {")); + buffer.append( + statement( + indent(4), + "app.getServices().listOf(io.jooby.jsonrpc.JsonRpcService.class).add(this)", + semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "@Override")); + buffer.append(statement(indent(2), "public java.util.List getMethods() {")); + buffer.append( + statement(indent(4), "return java.util.List.of(", methodListString, ")", semicolon(kt))); + buffer.append(statement(indent(2), "}")); + buffer.append(System.lineSeparator()); + + buffer.append(statement(indent(2), "@Override")); + buffer.append( + statement( + indent(2), + "public Object execute(io.jooby.Context ctx, io.jooby.jsonrpc.JsonRpcRequest req)" + + " throws Exception {")); + buffer.append( + statement(indent(4), generateTypeName, " c = factory.apply(ctx)", semicolon(kt))); + buffer.append(statement(indent(4), "String method = req.getMethod()", semicolon(kt))); + buffer.append( + statement( + indent(4), + var(kt), + "parser = ctx.require(io.jooby.jsonrpc.JsonRpcParser", + clazz(kt), + ")", + semicolon(kt))); + buffer.append(statement(indent(4), "switch(method) {")); + + for (int i = 0; i < rpcRoutes.size(); i++) { + buffer.append(statement(indent(6), "case \"", fullMethods.get(i), "\": {")); + rpcRoutes.get(i).generateJsonRpcDispatchCase(false).forEach(buffer::append); + buffer.append(statement(indent(6), "}")); + } + + buffer.append( + statement( + indent(6), + "default: throw new" + + " io.jooby.jsonrpc.JsonRpcException(io.jooby.jsonrpc.JsonRpcErrorCode.METHOD_NOT_FOUND," + + " \"Method not found: \" + method)", + semicolon(kt))); + buffer.append(statement(indent(4), "}")); + buffer.append(statement(indent(2), "}")); + } + + buffer.append(statement("}")); + + return buffer.toString(); + } + private StringBuilder trimr(StringBuilder buffer) { var i = buffer.length() - 1; while (i > 0 && Character.isWhitespace(buffer.charAt(i))) { diff --git a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java index ab3c3005ef..15f39a62da 100644 --- a/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java +++ b/modules/jooby-apt/src/test/java/io/jooby/apt/ProcessorRunner.java @@ -79,7 +79,7 @@ protected void onGeneratedSource(String classname, JavaFileObject source) { javaFiles.put(classname, source); try { // Generate kotlin source code inside the compiler scope... avoid false positive errors - kotlinFiles.put(classname, context.getRouters().get(0).toSourceCode(true)); + kotlinFiles.put(classname, context.getRouters().get(0).getRestSourceCode(true)); } catch (IOException e) { SneakyThrows.propagate(e); } diff --git a/modules/jooby-apt/src/test/java/tests/i3864/C3864.java b/modules/jooby-apt/src/test/java/tests/i3864/C3864.java new file mode 100644 index 0000000000..ff4c9693d4 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3864/C3864.java @@ -0,0 +1,18 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3864; + +import io.jooby.Context; +import io.jooby.annotation.JsonRpc; + +@JsonRpc("users") +public class C3864 { + + @JsonRpc + public String ping(Context ctx, int year) { + return null; + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.java new file mode 100644 index 0000000000..237b2e197c --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3864/Issue3868.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.i3864; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3868 { + @Test + public void shouldGenerateJsonRpcService() throws Exception { + new ProcessorRunner(new C3864()) + .withSourceCode( + source -> { + System.out.println(source); + }); + } +} 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 a1e97b2ff2..6e9fb58b06 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 @@ -15,9 +15,10 @@ 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.internal.avaje.jsonb.*; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcRequest; +import io.jooby.jsonrpc.JsonRpcResponse; import io.jooby.output.Output; import io.jooby.trpc.TrpcErrorCode; import io.jooby.trpc.TrpcParser; @@ -82,7 +83,7 @@ public AvajeJsonbModule(@NonNull Jsonb jsonb) { /** Creates a new Avaje-JsonB module. */ public AvajeJsonbModule() { - this(Jsonb.builder().add(TrpcResponse.class, trpcResponseAdapter()).build()); + this(builder().build()); } @Override @@ -98,6 +99,8 @@ public void install(@NonNull Jooby application) throws Exception { services .mapOf(Class.class, TrpcErrorCode.class) .put(JsonDataException.class, TrpcErrorCode.BAD_REQUEST); + // JSON-RPC + services.put(JsonRpcParser.class, new AvajeJsonRpcParser(jsonb)); } @Override @@ -156,12 +159,12 @@ private void encodeProjection(JsonWriter writer, Projected projected) { view.toJson(value, writer); } - /** - * Custom adapter for {@link TrpcResponse}. - * - * @return Custom adapter for {@link TrpcResponse}. - */ - public static Jsonb.AdapterBuilder trpcResponseAdapter() { - return AvajeTrpcResponseAdapter::new; + public static Jsonb.Builder builder() { + var jsonb = Jsonb.builder(); + jsonb.add(TrpcResponse.class, AvajeTrpcResponseAdapter::new); + jsonb.add(JsonRpcRequest.class, AvajeJsonRpcRequestAdapter::new); + jsonb.add(JsonRpcResponse.class, AvajeJsonRpcResponseAdapter::new); + jsonb.add(JsonRpcResponse.ErrorDetail.class, AvajeJsonRpcErrorAdapter::new); + return jsonb; } } diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java new file mode 100644 index 0000000000..de770b6a44 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcDecoder.java @@ -0,0 +1,41 @@ +/* + * 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.JsonType; +import io.avaje.jsonb.Jsonb; +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; + +public class AvajeJsonRpcDecoder implements JsonRpcDecoder { + + private final Jsonb jsonb; + private final Type type; + private final JsonType typeAdapter; + + public AvajeJsonRpcDecoder(Jsonb jsonb, Type type) { + this.jsonb = jsonb; + this.type = type; + this.typeAdapter = jsonb.type(type); + } + + @Override + public T decode(String name, Object node) { + try { + if (node == null) { + throw new MissingValueException(name); + } + // Convert the Map/List/primitive back to JSON, then to the target type + // This leverages Avaje's exact mappings without needing a tree traversal model + return typeAdapter.fromJson(jsonb.toJson(node)); + } catch (Exception x) { + throw new TypeMismatchException(name, type, x); + } + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java new file mode 100644 index 0000000000..070a226272 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcErrorAdapter.java @@ -0,0 +1,44 @@ +/* + * 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.JsonAdapter; +import io.avaje.json.JsonReader; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.Jsonb; +import io.jooby.jsonrpc.JsonRpcResponse.ErrorDetail; + +public class AvajeJsonRpcErrorAdapter implements JsonAdapter { + + private final Jsonb jsonb; + + public AvajeJsonRpcErrorAdapter(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public void toJson(JsonWriter writer, ErrorDetail error) { + writer.beginObject(); + + writer.name("code"); + writer.value(error.getCode()); + + writer.name("message"); + writer.value(error.getMessage()); + + if (error.getData() != null) { + writer.name("data"); + jsonb.adapter(Object.class).toJson(writer, error.getData()); + } + + writer.endObject(); + } + + @Override + public ErrorDetail fromJson(JsonReader reader) { + throw new UnsupportedOperationException("Servers don't deserialize error responses"); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java new file mode 100644 index 0000000000..26b3053d84 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcParser.java @@ -0,0 +1,33 @@ +/* + * 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.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcReader; + +public class AvajeJsonRpcParser implements JsonRpcParser { + + private final Jsonb jsonb; + + public AvajeJsonRpcParser(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public JsonRpcDecoder decoder(Type type) { + return new AvajeJsonRpcDecoder<>(jsonb, type); + } + + @Override + public JsonRpcReader reader(Object params) { + // params will be either a List (positional) or a Map (named) + return new AvajeJsonRpcReader(params); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java new file mode 100644 index 0000000000..8620eb46f3 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcReader.java @@ -0,0 +1,108 @@ +/* + * 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.List; +import java.util.Map; + +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcReader; + +public class AvajeJsonRpcReader implements JsonRpcReader { + + private final Map map; + private final List list; + private int index = 0; + + @SuppressWarnings("unchecked") + public AvajeJsonRpcReader(Object params) { + if (params instanceof List) { + this.list = (List) params; + this.map = null; + } else if (params instanceof Map) { + this.map = (Map) params; + this.list = null; + } else { + this.map = null; + this.list = null; + } + } + + private Object peek(String name) { + if (list != null && index < list.size()) { + return list.get(index); + } else if (map != null) { + return map.get(name); + } + return null; + } + + private Object consume(String name) { + if (list != null && index < list.size()) { + return list.get(index++); + } else if (map != null) { + return map.get(name); + } + return null; + } + + private Object require(String name) { + Object value = consume(name); + if (value == null) { + throw new MissingValueException(name); + } + return value; + } + + @Override + public boolean nextIsNull(String name) { + return peek(name) == null; + } + + @Override + public int nextInt(String name) { + Object val = require(name); + if (val instanceof Number n) return n.intValue(); + throw new TypeMismatchException(name, int.class); + } + + @Override + public long nextLong(String name) { + Object val = require(name); + if (val instanceof Number n) return n.longValue(); + throw new TypeMismatchException(name, long.class); + } + + @Override + public boolean nextBoolean(String name) { + Object val = require(name); + if (val instanceof Boolean b) return b; + throw new TypeMismatchException(name, boolean.class); + } + + @Override + public double nextDouble(String name) { + Object val = require(name); + if (val instanceof Number n) return n.doubleValue(); + throw new TypeMismatchException(name, double.class); + } + + @Override + public String nextString(String name) { + return require(name).toString(); + } + + @Override + public T nextObject(String name, JsonRpcDecoder decoder) { + var val = require(name); + return decoder.decode(name, val); + } + + @Override + public void close() {} +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcRequestAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcRequestAdapter.java new file mode 100644 index 0000000000..3314f37783 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcRequestAdapter.java @@ -0,0 +1,101 @@ +/* + * 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.List; +import java.util.Map; + +import io.avaje.json.JsonAdapter; +import io.avaje.json.JsonReader; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; +import io.jooby.jsonrpc.JsonRpcRequest; + +public class AvajeJsonRpcRequestAdapter implements JsonAdapter { + + private final JsonType anyType; + + public AvajeJsonRpcRequestAdapter(Jsonb jsonb) { + // The Object.class adapter parses JSON arrays to Lists and objects to Maps + this.anyType = jsonb.type(Object.class); + } + + @Override + public JsonRpcRequest fromJson(JsonReader reader) { + Object payload = anyType.fromJson(reader); + + if (payload instanceof List list) { + // Spec: Empty array must return a single Invalid Request object (-32600) + if (list.isEmpty()) { + JsonRpcRequest invalid = new JsonRpcRequest(); + invalid.setMethod(null); + invalid.setBatch(false); + return invalid; + } + + JsonRpcRequest batch = new JsonRpcRequest(); + for (Object element : list) { + batch.add(parseSingle(element)); + } + return batch; + } else { + return parseSingle(payload); + } + } + + private JsonRpcRequest parseSingle(Object node) { + JsonRpcRequest req = new JsonRpcRequest(); + + if (!(node instanceof Map map)) { + req.setMethod(null); // Triggers -32600 Invalid Request + return req; + } + + // 1. Extract ID + Object idVal = map.get("id"); + if (idVal != null) { + if (idVal instanceof Number n) { + req.setId(n); + } else if (idVal instanceof String s) { + req.setId(s); + } + } + + // 2. Validate JSON-RPC version + Object versionVal = map.get("jsonrpc"); + if (!"2.0".equals(versionVal)) { + req.setMethod(null); + return req; + } + + // 3. Extract Method + Object methodVal = map.get("method"); + if (methodVal instanceof String s) { + req.setMethod(s); + } else { + req.setMethod(null); + } + + // 4. Extract Params (Must be Map or List per spec) + Object paramsVal = map.get("params"); + if (paramsVal != null) { + if (paramsVal instanceof Map || paramsVal instanceof List) { + req.setParams(paramsVal); + } else { + req.setMethod(null); // Primitive params -> -32600 + } + } + + return req; + } + + @Override + public void toJson(JsonWriter writer, JsonRpcRequest value) { + // We only deserialize inbound requests, servers don't emit them + throw new UnsupportedOperationException("Serialization of JsonRpcRequest is not required"); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcResponseAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcResponseAdapter.java new file mode 100644 index 0000000000..cd77c2e7c2 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeJsonRpcResponseAdapter.java @@ -0,0 +1,57 @@ +/* + * 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.JsonAdapter; +import io.avaje.json.JsonReader; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.Jsonb; +import io.jooby.jsonrpc.JsonRpcResponse; + +public class AvajeJsonRpcResponseAdapter implements JsonAdapter { + + private final Jsonb jsonb; + + public AvajeJsonRpcResponseAdapter(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public void toJson(JsonWriter writer, JsonRpcResponse response) { + writer.beginObject(); + + writer.name("jsonrpc"); + writer.value("2.0"); + + if (response.getError() != null) { + writer.name("error"); + writePOJO(writer, response.getError()); + } else { + writer.name("result"); + if (response.getResult() == null) { + writer.nullValue(); + } else { + writePOJO(writer, response.getResult()); + } + } + + writer.name("id"); + var id = response.getId(); + writer.jsonValue(id); + + writer.endObject(); + } + + private void writePOJO(JsonWriter writer, Object item) { + JsonAdapter itemAdapter = jsonb.adapter(Object.class); + itemAdapter.toJson(writer, item); + } + + @Override + public JsonRpcResponse fromJson(JsonReader reader) { + throw new UnsupportedOperationException("Servers don't deserialize responses"); + } +} 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 index 5734a537ff..761162f5ac 100644 --- 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 @@ -5,8 +5,6 @@ */ 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; @@ -31,33 +29,7 @@ public void toJson(JsonWriter writer, TrpcResponse envelope) { 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); - } + writePOJO(writer, data); } writer.endObject(); @@ -65,9 +37,7 @@ public void toJson(JsonWriter writer, TrpcResponse envelope) { } 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()); + JsonAdapter itemAdapter = jsonb.adapter(Object.class); itemAdapter.toJson(writer, item); } diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcDecoder.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcDecoder.java new file mode 100644 index 0000000000..62ec624232 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcDecoder.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.lang.reflect.Type; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; + +public class JacksonJsonRpcDecoder implements JsonRpcDecoder { + + private final ObjectMapper mapper; + private final JavaType javaType; + + public JacksonJsonRpcDecoder(ObjectMapper mapper, Type type) { + this.mapper = mapper; + this.javaType = mapper.constructType(type); + } + + @Override + public T decode(String name, Object node) { + try { + if (node == null || ((JsonNode) node).isNull() || ((JsonNode) node).isMissingNode()) { + throw new MissingValueException(name); + } + return mapper.treeToValue((JsonNode) node, javaType); + } catch (Exception x) { + throw new TypeMismatchException(name, javaType, x); + } + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcParser.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcParser.java new file mode 100644 index 0000000000..f48727cd6e --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcParser.java @@ -0,0 +1,35 @@ +/* + * 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.lang.reflect.Type; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcReader; + +public class JacksonJsonRpcParser implements JsonRpcParser { + + private final ObjectMapper mapper; + + public JacksonJsonRpcParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public JsonRpcDecoder decoder(Type type) { + return new JacksonJsonRpcDecoder<>(mapper, type); + } + + @Override + public JsonRpcReader reader(Object params) { + // The JsonRpcRequestDeserializer stores the params field as a JsonNode + JsonNode node = (JsonNode) params; + return new JacksonJsonRpcReader(node); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcReader.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcReader.java new file mode 100644 index 0000000000..9d8f4a16c1 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcReader.java @@ -0,0 +1,116 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import com.fasterxml.jackson.databind.JsonNode; +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcReader; + +public class JacksonJsonRpcReader implements JsonRpcReader { + + private final JsonNode params; + private final boolean isArray; + private int index = 0; + + public JacksonJsonRpcReader(JsonNode params) { + this.params = params; + this.isArray = params != null && params.isArray(); + } + + private JsonNode peekNode(String name) { + if (params == null) { + return null; + } + if (isArray) { + return params.get(index); + } else if (params.isObject()) { + return params.get(name); + } + return null; + } + + private JsonNode consumeNode(String name) { + if (params == null) { + return null; + } + if (isArray) { + return params.get(index++); + } else if (params.isObject()) { + return params.get(name); + } + return null; + } + + private JsonNode requireNode(String name) { + JsonNode node = consumeNode(name); + if (node == null || node.isNull() || node.isMissingNode()) { + throw new MissingValueException(name); + } + return node; + } + + @Override + public boolean nextIsNull(String name) { + JsonNode node = peekNode(name); + return node == null || node.isNull(); + } + + @Override + public int nextInt(String name) { + var node = requireNode(name); + if (node.isNumber()) { + return node.intValue(); + } + throw new TypeMismatchException(name, int.class); + } + + @Override + public long nextLong(String name) { + var node = requireNode(name); + if (node.isNumber()) { + return node.longValue(); + } + throw new TypeMismatchException(name, long.class); + } + + @Override + public boolean nextBoolean(String name) { + var node = requireNode(name); + if (node.isBoolean()) { + return node.booleanValue(); + } + throw new TypeMismatchException(name, boolean.class); + } + + @Override + public double nextDouble(String name) { + var node = requireNode(name); + if (node.isNumber()) { + return node.doubleValue(); + } + throw new TypeMismatchException(name, double.class); + } + + @Override + public String nextString(String name) { + var node = requireNode(name); + if (node.isTextual()) { + return node.textValue(); + } + throw new TypeMismatchException(name, String.class); + } + + @Override + public T nextObject(String name, JsonRpcDecoder decoder) { + var node = requireNode(name); + return decoder.decode(name, node); + } + + @Override + public void close() {} +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcRequestDeserializer.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcRequestDeserializer.java new file mode 100644 index 0000000000..fe3787f592 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcRequestDeserializer.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.internal.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.node.ArrayNode; +import io.jooby.jsonrpc.JsonRpcRequest; + +public class JacksonJsonRpcRequestDeserializer extends StdDeserializer { + + public JacksonJsonRpcRequestDeserializer() { + super(JsonRpcRequest.class); + } + + @Override + public JsonRpcRequest deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // Jackson 2 standard way to read the tree + JsonNode node = p.getCodec().readTree(p); + + if (node.isArray()) { + var arrayNode = (ArrayNode) node; + + // Spec: Empty array must return a single Invalid Request object (-32600) + if (arrayNode.isEmpty()) { + JsonRpcRequest invalid = new JsonRpcRequest(); + invalid.setMethod(null); // Acts as a flag for Invalid Request + invalid.setBatch(false); // Force single return shape + return invalid; + } + + JsonRpcRequest batch = new JsonRpcRequest(); + for (JsonNode element : arrayNode) { + batch.add(parseSingle(element)); + } + return batch; + } else { + return parseSingle(node); + } + } + + private JsonRpcRequest parseSingle(JsonNode node) { + var req = new JsonRpcRequest(); + + if (!node.isObject()) { + req.setMethod(null); + return req; + } + + // 1. Extract ID if present (crucial for error echoing) + JsonNode idNode = node.get("id"); + if (idNode != null && !idNode.isNull()) { + if (idNode.isNumber()) { + req.setId(idNode.numberValue()); + } else if (idNode.isTextual()) { // Jackson 2 uses isTextual() + req.setId(idNode.asText()); // Jackson 2 uses asText() instead of asString() + } + } + + // 2. Validate JSON-RPC version + JsonNode versionNode = node.get("jsonrpc"); + if (versionNode == null || !versionNode.isTextual() || !"2.0".equals(versionNode.asText())) { + req.setMethod(null); // Triggers -32600 Invalid Request + return req; + } + + // 3. Extract Method + JsonNode methodNode = node.get("method"); + if (methodNode != null && methodNode.isTextual()) { + req.setMethod(methodNode.asText()); + } else { + req.setMethod(null); // Triggers -32600 Invalid Request + } + + // 4. Extract Params (Must be an Array or an Object per spec) + JsonNode paramsNode = node.get("params"); + if (paramsNode != null && !paramsNode.isNull()) { + if (paramsNode.isArray() || paramsNode.isObject()) { + req.setParams(paramsNode); // Keep as JsonNode for the Reader later + } else { + req.setMethod(null); // Primitive params are invalid -> -32600 + } + } + + return req; + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcResponseSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcResponseSerializer.java new file mode 100644 index 0000000000..71c04af9ae --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonJsonRpcResponseSerializer.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.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.jsonrpc.JsonRpcResponse; + +public class JacksonJsonRpcResponseSerializer extends StdSerializer { + + public JacksonJsonRpcResponseSerializer() { + super(JsonRpcResponse.class); + } + + @Override + public void serialize(JsonRpcResponse response, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeStartObject(); + + gen.writeStringField("jsonrpc", "2.0"); + + if (response.getError() != null) { + gen.writeObjectField("error", response.getError()); + } else { + gen.writeObjectField("result", response.getResult()); + } + + if (response.getId() == null) { + gen.writeNullField("id"); + } else { + gen.writeObjectField("id", response.getId()); + } + + gen.writeEndObject(); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectedSerializer.java similarity index 95% rename from modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java rename to modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectedSerializer.java index 13fce9d27a..ba3158877c 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectedSerializer.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectedSerializer.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.jackson; +package io.jooby.internal.jackson; import java.io.IOException; import java.util.Map; @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; import io.jooby.Projected; import io.jooby.Projection; +import io.jooby.jackson.JacksonModule; public class JacksonProjectedSerializer extends JsonSerializer { private final Map, ObjectWriter> writerCache = new ConcurrentHashMap<>(); diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectionFilter.java similarity index 99% rename from modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java rename to modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectionFilter.java index f6650e4e40..4fe6d8faa9 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonProjectionFilter.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonProjectionFilter.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.jackson; +package io.jooby.internal.jackson; import java.util.ArrayDeque; import java.util.Deque; 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 30ebbd86b2..a57ae7060f 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 @@ -5,8 +5,6 @@ */ package io.jooby.jackson; -import static com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter.*; - import java.io.InputStream; import java.lang.reflect.Type; import java.util.HashMap; @@ -30,8 +28,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.internal.jackson.*; +import io.jooby.jsonrpc.JsonRpcErrorCode; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcRequest; +import io.jooby.jsonrpc.JsonRpcResponse; import io.jooby.output.Output; import io.jooby.trpc.TrpcParser; import io.jooby.trpc.TrpcResponse; @@ -157,6 +158,13 @@ public void install(@NonNull Jooby application) { // tRPC services.put(TrpcParser.class, new JacksonTrpcParser(mapper)); + // JSON-RPC + services.put(JsonRpcParser.class, new JacksonJsonRpcParser(mapper)); + services + .mapOf(Class.class, JsonRpcErrorCode.class) + .put(MismatchedInputException.class, JsonRpcErrorCode.INVALID_PARAMS) + .put(DatabindException.class, JsonRpcErrorCode.INVALID_PARAMS); + // Filter var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false); mapper.addMixIn(Object.class, ProjectionMixIn.class); @@ -220,7 +228,7 @@ public Object decode(Context ctx, Type type) throws Exception { * @param modules Extra/additional modules to install. * @return Object mapper instance. */ - public static @NonNull ObjectMapper create(Module... modules) { + public static ObjectMapper create(Module... modules) { JsonMapper.Builder builder = JsonMapper.builder() .addModule(new ParameterNamesModule()) @@ -228,10 +236,12 @@ 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); + // RPC + var rpc = new SimpleModule(); + rpc.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer()); + rpc.addDeserializer(JsonRpcRequest.class, new JacksonJsonRpcRequestDeserializer()); + rpc.addSerializer(JsonRpcResponse.class, new JacksonJsonRpcResponseSerializer()); + builder.addModule(rpc); return builder.build(); } diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcDecoder.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcDecoder.java new file mode 100644 index 0000000000..edfc958a30 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcDecoder.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.jackson3; + +import java.lang.reflect.Type; + +import io.jooby.exception.MissingValueException; +import io.jooby.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +public class JacksonJsonRpcDecoder implements JsonRpcDecoder { + + private final ObjectMapper mapper; + private final JavaType javaType; + + public JacksonJsonRpcDecoder(ObjectMapper mapper, Type type) { + this.mapper = mapper; + this.javaType = mapper.constructType(type); + } + + @Override + public T decode(String name, Object node) { + try { + if (node == null || ((JsonNode) node).isNull() || ((JsonNode) node).isMissingNode()) { + throw new MissingValueException(name); + } + return mapper.treeToValue((JsonNode) node, javaType); + } catch (Exception x) { + throw new TypeMismatchException(name, javaType, x); + } + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcParser.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcParser.java new file mode 100644 index 0000000000..fc1ef15bd9 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcParser.java @@ -0,0 +1,35 @@ +/* + * 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.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcReader; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +public class JacksonJsonRpcParser implements JsonRpcParser { + + private final ObjectMapper mapper; + + public JacksonJsonRpcParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public JsonRpcDecoder decoder(Type type) { + return new JacksonJsonRpcDecoder<>(mapper, type); + } + + @Override + public JsonRpcReader reader(Object params) { + // The JsonRpcRequestDeserializer stores the params field as a JsonNode + JsonNode node = (JsonNode) params; + return new JacksonJsonRpcReader(node); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcReader.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcReader.java new file mode 100644 index 0000000000..32fcd6270b --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcReader.java @@ -0,0 +1,116 @@ +/* + * 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.exception.TypeMismatchException; +import io.jooby.jsonrpc.JsonRpcDecoder; +import io.jooby.jsonrpc.JsonRpcReader; +import tools.jackson.databind.JsonNode; + +public class JacksonJsonRpcReader implements JsonRpcReader { + + private final JsonNode params; + private final boolean isArray; + private int index = 0; + + public JacksonJsonRpcReader(JsonNode params) { + this.params = params; + this.isArray = params != null && params.isArray(); + } + + private JsonNode peekNode(String name) { + if (params == null) { + return null; + } + if (isArray) { + return params.get(index); + } else if (params.isObject()) { + return params.get(name); + } + return null; + } + + private JsonNode consumeNode(String name) { + if (params == null) { + return null; + } + if (isArray) { + return params.get(index++); + } else if (params.isObject()) { + return params.get(name); + } + return null; + } + + private JsonNode requireNode(String name) { + JsonNode node = consumeNode(name); + if (node == null || node.isNull() || node.isMissingNode()) { + throw new MissingValueException(name); + } + return node; + } + + @Override + public boolean nextIsNull(String name) { + JsonNode node = peekNode(name); + return node == null || node.isNull(); + } + + @Override + public int nextInt(String name) { + var node = requireNode(name); + if (node.isInt()) { + return node.asInt(); + } + throw new TypeMismatchException(name, int.class); + } + + @Override + public long nextLong(String name) { + var node = requireNode(name); + if (node.isLong()) { + return node.longValue(); + } + throw new TypeMismatchException(name, long.class); + } + + @Override + public boolean nextBoolean(String name) { + var node = requireNode(name); + if (node.isBoolean()) { + return node.booleanValue(); + } + throw new TypeMismatchException(name, boolean.class); + } + + @Override + public double nextDouble(String name) { + var node = requireNode(name); + if (node.isDouble()) { + return node.asDouble(); + } + throw new TypeMismatchException(name, double.class); + } + + @Override + public String nextString(String name) { + var node = requireNode(name); + if (node.isString()) { + return node.asString(); + } + throw new TypeMismatchException(name, String.class); + } + + @Override + public T nextObject(String name, JsonRpcDecoder decoder) { + var node = requireNode(name); + return decoder.decode(name, node); + } + + @Override + public void close() {} +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcRequestDeserializer.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcRequestDeserializer.java new file mode 100644 index 0000000000..2d1252dbf0 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcRequestDeserializer.java @@ -0,0 +1,91 @@ +/* + * 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.jsonrpc.JsonRpcRequest; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.node.ArrayNode; + +public class JacksonJsonRpcRequestDeserializer extends StdDeserializer { + + public JacksonJsonRpcRequestDeserializer() { + super(JsonRpcRequest.class); + } + + @Override + public JsonRpcRequest deserialize(JsonParser p, DeserializationContext ctxt) { + JsonNode node = p.readValueAsTree(); + + if (node.isArray()) { + var arrayNode = (ArrayNode) node; + + // Spec: Empty array must return a single Invalid Request object (-32600) + if (arrayNode.isEmpty()) { + JsonRpcRequest invalid = new JsonRpcRequest(); + invalid.setMethod(null); // Acts as a flag for Invalid Request + invalid.setBatch(false); // Force single return shape + return invalid; + } + + JsonRpcRequest batch = new JsonRpcRequest(); + for (JsonNode element : arrayNode) { + batch.add(parseSingle(element)); + } + return batch; + } else { + return parseSingle(node); + } + } + + private JsonRpcRequest parseSingle(JsonNode node) { + var req = new JsonRpcRequest(); + + if (!node.isObject()) { + req.setMethod(null); + return req; + } + + // 1. Extract ID if present (crucial for error echoing) + JsonNode idNode = node.get("id"); + if (idNode != null && !idNode.isNull()) { + if (idNode.isNumber()) { + req.setId(idNode.numberValue()); + } else if (idNode.isString()) { + req.setId(idNode.asString()); + } + } + + // 2. Validate JSON-RPC version + JsonNode versionNode = node.get("jsonrpc"); + if (versionNode == null || !versionNode.isString() || !"2.0".equals(versionNode.asString())) { + req.setMethod(null); // Triggers -32600 Invalid Request + return req; + } + + // 3. Extract Method + JsonNode methodNode = node.get("method"); + if (methodNode != null && methodNode.isString()) { + req.setMethod(methodNode.asString()); + } else { + req.setMethod(null); // Triggers -32600 Invalid Request + } + + // 4. Extract Params (Must be an Array or an Object per spec) + JsonNode paramsNode = node.get("params"); + if (paramsNode != null && !paramsNode.isNull()) { + if (paramsNode.isArray() || paramsNode.isObject()) { + req.setParams(paramsNode); // Keep as JsonNode for the Reader later + } else { + req.setMethod(null); // Primitive params are invalid -> -32600 + } + } + + return req; + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcResponseSerializer.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcResponseSerializer.java new file mode 100644 index 0000000000..74a5ca9f75 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonJsonRpcResponseSerializer.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.internal.jackson3; + +import io.jooby.jsonrpc.JsonRpcResponse; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ser.std.StdSerializer; + +public class JacksonJsonRpcResponseSerializer extends StdSerializer { + + public JacksonJsonRpcResponseSerializer() { + super(JsonRpcResponse.class); + } + + @Override + public void serialize(JsonRpcResponse response, JsonGenerator gen, SerializationContext ctxt) + throws JacksonException { + gen.writeStartObject(); + gen.writeName("jsonrpc"); + gen.writeString("2.0"); + + if (response.getError() != null) { + gen.writeName("error"); + gen.writePOJO(response.getError()); + } else { + gen.writeName("result"); + gen.writePOJO(response.getResult()); + } + if (response.getId() == null) { + gen.writeNullProperty("id"); + } else { + gen.writePOJOProperty("id", response.getId()); + } + + gen.writeEndObject(); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonProjectionFilter.java similarity index 98% rename from modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java rename to modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonProjectionFilter.java index ce74da8e85..d3fcd5df03 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/JacksonProjectionFilter.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonProjectionFilter.java @@ -3,7 +3,7 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.jackson3; +package io.jooby.internal.jackson3; import java.util.ArrayList; import java.util.Collections; 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 b7149a65da..80497162d3 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,8 +17,11 @@ 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.internal.jackson3.*; +import io.jooby.jsonrpc.JsonRpcErrorCode; +import io.jooby.jsonrpc.JsonRpcParser; +import io.jooby.jsonrpc.JsonRpcRequest; +import io.jooby.jsonrpc.JsonRpcResponse; import io.jooby.output.Output; import io.jooby.trpc.TrpcErrorCode; import io.jooby.trpc.TrpcParser; @@ -147,13 +150,23 @@ public void install(@NonNull Jooby application) { 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); + .put(MismatchedInputException.class, TrpcErrorCode.BAD_REQUEST) + .put(DatabindException.class, TrpcErrorCode.BAD_REQUEST); + + // JSON-RPC + services.put(JsonRpcParser.class, new JacksonJsonRpcParser(mapper)); + services + .mapOf(Class.class, JsonRpcErrorCode.class) + .put(StreamReadException.class, JsonRpcErrorCode.INVALID_PARAMS) + .put(MismatchedInputException.class, JsonRpcErrorCode.INVALID_PARAMS) + .put(DatabindException.class, JsonRpcErrorCode.INVALID_PARAMS); + + // Parsing exception as 400 + application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST); + application.errorCode(DatabindException.class, StatusCode.BAD_REQUEST); application.onStarting(() -> onStarting(application, services, mapperType)); @@ -227,9 +240,11 @@ 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); + var rpcModule = new SimpleModule(); + rpcModule.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer()); + rpcModule.addSerializer(JsonRpcResponse.class, new JacksonJsonRpcResponseSerializer()); + rpcModule.addDeserializer(JsonRpcRequest.class, new JacksonJsonRpcRequestDeserializer()); + builder.addModule(rpcModule); return builder.build(); } diff --git a/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java new file mode 100644 index 0000000000..d36e8d324b --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/AbstractTrpcProtocolTest.java @@ -0,0 +1,246 @@ +/* + * 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.Jooby; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; +import io.jooby.trpc.TrpcModule; + +public abstract class AbstractTrpcProtocolTest { + + /** + * Subclasses must provide the specific JSON engine module (e.g., JacksonModule, + * AvajeJsonbModule). + */ + protected abstract void installJsonEngine(Jooby app); + + // Helper to keep test setup DRY + private void setupApp(Jooby app) { + installJsonEngine(app); + app.install(new TrpcModule()); + app.mvc(new MovieService_()); + } + + @ServerTest + void shouldHandleBasicAndMultiArgumentCalls(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 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); + }); + }); + } + + @ServerTest + void shouldHandleSeamlessVsTupleWrappers(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 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) + 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"); + }); + }); + } + + @ServerTest + void shouldHandleReactiveAndVoidTypes(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 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\""); + }); + }); + } + + @ServerTest + void shouldHandleErrorsAndEdgeCases(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 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"); + }); + }); + } + + @ServerTest + void shouldHandleNullabilityValidation(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 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(); + 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 Movie + 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/i3863/AvajeTrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/AvajeTrpcProtocolTest.java new file mode 100644 index 0000000000..5fe78d9de0 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/AvajeTrpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3863; + +import io.jooby.Jooby; +import io.jooby.avaje.jsonb.AvajeJsonbModule; + +public class AvajeTrpcProtocolTest extends AbstractTrpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new AvajeJsonbModule()); + } +} diff --git a/tests/src/test/java/io/jooby/i3863/Jackson2TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/Jackson2TrpcProtocolTest.java new file mode 100644 index 0000000000..8e90d41c14 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/Jackson2TrpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3863; + +import io.jooby.Jooby; +import io.jooby.jackson.JacksonModule; + +public class Jackson2TrpcProtocolTest extends AbstractTrpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new JacksonModule()); + } +} diff --git a/tests/src/test/java/io/jooby/i3863/Jackson3TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/Jackson3TrpcProtocolTest.java new file mode 100644 index 0000000000..f5dad5f885 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/Jackson3TrpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3863; + +import io.jooby.Jooby; +import io.jooby.jackson3.Jackson3Module; + +public class Jackson3TrpcProtocolTest extends AbstractTrpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new Jackson3Module()); + } +} diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java deleted file mode 100644 index 3ee57fddbc..0000000000 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * 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/i3868/AbstractJsonRpcProtocolTest.java b/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java new file mode 100644 index 0000000000..af9edcffc3 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3868/AbstractJsonRpcProtocolTest.java @@ -0,0 +1,400 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3868; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import com.jayway.jsonpath.JsonPath; +import io.jooby.Jooby; +import io.jooby.junit.ServerTest; +import io.jooby.junit.ServerTestRunner; + +public abstract class AbstractJsonRpcProtocolTest { + + /** + * Subclasses must provide the specific JSON engine module (e.g., JacksonModule, Jackson3Module). + */ + protected abstract void installJsonEngine(Jooby app); + + // Helper to keep test setup DRY + private void setupApp(Jooby app) { + installJsonEngine(app); + app.mvc(new MovieService_()); + app.mvc(new MovieServiceRpc_()); + } + + @ServerTest + void shouldHandleSingleRequests(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // GetById (Positional Arguments Array) + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.getById", + "params": [1], + "id": 1 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + // Spec: Success must have 'result' and must NOT have 'error' + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("result").doesNotContainKey("error"); + + assertThat(JsonPath.read(json, "$.id")).isEqualTo(1); + assertThat(JsonPath.read(json, "$.result.title")) + .isEqualTo("The Godfather"); + }); + + // Search (Named Arguments Object) + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.search", + "params": {"title": "Pulp Fiction", "year": 1994}, + "id": 2 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("result").doesNotContainKey("error"); + + assertThat(JsonPath.read(json, "$.id")).isEqualTo(2); + assertThat(JsonPath.read(json, "$.result[0].id")).isEqualTo(2); + assertThat(JsonPath.read(json, "$.result[0].title")) + .isEqualTo("Pulp Fiction"); + }); + + // Create (Complex Object passed by position) + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.create", + "params": [{"id": 3, "title": "Goodfellas", "year": 1990}], + "id": 3 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("result").doesNotContainKey("error"); + + assertThat(JsonPath.read(json, "$.result.title")) + .isEqualTo("Goodfellas"); + }); + }); + } + + @ServerTest + void shouldHandleNotifications(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.deleteMovie", + "params": [1] + } + """, + rsp -> { + assertThat(rsp.code()).isEqualTo(204); // No Content + assertThat(rsp.body().string()).isEmpty(); + }); + }); + } + + @ServerTest + void shouldHandleErrors(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // Method Not Found (-32601) + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.unknownMethod", + "params": [], + "id": 4 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + // Spec: Error must have 'error' and must NOT have 'result' + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32601); + assertThat(JsonPath.read(json, "$.error.message")) + .contains("Method not found"); + }); + + // Invalid Request (-32600) + http.postJson( + "/rpc", + """ + { + "method": "movies.getById", + "params": [1], + "id": 5 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32600); + }); + + // Parse Error (-32700) + http.postJson( + "/rpc", + "[{ malformed json... ", + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32700); + }); + + // Application Exception Bubbling (-32004) + http.postJson( + "/rpc", + """ + { + "jsonrpc": "2.0", + "method": "movies.getById", + "params": [99], + "id": 6 + } + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.message")) + .contains("Not found"); + assertThat(JsonPath.read(json, "$.error.data")) + .contains("Movie not found: 99"); + }); + }); + } + + @ServerTest + void shouldHandleInvalidParams(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // 1. Missing Argument (Named) + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.search", "params": {"title": "Pulp Fiction"}, "id": 10} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + + // 2. Missing Argument (Positional) + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.search", "params": ["Pulp Fiction"], "id": 11} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + + // 3. Type Mismatch + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.search", "params": {"title": "Pulp Fiction", "year": "ninety-four"}, "id": 12} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + + // 4. Empty Params (Array) + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.getById", "params": [], "id": 13} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + + // 5. Omitted Params Object entirely + http.postJson( + "/rpc", + """ + {"jsonrpc": "2.0", "method": "movies.getById", "id": 14} + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32602); + }); + }); + } + + @ServerTest + void shouldHandleBatchProcessing(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + http.postJson( + "/rpc", + """ + [ + {"jsonrpc": "2.0", "method": "movies.getById", "params": [1], "id": "req-1"}, + {"jsonrpc": "2.0", "method": "movies.getById", "params": [2], "id": "req-2"}, + {"jsonrpc": "2.0", "method": "movies.deleteMovie", "params": [3]} + ] + """, + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + assertThat(json).startsWith("["); + + // Assert length is exactly 2 (notification should be dropped) + assertThat(JsonPath.read(json, "$.length()")).isEqualTo(2); + + // Assert mutual exclusivity on the individual batch elements + Map element0 = JsonPath.read(json, "$[0]"); + Map element1 = JsonPath.read(json, "$[1]"); + + // Both should be successes + assertThat(element0).containsKey("result").doesNotContainKey("error"); + assertThat(element1).containsKey("result").doesNotContainKey("error"); + + // Order-agnostic assertions using JsonPath filters! + List req1Title = + JsonPath.read(json, "$[?(@.id == 'req-1')].result.title"); + assertThat(req1Title).containsExactly("The Godfather"); + + List req2Id = JsonPath.read(json, "$[?(@.id == 'req-2')].id"); + assertThat(req2Id).containsExactly("req-2"); + + // Notification (deleteMovie) should leave no trace in the response + assertThat(json).doesNotContain("deleteMovie"); + }); + }); + } + + @ServerTest + void shouldHandleBatchEdgeCases(ServerTestRunner runner) { + runner + .define(this::setupApp) + .ready( + http -> { + // Edge Case 1: Empty Array + http.postJson( + "/rpc", + "[]", + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + assertThat(json).doesNotStartWith("["); // MUST be a single object + + Map root = JsonPath.read(json, "$"); + assertThat(root).containsKey("error").doesNotContainKey("result"); + + assertThat(JsonPath.read(json, "$.error.code")).isEqualTo(-32600); + assertThat(JsonPath.read(json, "$.error.message")) + .containsIgnoringCase("Invalid Request"); + }); + + // Edge Case 2: Array containing completely invalid data + http.postJson( + "/rpc", + "[1, 2, 3]", + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(200); + assertThat(json).startsWith("["); + assertThat(JsonPath.read(json, "$.length()")).isEqualTo(3); + + // Check mutual exclusivity on all error elements in the batch + List> array = JsonPath.read(json, "$"); + for (Map element : array) { + assertThat(element).containsKey("error").doesNotContainKey("result"); + } + + // All three elements should be Invalid Request errors + List errorCodes = JsonPath.read(json, "$[*].error.code"); + assertThat(errorCodes).containsExactly(-32600, -32600, -32600); + }); + }); + } +} diff --git a/tests/src/test/java/io/jooby/i3868/AvajeJsonRpcProtocolTest.java b/tests/src/test/java/io/jooby/i3868/AvajeJsonRpcProtocolTest.java new file mode 100644 index 0000000000..bafad8410f --- /dev/null +++ b/tests/src/test/java/io/jooby/i3868/AvajeJsonRpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3868; + +import io.jooby.Jooby; +import io.jooby.avaje.jsonb.AvajeJsonbModule; + +public class AvajeJsonRpcProtocolTest extends AbstractJsonRpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new AvajeJsonbModule()); + } +} diff --git a/tests/src/test/java/io/jooby/i3868/Jackson2JsonRpcProtocolTest.java b/tests/src/test/java/io/jooby/i3868/Jackson2JsonRpcProtocolTest.java new file mode 100644 index 0000000000..0b8d6d6e9b --- /dev/null +++ b/tests/src/test/java/io/jooby/i3868/Jackson2JsonRpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3868; + +import io.jooby.Jooby; +import io.jooby.jackson.JacksonModule; + +public class Jackson2JsonRpcProtocolTest extends AbstractJsonRpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new JacksonModule()); + } +} diff --git a/tests/src/test/java/io/jooby/i3868/Jackson3JsonRpcProtocolTest.java b/tests/src/test/java/io/jooby/i3868/Jackson3JsonRpcProtocolTest.java new file mode 100644 index 0000000000..f2b766aa18 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3868/Jackson3JsonRpcProtocolTest.java @@ -0,0 +1,16 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3868; + +import io.jooby.Jooby; +import io.jooby.jackson3.Jackson3Module; + +public class Jackson3JsonRpcProtocolTest extends AbstractJsonRpcProtocolTest { + @Override + protected void installJsonEngine(Jooby app) { + app.install(new Jackson3Module()); + } +} diff --git a/tests/src/test/java/io/jooby/i3868/MovieService.java b/tests/src/test/java/io/jooby/i3868/MovieService.java new file mode 100644 index 0000000000..db61b6afa2 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3868/MovieService.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.i3868; + +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.annotation.*; +import io.jooby.exception.NotFoundException; +import io.jooby.i3863.Movie; + +@JsonRpc("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. */ + @POST + @Trpc + public Movie create(Movie movie) { + // In a real app, save to DB. For now, just return it. + return movie; + } + + public @NonNull Movie getById(int id) { + return database.stream() + .filter(m -> m.id() == id) + .findFirst() + .orElseThrow(() -> new NotFoundException("Movie not found: " + id)); + } + + public List search(String title, int year) { + return database.stream().filter(m -> m.title().contains(title) && (m.year() == year)).toList(); + } + + public void deleteMovie(@PathParam int id) {} +}