From a50c024ee6ee7a3efb1e65f1d9e786cfa8ce4c32 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 13:15:10 -0300 Subject: [PATCH 01/16] tRPC implementation - generate `d.ts` file from MVC routes - introduce `@Trpc`, `@Trpc.Query` and `@Trpc.Mutation` - implement `GET` on APT - ref #3863 --- .../main/java/io/jooby/annotation/Trpc.java | 62 ++ .../main/java/io/jooby/trpc/TrpcError.java | 12 + .../main/java/io/jooby/trpc/TrpcResponse.java | 13 + .../main/java/io/jooby/trpc/TrpcResult.java | 8 + modules/jooby-apt/pom.xml | 8 + .../java/io/jooby/apt/JoobyProcessor.java | 2 +- .../java/io/jooby/internal/apt/CodeBlock.java | 4 + .../io/jooby/internal/apt/HttpMethod.java | 17 +- .../java/io/jooby/internal/apt/HttpPath.java | 28 +- .../io/jooby/internal/apt/MvcContext.java | 20 + .../java/io/jooby/internal/apt/MvcRoute.java | 261 ++++++-- .../src/test/java/tests/i3863/C3863.java | 22 + .../src/test/java/tests/i3863/C3863_.java | 53 ++ .../src/test/java/tests/i3863/Issue3863.java | 21 + .../src/test/java/tests/i3863/U3863.java | 8 + .../java/io/jooby/jackson3/PrimitiveTest.java | 21 + .../io/jooby/openapi/OpenAPIGenerator.java | 2 +- modules/jooby-trcp/pom.xml | 137 ++++ .../java/io/jooby/trpc/TrpcGenerator.java | 594 ++++++++++++++++++ .../test/java/io/jooby/trpc/i3863/C3863.java | 33 + .../jooby/trpc/i3863/TrpcGeneratorTest.java | 68 ++ .../test/java/io/jooby/trpc/i3863/U3863.java | 8 + modules/pom.xml | 3 + 23 files changed, 1352 insertions(+), 53 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/annotation/Trpc.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcError.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcResponse.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcResult.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/C3863.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/C3863_.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java create mode 100644 modules/jooby-apt/src/test/java/tests/i3863/U3863.java create mode 100644 modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java create mode 100644 modules/jooby-trcp/pom.xml create mode 100644 modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java create mode 100644 modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java diff --git a/jooby/src/main/java/io/jooby/annotation/Trpc.java b/jooby/src/main/java/io/jooby/annotation/Trpc.java new file mode 100644 index 0000000000..d85b3cab25 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/Trpc.java @@ -0,0 +1,62 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Marks a controller class or a specific route method for tRPC TypeScript generation. */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Trpc { + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface Mutation { + /** + * Custom name for the tRPC procedure. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a + * class, this overrides the generated namespace/router name. + * + * @return The custom procedure name. Empty by default, which means the generator will use the + * Java method or class name. + */ + String value() default ""; + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface Query { + /** + * Custom name for the tRPC procedure. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a + * class, this overrides the generated namespace/router name. + * + * @return The custom procedure name. Empty by default, which means the generator will use the + * Java method or class name. + */ + String value() default ""; + } + + /** + * Custom name for the tRPC procedure or namespace. + * + *

If applied to a method, this overrides the generated procedure name. If applied to a class, + * this overrides the generated namespace/router name. + * + * @return The custom procedure or namespace name. Empty by default, which means the generator + * will use the Java method or class name. + */ + String value() default ""; +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcError.java b/jooby/src/main/java/io/jooby/trpc/TrpcError.java new file mode 100644 index 0000000000..a32dbbad92 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcError.java @@ -0,0 +1,12 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.util.Map; + +public record TrpcError(ErrorDetail error) { + public record ErrorDetail(String message, int code, Map data) {} +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java new file mode 100644 index 0000000000..2b16ea6af9 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +public record TrpcResponse(TrpcResult result) { + + public static TrpcResponse success(T data) { + return new TrpcResponse<>(new TrpcResult<>(data)); + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResult.java b/jooby/src/main/java/io/jooby/trpc/TrpcResult.java new file mode 100644 index 0000000000..70b3337ac3 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResult.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +public record TrpcResult(T data) {} diff --git a/modules/jooby-apt/pom.xml b/modules/jooby-apt/pom.xml index 454298979c..d3bf806dcd 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -25,6 +25,13 @@ test + + io.jooby + jooby-jackson3 + ${jooby.version} + test + + jakarta.validation jakarta.validation-api @@ -76,6 +83,7 @@ test + io.jooby jooby-test diff --git a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java index 5beba9abd5..e67006a707 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java +++ b/modules/jooby-apt/src/main/java/io/jooby/apt/JoobyProcessor.java @@ -297,7 +297,7 @@ private void buildRouteRegistry(Map registry, TypeElemen } }); if (!currentType.equals(superType)) { - // edge-case #1: when controller has no method and extends another class which has. + // edge-case #1: when a controller has no method and extends another class which has. // edge-case #2: some odd usage a controller could be empty. // See https://github.com/jooby-project/jooby/issues/3656 if (registry.containsKey(superType)) { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java index 86e2c43d85..3fa2a04857 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/CodeBlock.java @@ -35,6 +35,10 @@ public static CharSequence semicolon(boolean kt) { return kt ? "" : ";"; } + public static CharSequence var(boolean kt) { + return kt ? "val " : "var "; + } + public static String indent(int count) { return " ".repeat(count); } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 4481b567a3..9b4fb93bbd 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -27,7 +27,13 @@ public enum HttpMethod implements AnnotationSupport { OPTIONS, PATCH, POST, - PUT; + PUT, + // Special + tRPC( + List.of( + "io.jooby.annotation.Trpc", + "io.jooby.annotation.Trpc.Mutation", + "io.jooby.annotation.Trpc.Query")); private final List annotations; HttpMethod(String... packages) { @@ -36,6 +42,10 @@ public enum HttpMethod implements AnnotationSupport { this.annotations = packageList.stream().map(it -> it + "." + name()).toList(); } + HttpMethod(List annotations) { + this.annotations = annotations; + } + /** * Look at path attribute over HTTP method annotation (like io.jooby.annotation.GET) or fallback * to Path annotation. @@ -76,6 +86,11 @@ public List produces(Element element) { return mediaType(element, HttpMediaType.Produces, "produces"::equals); } + public boolean matches(Element element) { + return annotations.stream() + .anyMatch(it -> AnnotationSupport.findAnnotationByName(element, it) != null); + } + private List mediaType( Element element, HttpMediaType mediaType, Predicate filter) { var path = diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java index 60e285d9e7..696250aef2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java @@ -32,10 +32,24 @@ public List getAnnotations() { * @return Path or empty list. */ public List path(Collection hierarchy) { + return path(hierarchy, getAnnotations()); + } + + /** + * Find path on type hierarchy. It goes back at hierarchy until it finds a Path annotation. + * + * @param hierarchy Type hierarchy. + * @return Path or empty list. + */ + public List trpcPath(Collection hierarchy) { + return path(hierarchy, List.of("io.jooby.annotation.Trpc")); + } + + private List path(Collection hierarchy, List annotations) { var prefix = Collections.emptyList(); var it = hierarchy.iterator(); while (prefix.isEmpty() && it.hasNext()) { - prefix = path(it.next()); + prefix = path(it.next(), annotations); } return prefix; } @@ -47,7 +61,17 @@ public List path(Collection hierarchy) { * @return Path or empty list. */ public List path(Element element) { - return getAnnotations().stream() + return path(element, getAnnotations()); + } + + /** + * Find Path from method or class. + * + * @param element Method or Class. + * @return Path or empty list. + */ + private List path(Element element, List annotations) { + return annotations.stream() .map(it -> AnnotationSupport.findAnnotationByName(element, it)) .filter(Objects::nonNull) .findFirst() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java index 6a4a441a9c..99ab9443ab 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java @@ -87,6 +87,26 @@ public ProcessingEnvironment getProcessingEnvironment() { return processingEnvironment; } + /** + * Find path from trpc route method and router type. This method scan and expand path base on the + * annotation present at method or class level. + * + * @param owner Router type. + * @param exec Method. + * @param procedure Child path. + * @return List of possible paths. + */ + public List trpcPath(TypeElement owner, ExecutableElement exec, String procedure) { + var prefix = HttpPath.PATH.trpcPath(superTypes(owner)); + if (prefix.isEmpty()) { + return procedure.isEmpty() ? Collections.singletonList("/") : List.of(procedure); + } + return prefix.stream() + .map(root -> root.equals("/") ? procedure : root + procedure) + .distinct() + .toList(); + } + /** * Find path from route method and router type. This method scan and expand path base on the * annotation present at method or class level. diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index b50423de28..e75cf175ff 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.lineSeparator; +import static java.lang.System.*; import static java.util.Optional.ofNullable; import java.util.*; @@ -31,6 +31,7 @@ public class MvcRoute { private final boolean suspendFun; private boolean uncheckedCast; private final boolean hasBeanValidation; + private final List trpcMethods = new ArrayList<>(); public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { this.context = context; @@ -142,19 +143,36 @@ public List generateMapping(boolean kt) { var lastHttpMethod = lastRoute && entries.get(entries.size() - 1).equals(e); var annotation = e.getKey(); var httpMethod = HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString()); + var dslMethod = annotation.getSimpleName().toString().toLowerCase(); var paths = context.path(router.getTargetType(), method, annotation); + var targetMethod = methodName; + if (httpMethod == HttpMethod.tRPC) { + httpMethod = trpcMethod(method); + if (httpMethod == null) { + throw new IllegalArgumentException( + "tRPC method not found: " + + method.getSimpleName() + + "() in " + + router.getTargetType()); + } + dslMethod = httpMethod.name().toLowerCase(); + paths = List.of(trpcPath(method)); + targetMethod = + "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1); + trpcMethods.add(new TrpcMethod(httpMethod, targetMethod)); + } for (var path : paths) { var lastLine = lastHttpMethod && paths.get(paths.size() - 1).equals(path); block.add(javadocLink); block.add( statement( isSuspendFun() ? "" : "app.", - annotation.getSimpleName().toString().toLowerCase(), + dslMethod, "(", string(leadingSlash(path)), ", ", context.pipeline( - getReturnTypeHandler(), methodReference(kt, thisRef, methodName)))); + getReturnTypeHandler(), methodReference(kt, thisRef, targetMethod)))); if (context.nonBlocking(getReturnTypeHandler()) || isSuspendFun()) { block.add(statement(indent(2), ".setNonBlocking(true)")); } @@ -245,7 +263,6 @@ public List generateHandlerCall(boolean kt) { paramList.add(generatedParameter); } - var throwsException = !method.getThrownTypes().isEmpty(); var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); @@ -256,48 +273,8 @@ public List generateHandlerCall(boolean kt) { returnTypeString = Types.PROJECTED + "<" + returnType + ">"; } - boolean nullable = false; - if (kt) { - nullable = - method.getAnnotationMirrors().stream() - .map(AnnotationMirror::getAnnotationType) - .map(Objects::toString) - .anyMatch(NULLABLE); - if (throwsException) { - buffer.add(statement("@Throws(Exception::class)")); - } - if (isSuspendFun()) { - buffer.add( - statement( - "suspend ", - "fun ", - returnTypeGenerics, - getGeneratedName(), - "(handler: io.jooby.kt.HandlerContext): ", - returnTypeString, - " {")); - buffer.add(statement(indent(2), "val ctx = handler.ctx")); - } else { - buffer.add( - statement( - "fun ", - returnTypeGenerics, - getGeneratedName(), - "(ctx: io.jooby.Context): ", - returnTypeString, - " {")); - } - } else { - buffer.add( - statement( - "public ", - returnTypeGenerics, - returnTypeString, - " ", - getGeneratedName(), - "(io.jooby.Context ctx) ", - throwsException ? "throws Exception {" : "{")); - } + var nullable = + methodCallHeader(kt, getGeneratedName(), buffer, returnTypeGenerics, returnTypeString); if (returnType.isVoid()) { String statusCode; if (annotationMap.size() == 1) { @@ -381,11 +358,152 @@ public List generateHandlerCall(boolean kt) { buffer.add(statement("}", System.lineSeparator())); if (uncheckedCast) { if (kt) { - buffer.add(0, statement("@Suppress(\"UNCHECKED_CAST\")")); + buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + } else { + buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } + } + for (var trpcMethod : trpcMethods) { + buffer.addAll(generateTrpcMethod(kt, trpcMethod)); + } + return buffer; + } + + private boolean methodCallHeader( + boolean kt, + String methodName, + ArrayList buffer, + String returnTypeGenerics, + String returnTypeString) { + var throwsException = !method.getThrownTypes().isEmpty(); + var nullable = false; + if (kt) { + nullable = + method.getAnnotationMirrors().stream() + .map(AnnotationMirror::getAnnotationType) + .map(Objects::toString) + .anyMatch(NULLABLE); + if (throwsException) { + buffer.add(statement("@Throws(Exception::class)")); + } + if (isSuspendFun()) { + buffer.add( + statement( + "suspend ", + "fun ", + returnTypeGenerics, + methodName, + "(handler: io.jooby.kt.HandlerContext): ", + returnTypeString, + " {")); + buffer.add(statement(indent(2), "val ctx = handler.ctx")); } else { - buffer.add(0, statement("@SuppressWarnings(\"unchecked\")")); + buffer.add( + statement( + "fun ", + returnTypeGenerics, + methodName, + "(ctx: io.jooby.Context): ", + returnTypeString, + " {")); + } + } else { + buffer.add( + statement( + "public ", + returnTypeGenerics, + returnTypeString, + " ", + methodName, + "(io.jooby.Context ctx) ", + throwsException ? "throws Exception {" : "{")); + } + return nullable; + } + + private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { + var buffer = new ArrayList(); + + var returnTypeString = + "io.jooby.trpc.TrpcResponse<" + type(kt, getReturnType().toString()) + ">"; + + var nullable = methodCallHeader(kt, trpcMethod.name, buffer, "", returnTypeString); + if (trpcMethod.method == HttpMethod.GET) { + buffer.add( + statement( + indent(2), + var(kt), + "input = ctx.query(", + string("input"), + ").value()", + semicolon(kt))); + buffer.add( + statement( + indent(2), + var(kt), + "mapper = ctx.require(tools.jackson.databind.ObjectMapper", + clazz(kt), + ")", + semicolon(kt))); + List arguments = + switch (method.getParameters().size()) { + case 0 -> List.of(); + case 1 -> { + buffer.add( + statement( + indent(2), + var(kt), + method.getParameters().getFirst().getSimpleName(), + " = mapper.readValue(input, ", + method.getParameters().getFirst().asType().toString(), + clazz(kt), + ")", + semicolon(kt))); + yield List.of(method.getParameters().getFirst().getSimpleName().toString()); + } + default -> { + buffer.add( + statement(indent(2), var(kt), "array = mapper.readTree(input)", semicolon(kt))); + var args = new ArrayList(); + for (int i = 0; i < method.getParameters().size(); i++) { + buffer.add( + statement( + indent(2), + var(kt), + method.getParameters().getFirst().getSimpleName(), + Integer.toString(i), + " = mapper.readValue(array.get(", + Integer.toString(i), + "), ", + method.getParameters().getFirst().asType().toString(), + clazz(kt), + ")", + semicolon(kt))); + args.add(method.getParameters().getFirst().getSimpleName().toString()); + } + yield args; + } + }; + controllerVar(kt, buffer); + var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + arguments.stream().collect(Collectors.joining(", ", "(", ")"))); + if (!cast.isEmpty()) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } + buffer.add(statement(indent(2), var(kt), "result = ", call, semicolon(kt))); + buffer.add( + statement( + indent(2), "return ", "io.jooby.trpc.TrpcResponse.success(result)", semicolon(kt))); } + + buffer.add(statement("}", System.lineSeparator())); return buffer; } @@ -544,4 +662,51 @@ public void setUncheckedCast(boolean value) { public boolean hasBeanValidation() { return hasBeanValidation; } + + private HttpMethod trpcMethod(Element element) { + var trpc = AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc"); + if (trpc != null) { + if (HttpMethod.GET.matches(element)) { + return HttpMethod.GET; + } + if (HttpMethod.POST.matches(element)) { + return HttpMethod.POST; + } + return null; + } + if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Query") != null) { + return HttpMethod.GET; + } + if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation") + != null) { + return HttpMethod.POST; + } + return null; + } + + public String trpcPath(Element element) { + var namespace = + Optional.ofNullable( + AnnotationSupport.findAnnotationByName( + element.getEnclosingElement(), "io.jooby.annotation.Trpc")) + .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) + .map(it -> it + ".") + .orElse(""); + + var procedure = + Stream.of( + "io.jooby.annotation.Trpc.Query", + "io.jooby.annotation.Trpc.Mutation", + "io.jooby.annotation.Trpc") + .map(it -> AnnotationSupport.findAnnotationByName(element, it)) + .filter(Objects::nonNull) + .findFirst() + .flatMap(it -> findAnnotationValue(it, VALUE).stream().findFirst()) + .orElse(element.getSimpleName().toString()); + return Stream.of("trpc", namespace + procedure) + .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) + .collect(Collectors.joining("/", "/", "")); + } + + record TrpcMethod(HttpMethod method, String name) {} } diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java new file mode 100644 index 0000000000..9ac6d42c75 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java @@ -0,0 +1,22 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +import io.jooby.annotation.*; + +@Trpc("users") +public class C3863 { + + @Trpc.Query + public U3863 findUser(@PathParam long id) { + return null; + } + + // @Trpc.Mutation + // public U3863 updateUser(@PathParam String id, U3863 payload) { + // return null; + // } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java new file mode 100644 index 0000000000..f929114403 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java @@ -0,0 +1,53 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +@io.jooby.annotation.Generated(C3863.class) +public class C3863_ implements io.jooby.Extension { + protected java.util.function.Function factory; + + public C3863_() { + this(io.jooby.SneakyThrows.singleton(C3863::new)); + } + + public C3863_(C3863 instance) { + setup(ctx -> instance); + } + + public C3863_(io.jooby.SneakyThrows.Supplier provider) { + setup(ctx -> (C3863) provider.get()); + } + + public C3863_(io.jooby.SneakyThrows.Function, C3863> provider) { + setup(ctx -> provider.apply(C3863.class)); + } + + private void setup(java.util.function.Function factory) { + this.factory = factory; + } + + public void install(io.jooby.Jooby app) throws Exception { + /** See {@link C3863#findUser(long) */ + app.get("/trpc/users.findUser", this::trpcFindUser); + + /** See {@link C3863#findUser(long) */ + app.get("/users/{id}", this::findUser); + } + + public U3863 findUser(io.jooby.Context ctx) { + var c = this.factory.apply(ctx); + return c.findUser(ctx.path("id").longValue()); + } + + public io.jooby.trpc.TrpcResponse trpcFindUser(io.jooby.Context ctx) { + var input = ctx.query("input").value(); + var mapper = ctx.require(tools.jackson.databind.ObjectMapper.class); + var arg0 = mapper.readValue(input, long.class); + var c = this.factory.apply(ctx); + var result = c.findUser(arg0); + return io.jooby.trpc.TrpcResponse.success(result); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java new file mode 100644 index 0000000000..cc1e3036e0 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +import org.junit.jupiter.api.Test; + +import io.jooby.apt.ProcessorRunner; + +public class Issue3863 { + @Test + public void shouldGenerateTrpcHandler() throws Exception { + new ProcessorRunner(new C3863()) + .withSourceCode( + source -> { + System.out.println(source); + }); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/U3863.java b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java new file mode 100644 index 0000000000..d8a96d53d7 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/i3863/U3863.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.i3863; + +public record U3863(long id, String name) {} diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java new file mode 100644 index 0000000000..83ada6f24b --- /dev/null +++ b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java @@ -0,0 +1,21 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; + +public class PrimitiveTest { + + @Test + public void shouldParsePrimitive() { + var mapper = new ObjectMapper(); + assertEquals(1, mapper.readValue("1", long.class)); + } +} diff --git a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java index 8f9046e07f..5b65339e9c 100644 --- a/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java +++ b/modules/jooby-openapi/src/main/java/io/jooby/openapi/OpenAPIGenerator.java @@ -142,7 +142,7 @@ public List write( } } - private Logger log = LoggerFactory.getLogger(getClass()); + private final Logger log = LoggerFactory.getLogger(getClass()); private Set debug; diff --git a/modules/jooby-trcp/pom.xml b/modules/jooby-trcp/pom.xml new file mode 100644 index 0000000000..15f208983a --- /dev/null +++ b/modules/jooby-trcp/pom.xml @@ -0,0 +1,137 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.0.16-SNAPSHOT + + jooby-trcp + jooby-trcp + + + + io.jooby + jooby + ${jooby.version} + + + + + jakarta.ws.rs + jakarta.ws.rs-api + + + + io.github.classgraph + classgraph + 4.8.184 + + + + cz.habarta.typescript-generator + typescript-generator-core + 3.2.1263 + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + io.jooby + jooby-kotlin + ${jooby.version} + test + + + + org.jetbrains.kotlin + kotlin-stdlib + test + + + + org.jetbrains.kotlin + kotlin-reflect + test + + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + none + + + test-compile + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + none + + + + default-testCompile + none + + + java-test-compile + + testCompile + + test-compile + + + + + + diff --git a/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java new file mode 100644 index 0000000000..8120a22f66 --- /dev/null +++ b/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java @@ -0,0 +1,594 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import cz.habarta.typescript.generator.DateMapping; +import cz.habarta.typescript.generator.EnumMapping; +import cz.habarta.typescript.generator.Input; +import cz.habarta.typescript.generator.JsonLibrary; +import cz.habarta.typescript.generator.Output; +import cz.habarta.typescript.generator.Settings; +import cz.habarta.typescript.generator.TypeScriptFileType; +import cz.habarta.typescript.generator.TypeScriptGenerator; +import cz.habarta.typescript.generator.TypeScriptOutputKind; +import io.github.classgraph.ClassGraph; + +/** + * A generator that orchestrates {@code typescript-generator} to produce a tRPC-compatible + * TypeScript API definition from compiled Jooby controllers. + * + *

This tool bypasses the standard REST scanner of {@code typescript-generator}. Instead, it: + * + *

    + *
  1. Scans the compiled class directory (or explicitly added classes) for controllers marked + * with {@code @Trpc}. + *
  2. Extracts only the input and return types (DTOs) of the matching methods. + *
  3. Feeds those data models to the generator to produce clean TypeScript interfaces. + *
  4. Uses a fast, recursive type resolver to accurately map Java methods to tRPC {@code { input, + * output }} shapes. + *
  5. Appends a strict {@code AppRouter} definition to the generated file. + *
+ */ +public class TrpcGenerator { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private Path buildClassesDir; + private ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + private Path outputDir; + private String outputFile = "trpc.d.ts"; + private boolean expandLookup = false; + + private final Set> manualControllers = new LinkedHashSet<>(); + + private JsonLibrary jsonLibrary = JsonLibrary.jackson2; + private Map customTypeMappings = new LinkedHashMap<>(); + private Map customTypeNaming = new LinkedHashMap<>(); + private List importDeclarations = new ArrayList<>(); + private DateMapping mapDate = DateMapping.asString; + private EnumMapping mapEnum = EnumMapping.asInlineUnion; + + /** + * Executes the full TypeScript and tRPC generation pipeline. + * + * @throws IOException If an I/O error occurs reading classes or writing the output file. + * @throws IllegalStateException If {@code outputDir} is not configured or if no controllers are + * found. + */ + public void generate() throws IOException { + if (outputDir == null) { + throw new IllegalStateException("outputDir is required to generate the TypeScript file."); + } + + var finalOutput = outputDir.resolve(outputFile); + if (!Files.exists(outputDir)) { + Files.createDirectories(outputDir); + } + + var controllers = discoverControllers(); + controllers.addAll(manualControllers); + + if (controllers.isEmpty()) { + throw new IllegalStateException( + "No controllers were found to generate. " + + "Ensure 'buildClassesDir' points to the compiled classes directory, " + + "or use 'addController(Class)' to manually register controllers for unit testing."); + } + + // 1. Extract ONLY the Data Models (Inputs/Outputs) + var typesToGenerate = new LinkedHashSet(); + for (var controller : controllers) { + for (var method : controller.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + typesToGenerate.add(method.getGenericReturnType()); + for (var param : method.getGenericParameterTypes()) { + typesToGenerate.add(param); + } + } + } + } + + var settings = new Settings(); + settings.outputFileType = TypeScriptFileType.declarationFile; + settings.outputKind = TypeScriptOutputKind.module; + settings.classLoader = classLoader; + settings.jsonLibrary = this.jsonLibrary; + settings.mapDate = this.mapDate; + settings.mapEnum = this.mapEnum; + if (customTypeMappings != null) settings.customTypeMappings.putAll(customTypeMappings); + if (customTypeNaming != null) settings.customTypeNaming.putAll(customTypeNaming); + if (importDeclarations != null) settings.importDeclarations.addAll(importDeclarations); + + // 2. Generate standard interfaces (DTOs only) + if (!typesToGenerate.isEmpty()) { + TypeScriptGenerator.setLogger(asSlf4j(log)); + var generator = new TypeScriptGenerator(settings); + var input = Input.from(typesToGenerate.toArray(new Type[0])); + generator.generateTypeScript(input, Output.to(finalOutput.toFile())); + } + + // Safety net: If typescript-generator skipped generation (e.g., only primitive types), create + // the base file. + if (!Files.exists(finalOutput)) { + Files.writeString(finalOutput, "/* tslint:disable */\n/* eslint-disable */\n\n"); + } + + // 3. Append the exact tRPC AppRouter + appendTrpcRouter(finalOutput, controllers); + } + + private static cz.habarta.typescript.generator.Logger asSlf4j(Logger log) { + return new cz.habarta.typescript.generator.Logger() { + @Override + protected void write(Level level, String message) { + switch (level) { + case Info -> log.info(message); + case Warning -> log.warn(message.replace("Warning: ", "")); + case Error -> log.error(message.replace("Error: ", "")); + case Debug -> log.debug(message.replace("Debug: ", "")); + case Verbose -> log.trace(message); + } + } + }; + } + + /** + * Constructs and appends the tRPC {@code AppRouter} mapping to the bottom of the generated file. + * + * @param finalOutput The path to the generated output file. + * @param controllers The set of validated controller classes. + * @throws IOException If file writing fails. + */ + private void appendTrpcRouter(Path finalOutput, Set> controllers) throws IOException { + var ts = new StringBuilder(); + + ts.append("\n// --- tRPC Router Mapping ---\n\n"); + ts.append("export type AppRouter = {\n"); + + for (var controller : controllers) { + var namespace = extractNamespace(controller); + String indent = " "; // Default indent for root methods + + if (namespace != null) { + ts.append(" ").append(namespace).append(": {\n"); + indent = " "; // Increase indent for nested methods + } + + for (var method : controller.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + var params = method.getGenericParameterTypes(); + String tsInput = "void"; + if (params.length == 1) { + tsInput = resolveTsType(params[0]); + } else if (params.length > 1) { + var tuple = new ArrayList(); + for (var p : params) tuple.add(resolveTsType(p)); + tsInput = "[" + String.join(", ", tuple) + "]"; + } + + String tsOutput = resolveTsType(method.getGenericReturnType()); + + ts.append(indent) + .append(method.getName()) + .append(": { input: ") + .append(tsInput) + .append("; output: ") + .append(tsOutput) + .append(" };\n"); + } + } + + if (namespace != null) { + ts.append(" };\n"); + } + } + + ts.append("};\n"); + Files.writeString(finalOutput, ts.toString(), StandardOpenOption.APPEND); + } + + /** + * Fast, recursive type resolver to map Java types directly to TypeScript signatures. Understands + * Jooby async types, standard collections, and primitive mappings. + * + * @param type The Java type to evaluate. + * @return A valid TypeScript string representation of the type. + */ + private String resolveTsType(Type type) { + if (type == void.class || type == Void.class) return "void"; + + if (type instanceof ParameterizedType pt) { + var raw = pt.getRawType(); + var rawName = raw.getTypeName(); + + // Unwrap async types (CompletableFuture, Mono, Single, Future) + if (rawName.endsWith("CompletableFuture") + || rawName.endsWith("Single") + || rawName.endsWith("Mono") + || rawName.endsWith("Future")) { + return resolveTsType(pt.getActualTypeArguments()[0]); + } + + if (raw instanceof Class clazz) { + if (java.util.Collection.class.isAssignableFrom(clazz)) { + return resolveTsType(pt.getActualTypeArguments()[0]) + "[]"; + } + if (java.util.Map.class.isAssignableFrom(clazz)) { + return "{ [index: string]: " + resolveTsType(pt.getActualTypeArguments()[1]) + " }"; + } + if (java.util.Optional.class.isAssignableFrom(clazz)) { + return resolveTsType(pt.getActualTypeArguments()[0]) + " | null"; + } + + // Handle generic DTOs + var args = pt.getActualTypeArguments(); + var tsArgs = new ArrayList(); + for (var arg : args) tsArgs.add(resolveTsType(arg)); + return getClassName(clazz) + "<" + String.join(", ", tsArgs) + ">"; + } + } + + if (type instanceof Class clazz) { + if (clazz.isArray()) { + if (clazz.getComponentType() == byte.class) + return "string"; // Common byte[] to base64 string + return resolveTsType(clazz.getComponentType()) + "[]"; + } + + if (clazz == String.class + || clazz == char.class + || clazz == Character.class + || clazz.getName().equals("java.util.UUID")) return "string"; + if (clazz == boolean.class || clazz == Boolean.class) return "boolean"; + if (Number.class.isAssignableFrom(clazz) || clazz.isPrimitive()) return "number"; + + if (java.util.Date.class.isAssignableFrom(clazz) + || clazz.getName().startsWith("java.time.")) { + return mapDate == DateMapping.asString + ? "string" + : (mapDate == DateMapping.asNumber ? "number" : "Date"); + } + + return getClassName(clazz); + } + + return "any"; + } + + /** + * Evaluates the custom mappings to determine the appropriate TypeScript interface name. + * + * @param clazz The Java class being resolved. + * @return The target TypeScript interface name. + */ + private String getClassName(Class clazz) { + var fqn = clazz.getName(); + if (customTypeMappings != null && customTypeMappings.containsKey(fqn)) { + return customTypeMappings.get(fqn); + } + if (customTypeNaming != null && customTypeNaming.containsKey(fqn)) { + return customTypeNaming.get(fqn); + } + return clazz.getSimpleName(); + } + + /** + * Scans the build output directory to load and validate controller classes. + * + * @return A set of valid controller classes found on disk. + * @throws IOException If the directory scan fails. + */ + private Set> discoverControllers() { + var controllers = new LinkedHashSet>(); + + // We scope the scan strictly to the build directory for maximum speed + var classGraph = + new ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .enableMethodInfo() + .ignoreClassVisibility(); + + if (buildClassesDir != null && Files.exists(buildClassesDir)) { + classGraph.overrideClasspath(buildClassesDir.toUri().toString()); + } else if (classLoader != null) { + classGraph.overrideClassLoaders(classLoader); + } else { + return controllers; + } + + try (var scanResult = classGraph.scan()) { + for (var classInfo : scanResult.getAllClasses()) { + try { + var clazz = classInfo.loadClass(false); // loads without initializing! + + boolean includeClass = isTrpcAnnotated(clazz); + if (!includeClass && expandLookup) includeClass = hasWebAnnotation(clazz); + + if (includeClass) { + controllers.add(clazz); + } else { + // Check methods if the class itself isn't annotated + for (var method : clazz.getDeclaredMethods()) { + boolean includeMethod = isTrpcAnnotated(method); + if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); + + if (includeMethod) { + controllers.add(clazz); + break; + } + } + } + } catch (Throwable ignored) { + // Safely ignore classes that throw LinkageError or NoClassDefFoundError + } + } + } + + return controllers; + } + + /** + * ClassLoader-agnostic check to see if an element has the Trpc annotation. + * + * @param element The class or method to inspect. + * @return True if annotated with {@code io.jooby.annotation.Trpc}. + */ + private boolean isTrpcAnnotated(AnnotatedElement element) { + for (Annotation a : element.getAnnotations()) { + if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { + return true; + } + } + return false; + } + + /** + * ClassLoader-agnostic check for standard web routing annotations. + * + * @param element The class or method to inspect. + * @return True if a JAX-RS or Jooby web annotation is present. + */ + private boolean hasWebAnnotation(AnnotatedElement element) { + for (Annotation a : element.getAnnotations()) { + var name = a.annotationType().getName(); + if (name.startsWith("io.jooby.annotation.") + || name.startsWith("jakarta.ws.rs.") + || name.startsWith("javax.ws.rs.")) { + return true; + } + } + return false; + } + + /** + * Extracts the target namespace for the tRPC router based on the controller. If the class is not + * annotated with @Trpc, it returns null (indicating root-level). + * + * @param controller The controller class. + * @return The determined namespace string, or null for root-level. + */ + private String extractNamespace(Class controller) { + boolean hasClassLevelTrpc = false; + + for (Annotation a : controller.getAnnotations()) { + if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { + hasClassLevelTrpc = true; + try { + var method = a.annotationType().getMethod("value"); + var value = (String) method.invoke(a); + // Explicit namespace provided: @Trpc("myNamespace") + if (value != null && !value.isBlank()) return value; + } catch (Exception ignored) { + } + } + } + + // No class-level annotation means these methods sit at the root of the router + if (!hasClassLevelTrpc) { + return null; + } + + // Class is annotated, but no explicit value provided. Derive from class name. + var name = controller.getSimpleName(); + name = Character.toLowerCase(name.charAt(0)) + name.substring(1); + return name.replace("Controller", "").replace("Resource", ""); + } + + // --- Configuration API (Getters, Setters, and Builders) --- + + /** + * Explicitly adds a controller class to the generation pipeline. Highly recommended for unit + * testing to avoid classpath scanning issues. + * + * @param controller The controller class to analyze. + */ + public void addController(Class controller) { + this.manualControllers.add(controller); + } + + /** + * @return The directory where compiled class files are located. + */ + public Path getBuildClassesDir() { + return buildClassesDir; + } + + /** + * @param buildClassesDir The directory where compiled class files are located. + */ + public void setBuildClassesDir(Path buildClassesDir) { + this.buildClassesDir = buildClassesDir; + } + + /** + * @return The class loader used to load compiled controllers. + */ + public ClassLoader getClassLoader() { + return classLoader; + } + + /** + * @param classLoader The class loader used to load compiled controllers. Defaults to context + * class loader. + */ + public void setClassLoader(ClassLoader classLoader) { + if (classLoader != null) this.classLoader = classLoader; + } + + /** + * @return The destination directory for the generated TypeScript file. + */ + public Path getOutputDir() { + return outputDir; + } + + /** + * @param outputDir The destination directory for the generated TypeScript file. + */ + public void setOutputDir(Path outputDir) { + this.outputDir = outputDir; + } + + /** + * @return The name of the generated TypeScript file. + */ + public String getOutputFile() { + return outputFile; + } + + /** + * @param outputFile The name of the generated TypeScript file. Defaults to {@code trpc.d.ts}. + */ + public void setOutputFile(String outputFile) { + if (outputFile != null && !outputFile.isBlank()) this.outputFile = outputFile; + } + + /** + * @return True if standard Jooby and JAX-RS annotations are included in the generation. + */ + public boolean isExpandLookup() { + return expandLookup; + } + + /** + * @param expandLookup Set to true to generate endpoints for standard web annotations even + * without @Trpc. + */ + public void setExpandLookup(boolean expandLookup) { + this.expandLookup = expandLookup; + } + + /** + * @return The target JSON library for data model generation. + */ + public JsonLibrary getJsonLibrary() { + return jsonLibrary; + } + + /** + * @param jsonLibrary The target JSON library used to parse field annotations. Defaults to Jackson + * 2. + */ + public void setJsonLibrary(JsonLibrary jsonLibrary) { + if (jsonLibrary != null) this.jsonLibrary = jsonLibrary; + } + + /** + * @return Custom mapping overrides translating Java types to raw TypeScript strings. + */ + public Map getCustomTypeMappings() { + return customTypeMappings; + } + + /** + * @param customTypeMappings Custom mapping overrides translating Java types to raw TypeScript + * strings. + */ + public void setCustomTypeMappings(Map customTypeMappings) { + this.customTypeMappings = customTypeMappings; + } + + /** + * @return Custom overrides for generating specific TypeScript interface names. + */ + public Map getCustomTypeNaming() { + return customTypeNaming; + } + + /** + * @param customTypeNaming Custom overrides for generating specific TypeScript interface names. + */ + public void setCustomTypeNaming(Map customTypeNaming) { + this.customTypeNaming = customTypeNaming; + } + + /** + * @return Raw import statements appended to the top of the generated file. + */ + public List getImportDeclarations() { + return importDeclarations; + } + + /** + * @param importDeclarations Raw import statements appended to the top of the generated file. + */ + public void setImportDeclarations(List importDeclarations) { + this.importDeclarations = importDeclarations; + } + + /** + * @return The mapping strategy applied to Java date types. + */ + public DateMapping getMapDate() { + return mapDate; + } + + /** + * @param mapDate The mapping strategy applied to Java date types. + */ + public void setMapDate(DateMapping mapDate) { + this.mapDate = mapDate; + } + + /** + * @return The mapping strategy applied to Java enum types. + */ + public EnumMapping getMapEnum() { + return mapEnum; + } + + /** + * @param mapEnum The mapping strategy applied to Java enum types. + */ + public void setMapEnum(EnumMapping mapEnum) { + this.mapEnum = mapEnum; + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java new file mode 100644 index 0000000000..a9e64941dc --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.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.trpc.i3863; + +import io.jooby.annotation.GET; +import io.jooby.annotation.POST; +import io.jooby.annotation.Path; +import io.jooby.annotation.Trpc; + +@Path("/users") +@Trpc("users") // Custom namespace +public class C3863 { + + @GET("/{id}") + @Trpc + public U3863 getUser(String id) { + return new U3863(id, "user"); + } + + @POST + @Trpc + public U3863 createUser(U3863 user) { + return user; + } + + @GET("/internal") + public String internalEndpoint() { + return "This should not be exposed to tRPC"; + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java new file mode 100644 index 0000000000..479c30f8dd --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc.i3863; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +import io.jooby.trpc.TrpcGenerator; + +class TrpcGeneratorTest { + + @Test + void shouldGenerateTrpcRouterAndModels() throws Exception { + var generator = new TrpcGenerator(); + var outputDir = Paths.get("target"); + + // Dynamically locate the test-classes directory where the sample is compiled + var testClassesDir = + Paths.get(C3863.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + + generator.setBuildClassesDir(testClassesDir); + generator.setClassLoader(Thread.currentThread().getContextClassLoader()); + generator.setOutputDir(outputDir); + generator.setOutputFile("api.d.ts"); + + generator.generate(); + + var outputFile = outputDir.resolve("api.d.ts"); + assertTrue(Files.exists(outputFile), "TypeScript file should be generated"); + + var actualContent = Files.readString(outputFile); + + var expectedContent = + """ + /* tslint:disable */ + /* eslint-disable */ + + export interface U3863 { + id: string; + name: string; + } + + // --- tRPC Router Mapping --- + + export type AppRouter = { + users: { + getUser: { input: string; output: U3863 }; + createUser: { input: U3863; output: U3863 }; + }; + }; + """; + + // Strip out the dynamic timestamp comment line + var cleanActual = + actualContent.replaceAll("// Generated using typescript-generator.*\\r?\\n", ""); + + // Assert with normalized newlines to avoid \r\n vs \n test flakes + assertThat(cleanActual).isEqualToNormalizingNewlines(expectedContent); + } +} diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java new file mode 100644 index 0000000000..55d1467b37 --- /dev/null +++ b/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java @@ -0,0 +1,8 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc.i3863; + +public record U3863(String id, String name) {} diff --git a/modules/pom.xml b/modules/pom.xml index 5277cd3326..fa20dd3db9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -32,6 +32,9 @@ jooby-swagger-ui jooby-redoc + + jooby-trcp + jooby-hikari jooby-jdbi From 569bc04ce8fbeb6519f989e76fe5dbf4ef169626 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 14:53:44 -0300 Subject: [PATCH 02/16] - fix typo on module - ref #3863 --- modules/{jooby-trcp => jooby-trpc}/pom.xml | 6 +++--- .../src/main/java/io/jooby/trpc/TrpcGenerator.java | 0 .../src/test/java/io/jooby/trpc/i3863/C3863.java | 0 .../test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java | 0 .../src/test/java/io/jooby/trpc/i3863/U3863.java | 0 modules/pom.xml | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename modules/{jooby-trcp => jooby-trpc}/pom.xml (97%) rename modules/{jooby-trcp => jooby-trpc}/src/main/java/io/jooby/trpc/TrpcGenerator.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/C3863.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java (100%) rename modules/{jooby-trcp => jooby-trpc}/src/test/java/io/jooby/trpc/i3863/U3863.java (100%) diff --git a/modules/jooby-trcp/pom.xml b/modules/jooby-trpc/pom.xml similarity index 97% rename from modules/jooby-trcp/pom.xml rename to modules/jooby-trpc/pom.xml index 15f208983a..9e84263f92 100644 --- a/modules/jooby-trcp/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -6,10 +6,10 @@ io.jooby modules - 4.0.16-SNAPSHOT + 4.0.17-SNAPSHOT - jooby-trcp - jooby-trcp + jooby-trpc + jooby-trpc diff --git a/modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java similarity index 100% rename from modules/jooby-trcp/src/main/java/io/jooby/trpc/TrpcGenerator.java rename to modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/C3863.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java diff --git a/modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java similarity index 100% rename from modules/jooby-trcp/src/test/java/io/jooby/trpc/i3863/U3863.java rename to modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/U3863.java diff --git a/modules/pom.xml b/modules/pom.xml index 841c08d41b..be324dbec9 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -33,7 +33,7 @@ jooby-redoc - jooby-trcp + jooby-trpc jooby-hikari From a2b6924999378016cadcab5e5adf126751fd6b0d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 19:00:22 -0300 Subject: [PATCH 03/16] - add basic ErrorHandler logic - ref #3863 --- jooby/src/main/java/io/jooby/StatusCode.java | 9 ++++ .../main/java/io/jooby/trpc/TrpcError.java | 6 ++- .../java/io/jooby/trpc/TrpcErrorCode.java | 51 +++++++++++++++++++ .../java/io/jooby/trpc/TrpcErrorHandler.java | 35 +++++++++++++ .../main/java/io/jooby/trpc/TrpcModule.java | 17 +++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcModule.java diff --git a/jooby/src/main/java/io/jooby/StatusCode.java b/jooby/src/main/java/io/jooby/StatusCode.java index 0292bcf902..92195514bd 100644 --- a/jooby/src/main/java/io/jooby/StatusCode.java +++ b/jooby/src/main/java/io/jooby/StatusCode.java @@ -738,6 +738,14 @@ public final class StatusCode { public static final StatusCode REQUEST_HEADER_FIELDS_TOO_LARGE = new StatusCode(REQUEST_HEADER_FIELDS_TOO_LARGE_CODE, "Request Header Fields Too Large"); + /** {@code 499 The client aborted the request before completion}. */ + public static final int CLIENT_CLOSED_REQUEST_CODE = 499; + + /** {@code 499 The client aborted the request before completion}. */ + public static final StatusCode CLIENT_CLOSED_REQUEST = + new StatusCode( + CLIENT_CLOSED_REQUEST_CODE, "The client aborted the request before completion"); + // --- 5xx Server Error --- /** @@ -1025,6 +1033,7 @@ public static StatusCode valueOf(final int statusCode) { case PRECONDITION_REQUIRED_CODE -> PRECONDITION_REQUIRED; case TOO_MANY_REQUESTS_CODE -> TOO_MANY_REQUESTS; case REQUEST_HEADER_FIELDS_TOO_LARGE_CODE -> REQUEST_HEADER_FIELDS_TOO_LARGE; + case CLIENT_CLOSED_REQUEST_CODE -> CLIENT_CLOSED_REQUEST; case SERVER_ERROR_CODE -> SERVER_ERROR; case NOT_IMPLEMENTED_CODE -> NOT_IMPLEMENTED; case BAD_GATEWAY_CODE -> BAD_GATEWAY; diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcError.java b/jooby/src/main/java/io/jooby/trpc/TrpcError.java index a32dbbad92..f7753ad46c 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcError.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcError.java @@ -7,6 +7,8 @@ import java.util.Map; -public record TrpcError(ErrorDetail error) { - public record ErrorDetail(String message, int code, Map data) {} +public class TrpcError extends RuntimeException { + public TrpcError(String message, int code, Map data) { + super(message); + } } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java new file mode 100644 index 0000000000..88ea7c1e77 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.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.trpc; + +import io.jooby.StatusCode; + +public enum TrpcErrorCode { + PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), + BAD_REQUEST(-32600, StatusCode.BAD_REQUEST), + INTERNAL_SERVER_ERROR(-32603, StatusCode.SERVER_ERROR), + UNAUTHORIZED(-32001, StatusCode.UNAUTHORIZED), + FORBIDDEN(-32003, StatusCode.FORBIDDEN), + NOT_FOUND(-32004, StatusCode.NOT_FOUND), + METHOD_NOT_SUPPORTED(-32005, StatusCode.METHOD_NOT_ALLOWED), + TIMEOUT(-32008, StatusCode.REQUEST_TIMEOUT), + CONFLICT(-32009, StatusCode.CONFLICT), + PRECONDITION_FAILED(-32012, StatusCode.PRECONDITION_FAILED), + PAYLOAD_TOO_LARGE(-32013, StatusCode.REQUEST_ENTITY_TOO_LARGE), + UNPROCESSABLE_CONTENT(-32022, StatusCode.UNPROCESSABLE_ENTITY), + TOO_MANY_REQUESTS(-32029, StatusCode.TOO_MANY_REQUESTS), + CLIENT_CLOSED_REQUEST(-32099, StatusCode.CLIENT_CLOSED_REQUEST); + + private final int rpcCode; + private final StatusCode statusCode; + + TrpcErrorCode(int rpcCode, StatusCode statusCode) { + this.rpcCode = rpcCode; + this.statusCode = statusCode; + } + + public int getRpcCode() { + return rpcCode; + } + + public StatusCode getStatusCode() { + return statusCode; + } + + /** Helper to map a standard Jooby HTTP status code to the closest tRPC equivalent. */ + public static TrpcErrorCode of(StatusCode status) { + for (var code : values()) { + if (code.statusCode.value() == status.value()) { + return code; + } + } + return INTERNAL_SERVER_ERROR; // Fallback + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java new file mode 100644 index 0000000000..2ade60e2ce --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.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.trpc; + +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.StatusCode; + +public class TrpcErrorHandler implements ErrorHandler { + @Override + public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) { + if (ctx.getRequestPath().startsWith("/trpc/")) { + + var trpcCode = TrpcErrorCode.of(code); + + Map errorData = + Map.of( + "code", trpcCode.name(), + "httpStatus", code.value(), + "path", ctx.getRequestPath().replace("/trpc/", "")); + + var errorDetail = + new TrpcError.ErrorDetail(cause.getMessage(), trpcCode.getRpcCode(), errorData); + var trpcResponse = new TrpcError(errorDetail); + + ctx.setResponseCode(code).render(trpcResponse); + } + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java new file mode 100644 index 0000000000..639dc4e10b --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; + +public class TrpcModule implements Extension { + @Override + public void install(@NonNull Jooby application) throws Exception { + application.error(new TrpcErrorHandler()); + } +} From e3261e1f5bc7c7165e090b8aee6fe12ec1ecc45e Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 19:52:14 -0300 Subject: [PATCH 04/16] doc: remove Jooby label from doc --- docs/asciidoc/docinfo-header.html | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/asciidoc/docinfo-header.html b/docs/asciidoc/docinfo-header.html index 9c5e1f744f..d68a499725 100644 --- a/docs/asciidoc/docinfo-header.html +++ b/docs/asciidoc/docinfo-header.html @@ -1,5 +1,4 @@
- Jooby From c5ab8374c42d11eeb65446faa420c8be2529bca6 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 2 Mar 2026 19:58:53 -0300 Subject: [PATCH 05/16] doc: revert removing Jooby text for mobile version --- docs/asciidoc/docinfo-header.html | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/asciidoc/docinfo-header.html b/docs/asciidoc/docinfo-header.html index d68a499725..9c5e1f744f 100644 --- a/docs/asciidoc/docinfo-header.html +++ b/docs/asciidoc/docinfo-header.html @@ -1,4 +1,5 @@
+ Jooby From 1d60d42c27cb17ef45c2a8d48da95aada44a3453 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 4 Mar 2026 09:35:11 -0300 Subject: [PATCH 06/16] - redo code generation for @Trpc - it uses a cool parser which allow to provide custom implemantation of it (Jackson/Avajeb) - remove hardcode dependency to Jackson - implement Java/Kotlin code generation - implement success, void/empty and error reponses - add protocol integration test - ref #3863 --- .../main/java/io/jooby/trpc/TrpcDecoder.java | 13 + .../main/java/io/jooby/trpc/TrpcError.java | 14 - .../java/io/jooby/trpc/TrpcErrorCode.java | 2 +- .../java/io/jooby/trpc/TrpcErrorHandler.java | 35 +- .../java/io/jooby/trpc/TrpcException.java | 64 +++ .../main/java/io/jooby/trpc/TrpcModule.java | 10 +- .../main/java/io/jooby/trpc/TrpcParser.java | 34 ++ .../main/java/io/jooby/trpc/TrpcReader.java | 27 + .../main/java/io/jooby/trpc/TrpcResponse.java | 13 +- jooby/src/main/java/module-info.java | 1 + .../io/jooby/internal/apt/MvcParameter.java | 4 + .../java/io/jooby/internal/apt/MvcRoute.java | 476 ++++++++++++------ .../io/jooby/internal/apt/TypeDefinition.java | 13 + .../src/test/java/tests/i3863/C3863.java | 25 +- .../src/test/java/tests/i3863/C3863_.java | 53 -- .../src/test/java/tests/i3863/Issue3863.java | 2 +- .../jooby/avaje/jsonb/AvajeJsonbModule.java | 29 +- .../avaje/jsonb/AvajeTrpcDecoder.java | 28 ++ .../internal/avaje/jsonb/AvajeTrpcParser.java | 38 ++ .../internal/avaje/jsonb/AvajeTrpcReader.java | 75 +++ .../avaje/jsonb/AvajeTrpcResponseAdapter.java | 79 +++ .../java/io/jooby/jackson/JacksonModule.java | 6 +- .../internal/jackson3/JacksonTrpcDecoder.java | 28 ++ .../internal/jackson3/JacksonTrpcParser.java | 43 ++ .../internal/jackson3/JacksonTrpcReader.java | 73 +++ .../JacksonTrpcResponseSerializer.java | 37 ++ .../io/jooby/jackson3/Jackson3Module.java | 21 +- .../java/io/jooby/jackson3/PrimitiveTest.java | 3 + tests/pom.xml | 7 +- .../src/test/java/io/jooby/i3863/Movie.java | 7 +- .../java/io/jooby/i3863/MovieService.java | 63 +++ .../java/io/jooby/i3863/TrpcProtocolTest.java | 160 ++++++ .../test/java/io/jooby/test/WebClient.java | 6 + 33 files changed, 1220 insertions(+), 269 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java delete mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcError.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcException.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcParser.java create mode 100644 jooby/src/main/java/io/jooby/trpc/TrpcReader.java delete mode 100644 modules/jooby-apt/src/test/java/tests/i3863/C3863_.java create mode 100644 modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java create mode 100644 modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java create mode 100644 modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java create mode 100644 modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java create mode 100644 modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java create mode 100644 modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java create mode 100644 modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java create mode 100644 modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java rename jooby/src/main/java/io/jooby/trpc/TrpcResult.java => tests/src/test/java/io/jooby/i3863/Movie.java (52%) create mode 100644 tests/src/test/java/io/jooby/i3863/MovieService.java create mode 100644 tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java new file mode 100644 index 0000000000..faccf4d940 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java @@ -0,0 +1,13 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +/** A pre-resolved decoder used at RUNTIME for single-argument methods. */ +public interface TrpcDecoder { + T decode(String name, byte[] payload); + + T decode(String name, String payload); +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcError.java b/jooby/src/main/java/io/jooby/trpc/TrpcError.java deleted file mode 100644 index f7753ad46c..0000000000 --- a/jooby/src/main/java/io/jooby/trpc/TrpcError.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.trpc; - -import java.util.Map; - -public class TrpcError extends RuntimeException { - public TrpcError(String message, int code, Map data) { - super(message); - } -} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java index 88ea7c1e77..cb86ef84a0 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java @@ -8,8 +8,8 @@ import io.jooby.StatusCode; public enum TrpcErrorCode { - PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), BAD_REQUEST(-32600, StatusCode.BAD_REQUEST), + PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), INTERNAL_SERVER_ERROR(-32603, StatusCode.SERVER_ERROR), UNAUTHORIZED(-32001, StatusCode.UNAUTHORIZED), FORBIDDEN(-32003, StatusCode.FORBIDDEN), diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java index 2ade60e2ce..983fa8b8a8 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java @@ -6,30 +6,39 @@ package io.jooby.trpc; import java.util.Map; +import java.util.Optional; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.Context; import io.jooby.ErrorHandler; +import io.jooby.Reified; import io.jooby.StatusCode; public class TrpcErrorHandler implements ErrorHandler { @Override public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) { if (ctx.getRequestPath().startsWith("/trpc/")) { + TrpcException trpcError; + if (cause instanceof TrpcException) { + trpcError = (TrpcException) cause; + } else { + Map, TrpcErrorCode> customMapping = + ctx.require(Reified.map(Class.class, TrpcErrorCode.class)); + var procedure = ctx.getRequestPath().replace("/trpc/", ""); + trpcError = + new TrpcException( + procedure, errorCode(customMapping, cause).orElse(TrpcErrorCode.of(code)), cause); + } + ctx.setResponseCode(trpcError.getStatusCode()).render(trpcError.toMap()); + } + } - var trpcCode = TrpcErrorCode.of(code); - - Map errorData = - Map.of( - "code", trpcCode.name(), - "httpStatus", code.value(), - "path", ctx.getRequestPath().replace("/trpc/", "")); - - var errorDetail = - new TrpcError.ErrorDetail(cause.getMessage(), trpcCode.getRpcCode(), errorData); - var trpcResponse = new TrpcError(errorDetail); - - ctx.setResponseCode(code).render(trpcResponse); + private Optional errorCode(Map, TrpcErrorCode> mappings, Throwable x) { + for (var mapping : mappings.entrySet()) { + if (mapping.getKey().isInstance(x)) { + return Optional.of(mapping.getValue()); + } } + return Optional.empty(); } } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcException.java b/jooby/src/main/java/io/jooby/trpc/TrpcException.java new file mode 100644 index 0000000000..a0e02461fe --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcException.java @@ -0,0 +1,64 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import io.jooby.StatusCode; + +public class TrpcException extends RuntimeException { + private final String procedure; + private final TrpcErrorCode errorCode; + + public TrpcException(String procedure, StatusCode code, Throwable cause) { + this(procedure, TrpcErrorCode.of(code), cause); + } + + public TrpcException(String procedure, TrpcErrorCode code, Throwable cause) { + super(procedure + ": " + code.name(), cause); + this.procedure = procedure; + this.errorCode = code; + } + + public TrpcException(String procedure, TrpcErrorCode code) { + super(procedure + ": " + code.name()); + this.procedure = procedure; + this.errorCode = code; + } + + public TrpcException(String procedure, StatusCode code) { + this(procedure, TrpcErrorCode.of(code)); + } + + public StatusCode getStatusCode() { + return errorCode.getStatusCode(); + } + + public String getProcedure() { + return procedure; + } + + public Map toMap() { + Map data = new LinkedHashMap<>(); + data.put("code", errorCode.name()); + data.put("httpStatus", errorCode.getStatusCode().value()); + data.put("path", procedure); + + Map error = new LinkedHashMap<>(); + error.put( + "message", + Optional.ofNullable(getCause()) + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .orElse(errorCode.name())); + error.put("code", errorCode.getRpcCode()); + error.put("data", data); + return Map.of("error", error); + } +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java index 639dc4e10b..23cec03253 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java @@ -11,7 +11,13 @@ public class TrpcModule implements Extension { @Override - public void install(@NonNull Jooby application) throws Exception { - application.error(new TrpcErrorHandler()); + public void install(@NonNull Jooby app) throws Exception { + var services = app.getServices(); + + services.require(TrpcParser.class); + // Custom mapping for TrpcErrorCode + services.mapOf(Class.class, TrpcErrorCode.class); + + app.error(new TrpcErrorHandler()); } } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java new file mode 100644 index 0000000000..6352635fc1 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java @@ -0,0 +1,34 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +import java.lang.reflect.Type; + +/** + * Factory provided by the JSON module (Jackson or Avaje). Used by the APT-generated routes AT + * STARTUP to cache deserializers. + */ +public interface TrpcParser { + + /** Resolves and caches the deserializer for a specific type during route initialization. */ + TrpcDecoder decoder(Type type); + + /** + * Creates a sequential reader for parsing tRPC arguments from a JSON array. + * + * @param payload A JSON array containing the method arguments. + * @return A reader for sequential argument extraction. + */ + TrpcReader reader(byte[] payload); + + /** + * Creates a sequential reader for parsing tRPC arguments from a JSON array. + * + * @param payload A JSON array containing the method arguments. + * @return A reader for sequential argument extraction. + */ + TrpcReader reader(String payload); +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java new file mode 100644 index 0000000000..1b1c1c6a20 --- /dev/null +++ b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java @@ -0,0 +1,27 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.trpc; + +/** + * A sequential reader used at RUNTIME for multi-argument methods. Allows the APT code to extract + * arguments one by one, preserving primitives. + */ +public interface TrpcReader extends AutoCloseable { + + // Primitive extractors to avoid autoboxing + int nextInt(String name); + + long nextLong(String name); + + boolean nextBoolean(String name); + + double nextDouble(String name); + + String nextString(String name); + + // Object extractor using a pre-resolved decoder + T nextObject(String name, TrpcDecoder decoder); +} diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java index 2b16ea6af9..e3631a46ff 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java @@ -5,9 +5,16 @@ */ package io.jooby.trpc; -public record TrpcResponse(TrpcResult result) { +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; - public static TrpcResponse success(T data) { - return new TrpcResponse<>(new TrpcResult<>(data)); +public record TrpcResponse(@Nullable T data) { + + public static @NonNull TrpcResponse of(@NonNull T data) { + return new TrpcResponse<>(data); + } + + public static @NonNull TrpcResponse empty() { + return new TrpcResponse<>(null); } } diff --git a/jooby/src/main/java/module-info.java b/jooby/src/main/java/module-info.java index d8382ec3b5..a0a2f8de49 100644 --- a/jooby/src/main/java/module-info.java +++ b/jooby/src/main/java/module-info.java @@ -14,6 +14,7 @@ exports io.jooby.problem; exports io.jooby.value; exports io.jooby.output; + exports io.jooby.trpc; uses io.jooby.Server; uses io.jooby.SslProvider; diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java index 107f177516..6e8038a90d 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcParameter.java @@ -37,6 +37,10 @@ public TypeDefinition getType() { return type; } + public String getName() { + return parameter.getSimpleName().toString(); + } + public String generateMapping(boolean kt) { var strategy = annotations.entrySet().stream() diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index e75cf175ff..3e4830923f 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 @@ -32,6 +32,7 @@ public class MvcRoute { private boolean uncheckedCast; private final boolean hasBeanValidation; private final List trpcMethods = new ArrayList<>(); + private final Set pending = new HashSet<>(); public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { this.context = context; @@ -160,9 +161,11 @@ public List generateMapping(boolean kt) { targetMethod = "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1); trpcMethods.add(new TrpcMethod(httpMethod, targetMethod)); + } else { + pending.add(methodName); } for (var path : paths) { - var lastLine = lastHttpMethod && paths.get(paths.size() - 1).equals(path); + var lastLine = lastHttpMethod && paths.getLast().equals(path); block.add(javadocLink); block.add( statement( @@ -251,116 +254,120 @@ static String leadingSlash(String path) { public List generateHandlerCall(boolean kt) { var buffer = new ArrayList(); - /* Parameters */ - var paramList = new StringJoiner(", ", "(", ")"); - for (var parameter : getParameters(true)) { - String generatedParameter = parameter.generateMapping(kt); - if (parameter.isRequireBeanValidation()) { - generatedParameter = - CodeBlock.of( - "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); - } - - paramList.add(generatedParameter); - } - var returnTypeGenerics = - getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var returnTypeString = type(kt, getReturnType().toString()); - var customReturnType = getReturnType(); - if (customReturnType.isProjection()) { - // Override for projection - returnTypeGenerics = ""; - returnTypeString = Types.PROJECTED + "<" + returnType + ">"; - } + var methodName = getGeneratedName(); + if (pending.contains(methodName)) { + /* Parameters */ + var paramList = new StringJoiner(", ", "(", ")"); + for (var parameter : getParameters(true)) { + String generatedParameter = parameter.generateMapping(kt); + if (parameter.isRequireBeanValidation()) { + generatedParameter = + CodeBlock.of( + "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); + } - var nullable = - methodCallHeader(kt, getGeneratedName(), buffer, returnTypeGenerics, returnTypeString); - if (returnType.isVoid()) { - String statusCode; - if (annotationMap.size() == 1) { - statusCode = - annotationMap.keySet().iterator().next().getSimpleName().toString().equals("DELETE") - ? "NO_CONTENT" - : "OK"; - } else { - statusCode = null; + paramList.add(generatedParameter); + } + var returnTypeGenerics = + getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var returnTypeString = type(kt, getReturnType().toString()); + var customReturnType = getReturnType(); + if (customReturnType.isProjection()) { + // Override for projection + returnTypeGenerics = ""; + returnTypeString = Types.PROJECTED + "<" + returnType + ">"; } - if (statusCode != null) { + + var nullable = + methodCallHeader(kt, "ctx", methodName, buffer, returnTypeGenerics, returnTypeString); + if (returnType.isVoid()) { + String statusCode; + if (annotationMap.size() == 1) { + statusCode = + annotationMap.keySet().iterator().next().getSimpleName().toString().equals("DELETE") + ? "NO_CONTENT" + : "OK"; + } else { + statusCode = null; + } + if (statusCode != null) { + buffer.add( + statement( + indent(2), + "ctx.setResponseCode(io.jooby.StatusCode.", + statusCode, + ")", + semicolon(kt))); + } else { + if (kt) { + buffer.add( + statement( + indent(2), + "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(", + string("DELETE"), + ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)")); + } else { + buffer.add( + statement( + indent(2), + "ctx.setResponseCode(ctx.getRoute().getMethod().equals(", + string("DELETE"), + ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)", + semicolon(false))); + } + } + controllerVar(kt, buffer); + buffer.add( + statement( + indent(2), "c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt))); + buffer.add(statement(indent(2), "return ctx.getResponseCode()", semicolon(kt))); + } else if (returnType.is("io.jooby.StatusCode")) { + controllerVar(kt, buffer); buffer.add( statement( indent(2), - "ctx.setResponseCode(io.jooby.StatusCode.", - statusCode, - ")", + kt ? "val" : "var", + " statusCode = c.", + this.method.getSimpleName(), + paramList.toString(), semicolon(kt))); + buffer.add(statement(indent(2), "ctx.setResponseCode(statusCode)", semicolon(kt))); + buffer.add(statement(indent(2), "return statusCode", semicolon(kt))); } else { - if (kt) { + controllerVar(kt, buffer); + var cast = + customReturnType.isProjection() + ? "" + : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + paramList.toString()); + if (!cast.isEmpty()) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + } + if (customReturnType.isProjection()) { + var projected = + of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); buffer.add( statement( - indent(2), - "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(", - string("DELETE"), - ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)")); + indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt))); } else { buffer.add( - statement( - indent(2), - "ctx.setResponseCode(ctx.getRoute().getMethod().equals(", - string("DELETE"), - ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)", - semicolon(false))); + statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); } } - controllerVar(kt, buffer); - buffer.add( - statement( - indent(2), "c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt))); - buffer.add(statement(indent(2), "return ctx.getResponseCode()", semicolon(kt))); - } else if (returnType.is("io.jooby.StatusCode")) { - controllerVar(kt, buffer); - buffer.add( - statement( - indent(2), - kt ? "val" : "var", - " statusCode = c.", - this.method.getSimpleName(), - paramList.toString(), - semicolon(kt))); - buffer.add(statement(indent(2), "ctx.setResponseCode(statusCode)", semicolon(kt))); - buffer.add(statement(indent(2), "return statusCode", semicolon(kt))); - } else { - controllerVar(kt, buffer); - var cast = - customReturnType.isProjection() - ? "" - : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; - var call = - of( - "c.", - this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, - paramList.toString()); - if (!cast.isEmpty()) { - setUncheckedCast(true); - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; - } - if (customReturnType.isProjection()) { - var projected = - of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); - buffer.add( - statement(indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt))); - } else { - buffer.add( - statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); - } - } - buffer.add(statement("}", System.lineSeparator())); - if (uncheckedCast) { - if (kt) { - buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); - } else { - buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + buffer.add(statement("}", System.lineSeparator())); + if (uncheckedCast) { + if (kt) { + buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); + } else { + buffer.addFirst(statement("@SuppressWarnings(\"unchecked\")")); + } } } for (var trpcMethod : trpcMethods) { @@ -371,11 +378,29 @@ public List generateHandlerCall(boolean kt) { private boolean methodCallHeader( boolean kt, + String contextVarname, String methodName, ArrayList buffer, String returnTypeGenerics, String returnTypeString) { - var throwsException = !method.getThrownTypes().isEmpty(); + return methodCallHeader( + kt, + contextVarname, + methodName, + buffer, + returnTypeGenerics, + returnTypeString, + !method.getThrownTypes().isEmpty()); + } + + private boolean methodCallHeader( + boolean kt, + String contextVarname, + String methodName, + ArrayList buffer, + String returnTypeGenerics, + String returnTypeString, + boolean throwsException) { var nullable = false; if (kt) { nullable = @@ -396,14 +421,16 @@ private boolean methodCallHeader( "(handler: io.jooby.kt.HandlerContext): ", returnTypeString, " {")); - buffer.add(statement(indent(2), "val ctx = handler.ctx")); + buffer.add(statement(indent(2), "val ", contextVarname, " = handler.ctx")); } else { buffer.add( statement( "fun ", returnTypeGenerics, methodName, - "(ctx: io.jooby.Context): ", + "(", + contextVarname, + ": io.jooby.Context): ", returnTypeString, " {")); } @@ -415,7 +442,9 @@ private boolean methodCallHeader( returnTypeString, " ", methodName, - "(io.jooby.Context ctx) ", + "(io.jooby.Context ", + contextVarname, + ") ", throwsException ? "throws Exception {" : "{")); } return nullable; @@ -427,80 +456,192 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { var returnTypeString = "io.jooby.trpc.TrpcResponse<" + type(kt, getReturnType().toString()) + ">"; - var nullable = methodCallHeader(kt, trpcMethod.name, buffer, "", returnTypeString); - if (trpcMethod.method == HttpMethod.GET) { - buffer.add( - statement( - indent(2), - var(kt), - "input = ctx.query(", - string("input"), - ").value()", - semicolon(kt))); + methodCallHeader(kt, "ctx", trpcMethod.name, buffer, "", returnTypeString, true); + var hasArgs = !parameters.isEmpty(); + if (hasArgs) { buffer.add( statement( indent(2), var(kt), - "mapper = ctx.require(tools.jackson.databind.ObjectMapper", + "parser = ctx.require(io.jooby.trpc.TrpcParser", clazz(kt), ")", semicolon(kt))); - List arguments = - switch (method.getParameters().size()) { - case 0 -> List.of(); - case 1 -> { - buffer.add( + if (trpcMethod.method == HttpMethod.GET) { + buffer.add( + statement( + indent(2), + var(kt), + "input = ctx.query(", + string("input"), + ").", + parameters.isEmpty() ? "valueOrNull" : "value", + "()", + semicolon(kt))); + if (kt) { + buffer.add( + statement( + indent(2), + "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true) throw" + + " IllegalArgumentException(", + string("tRPC input must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" + + " input.charAt(input.length() - 1) != ']') throw new" + + " IllegalArgumentException(", + string("tRPC input must be a JSON array (tuple)"), + ");")); + } + } else { + buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); + if (kt) { + buffer.add( + statement( + indent(2), + "if (input.size < 2 || input[0] != '['.toByte() || input[input.size - 1] !=" + + " ']'.toByte()) throw IllegalArgumentException(", + string("tRPC body must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']') throw" + + " new IllegalArgumentException(", + string("tRPC body must be a JSON array (tuple)"), + ");")); + } + } + } + var isVoid = returnType.isVoid() || returnType.is("kotlin.Unit"); + + var parseStatements = new ArrayList(); + var arguments = new ArrayList(); + for (var parameter : parameters) { + var paramenterName = parameter.getName(); + var type = parameter.getType().getRawType().toString(); + switch (type) { + case "io.jooby.Context": + { + arguments.add("ctx"); + } + break; + case "int", "long", "double", "boolean", "java.lang.String": + { + var simpleType = type.equals(String.class.getName()) ? "String" : type; + var nextName = + "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); + parseStatements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + nextName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + arguments.add(paramenterName); + } + break; + default: + { + if (kt) { + parseStatements.add( statement( - indent(2), - var(kt), - method.getParameters().getFirst().getSimpleName(), - " = mapper.readValue(input, ", - method.getParameters().getFirst().asType().toString(), - clazz(kt), + indent(4), + "val ", + paramenterName, + "Decoder: io.jooby.trpc.TrpcDecoder<", + parameter.getType().toString(), + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), ")", semicolon(kt))); - yield List.of(method.getParameters().getFirst().getSimpleName().toString()); - } - default -> { - buffer.add( - statement(indent(2), var(kt), "array = mapper.readTree(input)", semicolon(kt))); - var args = new ArrayList(); - for (int i = 0; i < method.getParameters().size(); i++) { - buffer.add( - statement( - indent(2), - var(kt), - method.getParameters().getFirst().getSimpleName(), - Integer.toString(i), - " = mapper.readValue(array.get(", - Integer.toString(i), - "), ", - method.getParameters().getFirst().asType().toString(), - clazz(kt), - ")", - semicolon(kt))); - args.add(method.getParameters().getFirst().getSimpleName().toString()); - } - yield args; + parseStatements.add( + statement( + indent(4), + "val ", + paramenterName, + ": ", + parameter.getType().toString(), + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName + "Decoder)", + semicolon(kt))); + } else { + parseStatements.add( + statement( + indent(4), + "io.jooby.trpc.TrpcDecoder<", + parameter.getType().toString(), + "> ", + paramenterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + parseStatements.add( + statement( + indent(4), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName + "Decoder)", + semicolon(kt))); } - }; - controllerVar(kt, buffer); - var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; - var call = - of( - "c.", - this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, - arguments.stream().collect(Collectors.joining(", ", "(", ")"))); - if (!cast.isEmpty()) { - setUncheckedCast(true); - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + arguments.add(paramenterName); + } + break; } - buffer.add(statement(indent(2), var(kt), "result = ", call, semicolon(kt))); - buffer.add( - statement( - indent(2), "return ", "io.jooby.trpc.TrpcResponse.success(result)", semicolon(kt))); + } + + int indent = 2; + if (hasArgs) { + indent = 4; + if (kt) { + buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); + } else { + buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); + } + buffer.addAll(parseStatements); + } + controllerVar(kt, buffer, indent); + var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + arguments.stream().collect(Collectors.joining(", ", "(", ")"))); + if (!cast.isEmpty()) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + } + if (isVoid) { + buffer.add(statement(indent(indent), call, semicolon(kt))); + } else { + buffer.add(statement(indent(indent), var(kt), "result = ", call, semicolon(kt))); + } + buffer.add( + statement( + indent(indent), + "return ", + "io.jooby.trpc.TrpcResponse.", + isVoid ? "empty()" : "of(result)", + semicolon(kt))); + if (hasArgs) { + buffer.add(statement(indent(2), "}")); } buffer.add(statement("}", System.lineSeparator())); @@ -508,8 +649,11 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { } private void controllerVar(boolean kt, List buffer) { - buffer.add( - statement(indent(2), kt ? "val" : "var", " c = this.factory.apply(ctx)", semicolon(kt))); + controllerVar(kt, buffer, 2); + } + + private void controllerVar(boolean kt, List buffer, int indent) { + buffer.add(statement(indent(indent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); } public String getGeneratedName() { diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java index 089adc4bc2..c23a995a67 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java @@ -44,6 +44,19 @@ public String toSourceCode(boolean kt) { return toSourceCode(this, kt, false); } + public String toJacksonTypeReference(boolean kt) { + if (isParameterizedType()) { + var buffer = new StringBuilder(); + if (!kt) { + buffer.append("new "); + } + buffer.append("tools.jackson.core.type.TypeReference<").append(type).append(">() {}"); + return buffer.toString(); + } else { + return CodeBlock.type(kt, getRawType().toString()) + clazz(kt); + } + } + public String getArgumentsString(boolean kt, boolean convertTypeVar, Set kinds) { List arguments = getArguments(); var filtered = diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java index 9ac6d42c75..0f34026eb8 100644 --- a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java @@ -5,18 +5,39 @@ */ package tests.i3863; +import io.jooby.Context; import io.jooby.annotation.*; @Trpc("users") public class C3863 { + // @Trpc.Query + // public String ping () { + // return null; + // } + + // @Trpc.Query + // public void clear() { + // + // } + @Trpc.Query - public U3863 findUser(@PathParam long id) { + public U3863 findUser(Context ctx, @PathParam long id) { return null; } + // @Trpc.Query + // public List multipleSimpleArgs(String q, String type) { + // return null; + // } + // + // @Trpc.Query + // public List multipleComplexArguments(U3863 current, List users) { + // return null; + // } + // // @Trpc.Mutation - // public U3863 updateUser(@PathParam String id, U3863 payload) { + // public U3863 updateUser(String id, U3863 payload) { // return null; // } } diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java deleted file mode 100644 index f929114403..0000000000 --- a/modules/jooby-apt/src/test/java/tests/i3863/C3863_.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package tests.i3863; - -@io.jooby.annotation.Generated(C3863.class) -public class C3863_ implements io.jooby.Extension { - protected java.util.function.Function factory; - - public C3863_() { - this(io.jooby.SneakyThrows.singleton(C3863::new)); - } - - public C3863_(C3863 instance) { - setup(ctx -> instance); - } - - public C3863_(io.jooby.SneakyThrows.Supplier provider) { - setup(ctx -> (C3863) provider.get()); - } - - public C3863_(io.jooby.SneakyThrows.Function, C3863> provider) { - setup(ctx -> provider.apply(C3863.class)); - } - - private void setup(java.util.function.Function factory) { - this.factory = factory; - } - - public void install(io.jooby.Jooby app) throws Exception { - /** See {@link C3863#findUser(long) */ - app.get("/trpc/users.findUser", this::trpcFindUser); - - /** See {@link C3863#findUser(long) */ - app.get("/users/{id}", this::findUser); - } - - public U3863 findUser(io.jooby.Context ctx) { - var c = this.factory.apply(ctx); - return c.findUser(ctx.path("id").longValue()); - } - - public io.jooby.trpc.TrpcResponse trpcFindUser(io.jooby.Context ctx) { - var input = ctx.query("input").value(); - var mapper = ctx.require(tools.jackson.databind.ObjectMapper.class); - var arg0 = mapper.readValue(input, long.class); - var c = this.factory.apply(ctx); - var result = c.findUser(arg0); - return io.jooby.trpc.TrpcResponse.success(result); - } -} diff --git a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java index cc1e3036e0..958185266b 100644 --- a/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java +++ b/modules/jooby-apt/src/test/java/tests/i3863/Issue3863.java @@ -11,7 +11,7 @@ public class Issue3863 { @Test - public void shouldGenerateTrpcHandler() throws Exception { + public void shouldGenerateTrpcService() throws Exception { new ProcessorRunner(new C3863()) .withSourceCode( 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 e5e10ef7ff..a1e97b2ff2 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java @@ -10,12 +10,18 @@ import java.util.*; import edu.umd.cs.findbugs.annotations.NonNull; +import io.avaje.json.JsonDataException; import io.avaje.json.JsonWriter; import io.avaje.jsonb.JsonView; import io.avaje.jsonb.Jsonb; import io.jooby.*; +import io.jooby.internal.avaje.jsonb.AvajeTrpcParser; +import io.jooby.internal.avaje.jsonb.AvajeTrpcResponseAdapter; import io.jooby.internal.avaje.jsonb.BufferedJsonOutput; import io.jooby.output.Output; +import io.jooby.trpc.TrpcErrorCode; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcResponse; /** * JSON module using Avaje-JsonB: projected) { var view = (JsonView) jsonbType.view(viewString); view.toJson(value, writer); } + + /** + * Custom adapter for {@link TrpcResponse}. + * + * @return Custom adapter for {@link TrpcResponse}. + */ + public static Jsonb.AdapterBuilder trpcResponseAdapter() { + return AvajeTrpcResponseAdapter::new; + } } diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java new file mode 100644 index 0000000000..bc09e67a35 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcDecoder.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.avaje.jsonb; + +import io.avaje.jsonb.JsonType; +import io.jooby.trpc.TrpcDecoder; + +public class AvajeTrpcDecoder implements TrpcDecoder { + + final JsonType typeAdapter; + + public AvajeTrpcDecoder(JsonType typeAdapter) { + this.typeAdapter = typeAdapter; + } + + @Override + public T decode(String name, byte[] payload) { + return typeAdapter.fromJson(payload); + } + + @Override + public T decode(String name, String payload) { + return typeAdapter.fromJson(payload); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java new file mode 100644 index 0000000000..93e58dbc9b --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.avaje.jsonb; + +import java.lang.reflect.Type; + +import io.avaje.jsonb.Jsonb; +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcReader; + +public class AvajeTrpcParser implements TrpcParser { + + private final Jsonb jsonb; + + public AvajeTrpcParser(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public TrpcDecoder decoder(Type type) { + // Avaje resolves the AOT-generated adapter here + return new AvajeTrpcDecoder<>(jsonb.type(type)); + } + + @Override + public TrpcReader reader(byte[] payload) { + return new AvajeTrpcReader(jsonb.reader(payload)); + } + + @Override + public TrpcReader reader(String payload) { + return new AvajeTrpcReader(jsonb.reader(payload)); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java new file mode 100644 index 0000000000..0f4862b198 --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java @@ -0,0 +1,75 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.avaje.jsonb; + +import io.avaje.json.JsonReader; +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcReader; + +public class AvajeTrpcReader implements TrpcReader { + + private final JsonReader reader; + + public AvajeTrpcReader(JsonReader reader) { + this.reader = reader; + reader.beginArray(); + } + + private void ensureNext() { + // hasNextElement() checks for ']' and consumes the comma ',' if present. + if (!reader.hasNextElement()) { + throw new IllegalArgumentException("Not enough arguments in tRPC tuple payload"); + } + } + + @Override + public int nextInt(String name) { + ensureNext(); + return reader.readInt(); + } + + @Override + public long nextLong(String name) { + ensureNext(); + return reader.readLong(); + } + + @Override + public boolean nextBoolean(String name) { + ensureNext(); + return reader.readBoolean(); + } + + @Override + public double nextDouble(String name) { + ensureNext(); + return reader.readDouble(); + } + + @Override + public String nextString(String name) { + ensureNext(); + return reader.readString(); + } + + @Override + public T nextObject(String name, TrpcDecoder decoder) { + ensureNext(); + // Cast to access the underlying Avaje JsonType adapter + AvajeTrpcDecoder avajeDecoder = (AvajeTrpcDecoder) decoder; + + // JsonType.fromJson(JsonReader) consumes exactly the tokens needed + // for the object, leaving the stream in the correct position. + return avajeDecoder.typeAdapter.fromJson(reader); + } + + @Override + public void close() { + // Consume the closing ']' and close the underlying stream + reader.endArray(); + reader.close(); + } +} diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java new file mode 100644 index 0000000000..5734a537ff --- /dev/null +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcResponseAdapter.java @@ -0,0 +1,79 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.avaje.jsonb; + +import java.util.Map; + +import io.avaje.json.JsonAdapter; +import io.avaje.json.JsonReader; +import io.avaje.json.JsonWriter; +import io.avaje.jsonb.Jsonb; +import io.jooby.trpc.TrpcResponse; + +public class AvajeTrpcResponseAdapter implements JsonAdapter { + + private final Jsonb jsonb; + + public AvajeTrpcResponseAdapter(Jsonb jsonb) { + this.jsonb = jsonb; + } + + @Override + public void toJson(JsonWriter writer, TrpcResponse envelope) { + writer.beginObject(); + writer.name("result"); + + writer.beginObject(); + var data = envelope.data(); + + if (data != null) { + writer.name("data"); + if (data instanceof Iterable iterable) { + writer.beginArray(); + for (var item : iterable) { + if (item == null) { + writer.nullValue(); + } else { + writePOJO(writer, item); + } + } + writer.endArray(); + } else if (data instanceof Map map) { + writer.beginObject(); + for (var entry : map.entrySet()) { + // JSON keys must be strings + writer.name(String.valueOf(entry.getKey())); + + var value = entry.getValue(); + if (value == null) { + writer.nullValue(); + } else { + writePOJO(writer, value); + } + } + writer.endObject(); + } else { + writePOJO(writer, data); + } + } + + writer.endObject(); + writer.endObject(); + } + + private void writePOJO(JsonWriter writer, Object item) { + // Look up the adapter for the specific element (e.g., Movie.class) + @SuppressWarnings("unchecked") + JsonAdapter itemAdapter = (JsonAdapter) jsonb.adapter(item.getClass()); + itemAdapter.toJson(writer, item); + } + + @Override + public TrpcResponse fromJson(JsonReader reader) { + // We only serialize out, tRPC clients don't send this envelope to the server + throw new UnsupportedOperationException("Deserialization of TrpcEnvelope is not required"); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java index fd8e960342..401b80115b 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java @@ -18,10 +18,9 @@ import com.fasterxml.jackson.annotation.JsonFilter; import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.Module; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; @@ -149,6 +148,7 @@ public void install(@NonNull Jooby application) { // Parsing exception as 400 application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST); + application.errorCode(MismatchedInputException.class, StatusCode.BAD_REQUEST); // Filter var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false); diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java new file mode 100644 index 0000000000..eeb1958bb6 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcDecoder.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import io.jooby.trpc.TrpcDecoder; +import tools.jackson.databind.ObjectReader; + +public class JacksonTrpcDecoder implements TrpcDecoder { + + final ObjectReader reader; + + public JacksonTrpcDecoder(ObjectReader reader) { + this.reader = reader; + } + + @Override + public T decode(String name, byte[] payload) { + return reader.readValue(payload); + } + + @Override + public T decode(String name, String payload) { + return reader.readValue(payload); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java new file mode 100644 index 0000000000..58b2792174 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java @@ -0,0 +1,43 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import java.lang.reflect.Type; + +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcReader; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; + +public class JacksonTrpcParser implements TrpcParser { + + private final ObjectMapper mapper; + + public JacksonTrpcParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public TrpcDecoder decoder(Type type) { + // Resolve the type exactly once at startup + var javaType = mapper.constructType(type); + // FIX: Tell Jackson not to panic when it sees the ']' or ',' immediately following the object + // in the stream. + var reader = mapper.readerFor(javaType).without(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + return new JacksonTrpcDecoder<>(reader); + } + + @Override + public TrpcReader reader(byte[] payload) { + return new JacksonTrpcReader(mapper.createParser(payload)); + } + + @Override + public TrpcReader reader(String payload) { + return new JacksonTrpcReader(mapper.createParser(payload)); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java new file mode 100644 index 0000000000..a905c98fc9 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java @@ -0,0 +1,73 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcReader; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; + +public class JacksonTrpcReader implements TrpcReader { + + private final JsonParser parser; + + public JacksonTrpcReader(JsonParser parser) { + this.parser = parser; + // tRPC multi-args are encoded as a JSON array (Tuple). + // Ensure the stream starts correctly. + var token = parser.nextToken(); + + if (token != JsonToken.START_ARRAY) { + throw new IllegalArgumentException("Expected JSON array for tRPC multi-argument payload"); + } + } + + @Override + public int nextInt(String name) { + parser.nextToken(); + return parser.getIntValue(); + } + + @Override + public long nextLong(String name) { + parser.nextToken(); + return parser.getLongValue(); + } + + @Override + public boolean nextBoolean(String name) { + parser.nextToken(); + return parser.getBooleanValue(); + } + + @Override + public double nextDouble(String name) { + parser.nextToken(); + return parser.getDoubleValue(); + } + + @Override + public String nextString(String name) { + parser.nextToken(); + return parser.getString(); + } + + @Override + public T nextObject(String name, TrpcDecoder decoder) { + parser.nextToken(); + + // Cast back to our specific implementation to access the underlying ObjectReader. + // This allows us to read complex objects directly from the current position + // in the stream without any intermediate byte[] buffering or allocation. + JacksonTrpcDecoder jacksonDecoder = (JacksonTrpcDecoder) decoder; + return jacksonDecoder.reader.readValue(parser); + } + + @Override + public void close() { + parser.close(); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java new file mode 100644 index 0000000000..91bd5e72d9 --- /dev/null +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcResponseSerializer.java @@ -0,0 +1,37 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson3; + +import io.jooby.trpc.TrpcResponse; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ser.std.StdSerializer; + +public class JacksonTrpcResponseSerializer extends StdSerializer { + public JacksonTrpcResponseSerializer() { + super(TrpcResponse.class); + } + + @Override + public void serialize(TrpcResponse value, JsonGenerator gen, SerializationContext provider) + throws JacksonException { + gen.writeStartObject(); // { + + gen.writeName("result"); + gen.writeStartObject(); // "result": { + + var data = value.data(); + // Only write the "data" key if the method actually returned something (not void/Unit) + if (data != null) { + gen.writeName("data"); + gen.writePOJO(data); + } + + gen.writeEndObject(); + gen.writeEndObject(); + } +} diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java index 32b92b5884..b7149a65da 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/jackson3/Jackson3Module.java @@ -17,13 +17,17 @@ import com.fasterxml.jackson.annotation.JsonFilter; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.*; +import io.jooby.internal.jackson3.JacksonTrpcParser; +import io.jooby.internal.jackson3.JacksonTrpcResponseSerializer; import io.jooby.output.Output; +import io.jooby.trpc.TrpcErrorCode; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcResponse; import tools.jackson.core.exc.StreamReadException; -import tools.jackson.databind.JacksonModule; -import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.*; +import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; import tools.jackson.databind.ser.std.SimpleFilterProvider; import tools.jackson.databind.type.TypeFactory; @@ -141,9 +145,15 @@ public void install(@NonNull Jooby application) { Class mapperType = mapper.getClass(); services.put(mapperType, mapper); services.put(ObjectMapper.class, mapper); + // tRPC + services.put(TrpcParser.class, new JacksonTrpcParser(mapper)); // Parsing exception as 400 application.errorCode(StreamReadException.class, StatusCode.BAD_REQUEST); + services + .mapOf(Class.class, TrpcErrorCode.class) + .put(StreamReadException.class, TrpcErrorCode.BAD_REQUEST) + .put(MismatchedInputException.class, TrpcErrorCode.BAD_REQUEST); application.onStarting(() -> onStarting(application, services, mapperType)); @@ -217,6 +227,9 @@ public static ObjectMapper create(JacksonModule... modules) { JsonMapper.Builder builder = JsonMapper.builder(); Stream.of(modules).forEach(builder::addModule); + var trpcModule = new SimpleModule(); + trpcModule.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer()); + builder.addModule(trpcModule); return builder.build(); } diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java index 83ada6f24b..21f5144c67 100644 --- a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java +++ b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java @@ -6,6 +6,7 @@ package io.jooby.jackson3; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import org.junit.jupiter.api.Test; @@ -17,5 +18,7 @@ public class PrimitiveTest { public void shouldParsePrimitive() { var mapper = new ObjectMapper(); assertEquals(1, mapper.readValue("1", long.class)); + assertEquals(1, mapper.readValue("null", long.class)); + assertNull(mapper.readValue("null", Long.class)); } } diff --git a/tests/pom.xml b/tests/pom.xml index a8c930a1b8..beb8c743e1 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -201,7 +201,12 @@ assertj-core test - + + com.jayway.jsonpath + json-path + 3.0.0 + test + org.mockito mockito-core diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResult.java b/tests/src/test/java/io/jooby/i3863/Movie.java similarity index 52% rename from jooby/src/main/java/io/jooby/trpc/TrpcResult.java rename to tests/src/test/java/io/jooby/i3863/Movie.java index 70b3337ac3..a5c4238799 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcResult.java +++ b/tests/src/test/java/io/jooby/i3863/Movie.java @@ -3,6 +3,9 @@ * Apache License Version 2.0 https://jooby.io/LICENSE.txt * Copyright 2014 Edgar Espina */ -package io.jooby.trpc; +package io.jooby.i3863; -public record TrpcResult(T data) {} +import io.avaje.jsonb.Json; + +@Json +public record Movie(int id, String title, int year) {} diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java new file mode 100644 index 0000000000..59476a997f --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -0,0 +1,63 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3863; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.jooby.annotation.Trpc; + +@Trpc("movies") +public class MovieService { + + private final List database = + List.of(new Movie(1, "The Godfather", 1972), new Movie(2, "Pulp Fiction", 1994)); + + /** Procedure: movies.create Takes a single complex object. */ + @Trpc.Mutation + public Movie create(Movie movie) { + // In a real app, save to DB. For now, just return it. + return movie; + } + + @Trpc.Mutation + public void resetIndex() {} + + /** Procedure: movies.bulkCreate Takes a List of complex objects. */ + @Trpc.Query + public List bulkCreate(List movies) { + return movies.stream().map(m -> "Created: " + m.title()).collect(Collectors.toList()); + } + + /** Procedure: movies.ping */ + @Trpc.Query + public String ping() { + return "pong"; + } + + /** Procedure: movies.getById Single primitive argument */ + @Trpc.Query + public Movie getById(int id) { + return database.stream().filter(m -> m.id() == id).findFirst().orElse(null); + } + + /** Procedure: movies.search Multi-argument (Tuple) */ + @Trpc.Query + public List search(String title, int year) { + return database.stream().filter(m -> m.title().contains(title) && m.year() == year).toList(); + } + + /** Procedure: movies.addReview Mix of String and int (Mutation) */ + @Trpc.Mutation + public Map addReview(String movieTitle, int stars, String comment) { + // Business logic... + return Map.of( + "title", movieTitle, + "rating", stars, + "status", "published"); + } +} diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java new file mode 100644 index 0000000000..13542809e7 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -0,0 +1,160 @@ +/* + * 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 com.jayway.jsonpath.JsonPath; +import io.jooby.Extension; +import io.jooby.avaje.jsonb.AvajeJsonbModule; +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 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) { + http.get( + "/trpc/movies.ping", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"result":{"data":"pong"}} + """); + }); + http.get( + "/trpc/movies.getById?input=[1]", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"result":{"data":{"id":1,"title":"The Godfather","year":1972}}} + """); + }); + // Test Multi-Arg Query (Pulp Fiction 1994) + // input=["Pulp Fiction", 1994] + http.get( + "/trpc/movies.search?input=[\"Pulp Fiction\", 1994]", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"result":{"data":[{"id":2,"title":"Pulp Fiction","year":1994}]}} + """); + }); + // Test Multi-Arg Mutation + // Body: ["The Godfather", 5, "Amazing"] + http.postJson( + "/trpc/movies.addReview", + "[\"The Godfather\", 5, \"Amazing\"]", + rsp -> { + var json = rsp.body().string(); + + String status = JsonPath.read(json, "$.result.data.status"); + int rating = JsonPath.read(json, "$.result.data.rating"); + + assertThat(status).isEqualTo("published"); + assertThat(rating).isEqualTo(5); + }); + // 3. The tRPC payload (The Tuple) + // Notice the outer `[` and `]` wrapping the actual JSON object. + http.postJson( + "/trpc/movies.create", + "[{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}]", + rsp -> { + String json = rsp.body().string(); + + // 4. Validating the tRPC envelope and data using JsonPath + AssertJ + assertThat(rsp.code()).isEqualTo(200); + + assertThat(JsonPath.read(json, "$.result.data.id")).isEqualTo(1); + assertThat(JsonPath.read(json, "$.result.data.title")).isEqualTo("The Matrix"); + assertThat(JsonPath.read(json, "$.result.data.year")).isEqualTo(1999); + }); + + http.post( + "/trpc/movies.resetIndex", + rsp -> { + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"result":{}} + """); + }); + + // 1. Test Error: Type Mismatch (Sending a String instead of an Integer for 'stars') + http.postJson( + "/trpc/movies.addReview", + "[\"The Godfather\", \"FIVE_STARS\", \"Amazing\"]", + rsp -> { + String json = rsp.body().string(); + + assertThat(rsp.code()).isEqualTo(400); + + // tRPC puts error details inside an "error" envelope, not "result" + assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); + assertThat(JsonPath.read(json, "$.error.data.httpStatus")).isEqualTo(400); + + // The message should complain about the parsing failure + String message = JsonPath.read(json, "$.error.message"); + assertThat(message).isNotEmpty(); + }); + + // 2. Test Error: Missing Tuple Wrapper (Sending Object instead of Array tuple) + http.postJson( + "/trpc/movies.create", + "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}", + 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")) + .isEqualTo("tRPC body must be a JSON array (tuple)"); + + // Ideally, the message explicitly states that an array was expected + assertThat(JsonPath.read(json, "$.error.message")).containsIgnoringCase("array"); + }); + + // 3. Test 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"); + assertThat(JsonPath.read(json, "$.error.data.httpStatus")).isEqualTo(404); + }); + } +} diff --git a/tests/src/test/java/io/jooby/test/WebClient.java b/tests/src/test/java/io/jooby/test/WebClient.java index 8236117b5f..d1ff7247aa 100644 --- a/tests/src/test/java/io/jooby/test/WebClient.java +++ b/tests/src/test/java/io/jooby/test/WebClient.java @@ -5,6 +5,8 @@ */ package io.jooby.test; +import static okhttp3.RequestBody.create; + import java.io.IOException; import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; @@ -359,6 +361,10 @@ public void post(String path, RequestBody form, SneakyThrows.Consumer post(path, form).execute(callback); } + public void postJson(String path, String json, SneakyThrows.Consumer callback) { + post(path, create(json, MediaType.parse("application/json"))).execute(callback); + } + public Request put(String path) { return invoke("put", path, EMPTY_BODY); } From c05aacdebb1f643ccf2b5c13d3a8fcb7c5c3880f Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 4 Mar 2026 13:53:09 -0300 Subject: [PATCH 07/16] - implement parameter nullability --- .../main/java/io/jooby/trpc/TrpcReader.java | 12 +- .../java/io/jooby/internal/apt/MvcRoute.java | 355 +++++++++++++----- .../src/test/java/tests/i3863/C3863.java | 25 +- .../internal/avaje/jsonb/AvajeTrpcReader.java | 56 ++- .../internal/jackson3/JacksonTrpcReader.java | 63 +++- .../test/java/io/jooby/i3863/Metadata.java | 11 + .../java/io/jooby/i3863/MovieService.java | 13 +- .../java/io/jooby/i3863/TrpcProtocolTest.java | 59 +++ 8 files changed, 475 insertions(+), 119 deletions(-) create mode 100644 tests/src/test/java/io/jooby/i3863/Metadata.java diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java index 1b1c1c6a20..76683f6262 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java @@ -5,13 +5,23 @@ */ package io.jooby.trpc; +import io.jooby.exception.MissingValueException; + /** * A sequential reader used at RUNTIME for multi-argument methods. Allows the APT code to extract * arguments one by one, preserving primitives. */ public interface TrpcReader extends AutoCloseable { - // Primitive extractors to avoid autoboxing + boolean nextIsNull(String name); + + // 2. Default helper for non-nullable primitives to fail fast + default void requireNext(String name) { + if (nextIsNull(name)) { + throw new MissingValueException(name); + } + } + int nextInt(String name); long nextLong(String name); 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 3e4830923f..43d8969df5 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 @@ -11,6 +11,7 @@ import static java.util.Optional.ofNullable; import java.util.*; +import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -519,40 +520,233 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { } var isVoid = returnType.isVoid() || returnType.is("kotlin.Unit"); - var parseStatements = new ArrayList(); var arguments = new ArrayList(); + var argumentStatements = generateTrpcParameter(kt, arguments::add); + int indent = 2; + if (hasArgs) { + indent = 4; + if (kt) { + buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); + } else { + buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); + } + buffer.addAll(argumentStatements); + } + controllerVar(kt, buffer, indent); + var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); + var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + var call = + of( + "c.", + this.method.getSimpleName(), + kotlinNotEnoughTypeInformation, + arguments.stream().collect(Collectors.joining(", ", "(", ")"))); + if (!cast.isEmpty()) { + setUncheckedCast(true); + call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; + } + if (isVoid) { + buffer.add(statement(indent(indent), call, semicolon(kt))); + } else { + buffer.add(statement(indent(indent), var(kt), "result = ", call, semicolon(kt))); + } + buffer.add( + statement( + indent(indent), + "return ", + "io.jooby.trpc.TrpcResponse.", + isVoid ? "empty()" : "of(result)", + semicolon(kt))); + if (hasArgs) { + buffer.add(statement(indent(2), "}")); + } + + buffer.add(statement("}", System.lineSeparator())); + return buffer; + } + + private List generateTrpcParameter(boolean kt, Consumer arguments) { + var statements = new ArrayList(); for (var parameter : parameters) { var paramenterName = parameter.getName(); var type = parameter.getType().getRawType().toString(); + + boolean isNullable = parameter.isNullable(kt); + switch (type) { case "io.jooby.Context": { - arguments.add("ctx"); + arguments.accept("ctx"); } break; - case "int", "long", "double", "boolean", "java.lang.String": + + case "int", + "long", + "double", + "boolean", + "java.lang.String", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Double", + "java.lang.Boolean": { - var simpleType = type.equals(String.class.getName()) ? "String" : type; - var nextName = + var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; + if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; + var readName = "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); - parseStatements.add( - statement( - indent(4), - var(kt), - paramenterName, - " = reader.", - nextName, - "(", - string(paramenterName), - ")", - semicolon(kt))); - arguments.add(paramenterName); + + if (isNullable) { + if (kt) { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readName, + "(", + string(paramenterName), + ")")); + } else { + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + } else { + // Non-Nullable: Direct call + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); + } + arguments.accept(paramenterName); + } + break; + + case "byte", + "short", + "float", + "char", + "java.lang.Byte", + "java.lang.Short", + "java.lang.Float", + "java.lang.Character": + { + var isChar = type.equals("char") || type.equals("java.lang.Character"); + var isFloat = type.equals("float") || type.equals("java.lang.Float"); + var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt"); + + if (isNullable) { + if (kt) { + var ktCast = + isChar + ? "?.get(0)" + : "?.to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast)); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); + } + } else { + if (kt) { + var ktCast = + isChar + ? "[0]" + : ".to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast, + semicolon(kt))); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); + } + } + arguments.accept(paramenterName); } break; + default: { if (kt) { - parseStatements.add( + statements.add( statement( indent(4), "val ", @@ -563,20 +757,34 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { parameter.getType().toSourceCode(kt), ")", semicolon(kt))); - parseStatements.add( - statement( - indent(4), - "val ", - paramenterName, - ": ", - parameter.getType().toString(), - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName + "Decoder)", - semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } else { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } } else { - parseStatements.add( + statements.add( statement( indent(4), "io.jooby.trpc.TrpcDecoder<", @@ -587,65 +795,42 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { parameter.getType().toSourceCode(kt), ")", semicolon(kt))); - parseStatements.add( - statement( - indent(4), - parameter.getType().toString(), - " ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName + "Decoder)", - semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(4), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } else { + statements.add( + statement( + indent(4), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } } - arguments.add(paramenterName); + arguments.accept(paramenterName); } break; } } - - int indent = 2; - if (hasArgs) { - indent = 4; - if (kt) { - buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); - } else { - buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); - } - buffer.addAll(parseStatements); - } - controllerVar(kt, buffer, indent); - var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; - var call = - of( - "c.", - this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, - arguments.stream().collect(Collectors.joining(", ", "(", ")"))); - if (!cast.isEmpty()) { - setUncheckedCast(true); - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; - } - if (isVoid) { - buffer.add(statement(indent(indent), call, semicolon(kt))); - } else { - buffer.add(statement(indent(indent), var(kt), "result = ", call, semicolon(kt))); - } - buffer.add( - statement( - indent(indent), - "return ", - "io.jooby.trpc.TrpcResponse.", - isVoid ? "empty()" : "of(result)", - semicolon(kt))); - if (hasArgs) { - buffer.add(statement(indent(2), "}")); - } - - buffer.add(statement("}", System.lineSeparator())); - return buffer; + return statements; } private void controllerVar(boolean kt, List buffer) { diff --git a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java index 0f34026eb8..e83bcaed0e 100644 --- a/modules/jooby-apt/src/test/java/tests/i3863/C3863.java +++ b/modules/jooby-apt/src/test/java/tests/i3863/C3863.java @@ -5,29 +5,33 @@ */ package tests.i3863; -import io.jooby.Context; import io.jooby.annotation.*; @Trpc("users") public class C3863 { + @Trpc.Query + public String ping(Integer year) { + return null; + } + // @Trpc.Query - // public String ping () { + // public String ping() { // return null; // } - + // // @Trpc.Query // public void clear() { // // } - - @Trpc.Query - public U3863 findUser(Context ctx, @PathParam long id) { - return null; - } - + // + // @Trpc.Query + // public U3863 findUser(Context ctx, @PathParam long id) { + // return null; + // } + // // @Trpc.Query - // public List multipleSimpleArgs(String q, String type) { + // public List multipleSimpleArgs(String q, byte type) { // return null; // } // @@ -40,4 +44,5 @@ public U3863 findUser(Context ctx, @PathParam long id) { // public U3863 updateUser(String id, U3863 payload) { // return null; // } + } diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java index 0f4862b198..7f856eca57 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java @@ -6,58 +6,96 @@ package io.jooby.internal.avaje.jsonb; import io.avaje.json.JsonReader; +import io.jooby.exception.MissingValueException; import io.jooby.trpc.TrpcDecoder; import io.jooby.trpc.TrpcReader; public class AvajeTrpcReader implements TrpcReader { - private final JsonReader reader; + private boolean hasPeeked = false; public AvajeTrpcReader(JsonReader reader) { this.reader = reader; reader.beginArray(); } - private void ensureNext() { + @Override + public boolean nextIsNull(String name) { + if (!hasPeeked) { + if (!reader.hasNextElement()) { + throw new MissingValueException(name); + } + hasPeeked = true; // We successfully advanced the cursor to a value + } + + if (reader.isNullValue()) { + // Avaje requires us to actively skip the null token to consume it + reader.skipValue(); + hasPeeked = false; // Reset because the value is consumed + return true; + } + + // It's not null. We leave hasPeeked = true so the next extraction method doesn't advance again. + return false; + } + + private void ensureNext(String name) { + if (hasPeeked) { + // We already advanced the stream during nextIsNull(). + // Reset the flag since the caller is about to consume the value. + hasPeeked = false; + return; + } + // hasNextElement() checks for ']' and consumes the comma ',' if present. if (!reader.hasNextElement()) { - throw new IllegalArgumentException("Not enough arguments in tRPC tuple payload"); + throw new MissingValueException(name); } } + private void ensureNonNull(String name) { + if (reader.isNullValue()) throw new MissingValueException(name); + } + @Override public int nextInt(String name) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); return reader.readInt(); } @Override public long nextLong(String name) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); return reader.readLong(); } @Override public boolean nextBoolean(String name) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); return reader.readBoolean(); } @Override public double nextDouble(String name) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); return reader.readDouble(); } @Override public String nextString(String name) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); return reader.readString(); } @Override public T nextObject(String name, TrpcDecoder decoder) { - ensureNext(); + ensureNext(name); + ensureNonNull(name); // Cast to access the underlying Avaje JsonType adapter AvajeTrpcDecoder avajeDecoder = (AvajeTrpcDecoder) decoder; diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java index a905c98fc9..e66cd3cc73 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java @@ -5,59 +5,98 @@ */ package io.jooby.internal.jackson3; +import io.jooby.exception.MissingValueException; import io.jooby.trpc.TrpcDecoder; import io.jooby.trpc.TrpcReader; import tools.jackson.core.JsonParser; import tools.jackson.core.JsonToken; public class JacksonTrpcReader implements TrpcReader { - private final JsonParser parser; + private boolean hasPeeked = false; public JacksonTrpcReader(JsonParser parser) { this.parser = parser; - // tRPC multi-args are encoded as a JSON array (Tuple). - // Ensure the stream starts correctly. - var token = parser.nextToken(); + parser.nextToken(); + } + + @Override + public boolean nextIsNull(String name) { + if (!hasPeeked) { + advance(name); + hasPeeked = true; + } + + if (parser.currentToken() == JsonToken.VALUE_NULL) { + hasPeeked = false; // Consume the null token + return true; + } + + // It's not null. We leave hasPeeked = true so extraction doesn't advance again. + return false; + } + + private void ensureNext(String name) { + if (hasPeeked) { + // We already advanced the stream during nextIsNull(). + // Reset the flag since the caller is about to consume the value. + hasPeeked = false; + return; + } + + advance(name); + } - if (token != JsonToken.START_ARRAY) { - throw new IllegalArgumentException("Expected JSON array for tRPC multi-argument payload"); + private void ensureNonNull(String name) { + if (parser.currentToken() == JsonToken.VALUE_NULL) throw new MissingValueException(name); + } + + private void advance(String name) { + JsonToken token = parser.nextToken(); + if (token == JsonToken.END_ARRAY || token == null) { + throw new MissingValueException(name); } } @Override public int nextInt(String name) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); return parser.getIntValue(); } @Override public long nextLong(String name) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); return parser.getLongValue(); } @Override public boolean nextBoolean(String name) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); return parser.getBooleanValue(); } @Override public double nextDouble(String name) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); return parser.getDoubleValue(); } @Override public String nextString(String name) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); return parser.getString(); } @Override public T nextObject(String name, TrpcDecoder decoder) { - parser.nextToken(); + ensureNext(name); + ensureNonNull(name); // Cast back to our specific implementation to access the underlying ObjectReader. // This allows us to read complex objects directly from the current position diff --git a/tests/src/test/java/io/jooby/i3863/Metadata.java b/tests/src/test/java/io/jooby/i3863/Metadata.java new file mode 100644 index 0000000000..8e8996ba47 --- /dev/null +++ b/tests/src/test/java/io/jooby/i3863/Metadata.java @@ -0,0 +1,11 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.i3863; + +import io.avaje.jsonb.Json; + +@Json +public record Metadata(String releaseDate) {} diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java index 59476a997f..1aebba3a0e 100644 --- a/tests/src/test/java/io/jooby/i3863/MovieService.java +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -47,8 +47,10 @@ public Movie getById(int id) { /** Procedure: movies.search Multi-argument (Tuple) */ @Trpc.Query - public List search(String title, int year) { - return database.stream().filter(m -> m.title().contains(title) && m.year() == year).toList(); + public List search(String title, Integer year) { + return database.stream() + .filter(m -> m.title().contains(title) && (year == null || m.year() == year)) + .toList(); } /** Procedure: movies.addReview Mix of String and int (Mutation) */ @@ -60,4 +62,11 @@ public Map addReview(String movieTitle, int stars, String commen "rating", stars, "status", "published"); } + + /** Procedure: movies.addReview Mix of String and int (Mutation) */ + @Trpc.Mutation + public Metadata updateMetadata(int id, Metadata metadata) { + // Business logic... + return metadata; + } } diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java index 13542809e7..47c103635d 100644 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -156,5 +156,64 @@ void assertProtocolData(WebClient http) { assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("NOT_FOUND"); assertThat(JsonPath.read(json, "$.error.data.httpStatus")).isEqualTo(404); }); + + // 1. Validating a nullable parameter is accepted when passing `null` + // Assuming `movies.search` has a signature like: search(String title, Integer year) + // where `year` is nullable. Sending: ["The Godfather", null] + http.get( + "/trpc/movies.search?input=[\"The Godfather\", null]", + rsp -> { + assertThat(rsp.code()).isEqualTo(200); + assertThat(rsp.body().string()) + .isEqualToIgnoringNewLines( + """ + {"result":{"data":[{"id":1,"title":"The Godfather","year":1972}]}} + """); + }); + + // 2. Validating a required (non-nullable) parameter rejects `null` + // Assuming `movies.getById` has signature: getById(int id) + // Sending: [null] + http.get( + "/trpc/movies.getById?input=[null]", + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(400); + + // Should trigger a parsing error because a primitive cannot be null + assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); + assertThat(JsonPath.read(json, "$.error.message")).containsIgnoringCase("id"); + }); + + // 3. Validating MissingValueException (Not enough arguments) + // Assuming `movies.addReview` requires 3 arguments: addReview(String title, int stars, String + // comment) + // Sending only 2: ["The Godfather", 5] + http.postJson( + "/trpc/movies.addReview", + "[\"The Godfather\", 5]", + rsp -> { + String json = rsp.body().string(); + assertThat(rsp.code()).isEqualTo(400); + + // Should trigger Jooby's MissingValueException because the array ended too early + assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); + assertThat(JsonPath.read(json, "$.error.message")) + .containsIgnoringCase("comment"); + }); + + // 4. Validating explicit `null` on an Object/POJO (if allowed) + // Assuming a method like: updateMetadata(int id, Metadata data) where `data` is nullable + // Sending: [1, null] + http.postJson( + "/trpc/movies.updateMetadata", + "[1, null]", + rsp -> { + String json = rsp.body().string(); + System.out.println(json); + assertThat(rsp.code()).isEqualTo(200); + // Validates the generator correctly used nextIsNull for the POJO decoder + assertThat(json).contains("\"result\""); + }); } } From 129179e3f9a50f94fdecbb97b97e60a4b9357c06 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Wed, 4 Mar 2026 20:15:12 -0300 Subject: [PATCH 08/16] - make sure kotlin implementation works --- .../java/io/jooby/internal/apt/MvcRoute.java | 58 ++++++++------- .../io/jooby/internal/apt/TypeDefinition.java | 13 ---- .../java/io/jooby/jackson3/PrimitiveTest.java | 24 ------- tests/src/test/kotlin/i3863/KMovieService.kt | 71 +++++++++++++++++++ 4 files changed, 105 insertions(+), 61 deletions(-) delete mode 100644 modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java create mode 100644 tests/src/test/kotlin/i3863/KMovieService.kt 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 43d8969df5..2462f640e4 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 @@ -455,9 +455,11 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { var buffer = new ArrayList(); var returnTypeString = - "io.jooby.trpc.TrpcResponse<" + type(kt, getReturnType().toString()) + ">"; + "io.jooby.trpc.TrpcResponse<" + + (returnType.isVoid() ? (kt ? "Unit" : "Void") : type(kt, returnType.toString())) + + ">"; - methodCallHeader(kt, "ctx", trpcMethod.name, buffer, "", returnTypeString, true); + var nullable = methodCallHeader(kt, "ctx", trpcMethod.name, buffer, "", returnTypeString, true); var hasArgs = !parameters.isEmpty(); if (hasArgs) { buffer.add( @@ -476,7 +478,7 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { "input = ctx.query(", string("input"), ").", - parameters.isEmpty() ? "valueOrNull" : "value", + "value", "()", semicolon(kt))); if (kt) { @@ -503,8 +505,8 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { buffer.add( statement( indent(2), - "if (input.size < 2 || input[0] != '['.toByte() || input[input.size - 1] !=" - + " ']'.toByte()) throw IllegalArgumentException(", + "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1] !=" + + " ']'.code.toByte()) throw IllegalArgumentException(", string("tRPC body must be a JSON array (tuple)"), ")")); } else { @@ -533,30 +535,38 @@ private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { buffer.addAll(argumentStatements); } controllerVar(kt, buffer, indent); - var cast = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; var call = of( "c.", this.method.getSimpleName(), - kotlinNotEnoughTypeInformation, + "", arguments.stream().collect(Collectors.joining(", ", "(", ")"))); - if (!cast.isEmpty()) { - setUncheckedCast(true); - call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; - } if (isVoid) { buffer.add(statement(indent(indent), call, semicolon(kt))); + buffer.add( + statement( + indent(indent), "return ", "io.jooby.trpc.TrpcResponse.empty()", semicolon(kt))); } else { - buffer.add(statement(indent(indent), var(kt), "result = ", call, semicolon(kt))); + if (kt) { + var cast = !returnType.getArguments().isEmpty(); + buffer.add( + statement( + indent(indent), + "val result = ", + call, + cast ? " as " + type(true, returnType.toString()) : "")); + buffer.add( + statement( + indent(indent), + "return ", + "io.jooby.trpc.TrpcResponse.of(result", + nullable ? "!!" : "", + ")")); + } else { + buffer.add(statement(indent(indent), "var result = ", call, ";")); + buffer.add(statement(indent(indent), "return ", "io.jooby.trpc.TrpcResponse.of(result);")); + } } - buffer.add( - statement( - indent(indent), - "return ", - "io.jooby.trpc.TrpcResponse.", - isVoid ? "empty()" : "of(result)", - semicolon(kt))); if (hasArgs) { buffer.add(statement(indent(2), "}")); } @@ -569,11 +579,11 @@ private List generateTrpcParameter(boolean kt, Consumer argument var statements = new ArrayList(); for (var parameter : parameters) { var paramenterName = parameter.getName(); - var type = parameter.getType().getRawType().toString(); + var type = type(kt, parameter.getType().toString()); boolean isNullable = parameter.isNullable(kt); - switch (type) { + switch (parameter.getType().getRawType().toString()) { case "io.jooby.Context": { arguments.accept("ctx"); @@ -752,7 +762,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument "val ", paramenterName, "Decoder: io.jooby.trpc.TrpcDecoder<", - parameter.getType().toString(), + type, "> = parser.decoder(", parameter.getType().toSourceCode(kt), ")", @@ -788,7 +798,7 @@ private List generateTrpcParameter(boolean kt, Consumer argument statement( indent(4), "io.jooby.trpc.TrpcDecoder<", - parameter.getType().toString(), + type, "> ", paramenterName, "Decoder = parser.decoder(", diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java index c23a995a67..089adc4bc2 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/TypeDefinition.java @@ -44,19 +44,6 @@ public String toSourceCode(boolean kt) { return toSourceCode(this, kt, false); } - public String toJacksonTypeReference(boolean kt) { - if (isParameterizedType()) { - var buffer = new StringBuilder(); - if (!kt) { - buffer.append("new "); - } - buffer.append("tools.jackson.core.type.TypeReference<").append(type).append(">() {}"); - return buffer.toString(); - } else { - return CodeBlock.type(kt, getRawType().toString()) + clazz(kt); - } - } - public String getArgumentsString(boolean kt, boolean convertTypeVar, Set kinds) { List arguments = getArguments(); var filtered = diff --git a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java b/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java deleted file mode 100644 index 21f5144c67..0000000000 --- a/modules/jooby-jackson3/src/test/java/io/jooby/jackson3/PrimitiveTest.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Jooby https://jooby.io - * Apache License Version 2.0 https://jooby.io/LICENSE.txt - * Copyright 2014 Edgar Espina - */ -package io.jooby.jackson3; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.Test; - -import tools.jackson.databind.ObjectMapper; - -public class PrimitiveTest { - - @Test - public void shouldParsePrimitive() { - var mapper = new ObjectMapper(); - assertEquals(1, mapper.readValue("1", long.class)); - assertEquals(1, mapper.readValue("null", long.class)); - assertNull(mapper.readValue("null", Long.class)); - } -} diff --git a/tests/src/test/kotlin/i3863/KMovieService.kt b/tests/src/test/kotlin/i3863/KMovieService.kt new file mode 100644 index 0000000000..2f6de68d1f --- /dev/null +++ b/tests/src/test/kotlin/i3863/KMovieService.kt @@ -0,0 +1,71 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package i3863 + +import io.jooby.annotation.Trpc +import io.jooby.annotation.Trpc.Mutation +import io.jooby.i3863.Metadata +import io.jooby.i3863.Movie +import java.util.stream.Collectors + +@Trpc("movies") +class KMovieService { + private val database = + listOf(Movie(1, "The Godfather", 1972), Movie(2, "Pulp Fiction", 1994)) + + /** Procedure: movies.create Takes a single complex object. */ + @Mutation + fun create(movie: Movie?): Movie? { + // In a real app, save to DB. For now, just return it. + return movie + } + + @Mutation fun resetIndex() {} + + /** Procedure: movies.bulkCreate Takes a List of complex objects. */ + @Trpc.Query + fun bulkCreate(movies: List): List { + return movies + .stream() + .map { m: Movie -> "Created: " + m.title } + .collect(Collectors.toList()) + } + + /** Procedure: movies.ping */ + @Trpc.Query + fun ping(): String { + return "pong" + } + + /** Procedure: movies.getById Single primitive argument */ + @Trpc.Query + fun getById(id: Int): Movie? { + return database.stream().filter { m: Movie? -> m!!.id == id }.findFirst().orElse(null) + } + + /** Procedure: movies.search Multi-argument (Tuple) */ + @Trpc.Query + fun search(title: String, year: Int?): List { + return database + .stream() + .filter { m: Movie? -> m!!.title.contains(title) && (year == null || m.year == year) } + .toList() + } + + /** Procedure: movies.addReview Mix of String and int (Mutation) */ + @Mutation + fun addReview(movieTitle: String, stars: Int, comment: String?): Map { + // Business logic... + return mapOf("title" to movieTitle, "rating" to stars, "status" to "published") + } + + /** Procedure: movies.addReview Mix of String and int (Mutation) */ + @Mutation + fun updateMetadata(id: Int, metadata: Metadata?): Metadata? { + // Business logic... + return metadata + } +} From c96182e2aa689fa015d4b5627f3d498e7b7fddf7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 6 Mar 2026 15:45:45 -0300 Subject: [PATCH 09/16] - refactor/code cleanup merge trpc inside normal mapping --- .../java/io/jooby/internal/apt/HttpPath.java | 10 - .../io/jooby/internal/apt/MvcContext.java | 20 - .../java/io/jooby/internal/apt/MvcRoute.java | 820 +++++++++--------- .../java/io/jooby/internal/apt/MvcRouter.java | 2 +- 4 files changed, 407 insertions(+), 445 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java index 696250aef2..1be305048a 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpPath.java @@ -35,16 +35,6 @@ public List path(Collection hierarchy) { return path(hierarchy, getAnnotations()); } - /** - * Find path on type hierarchy. It goes back at hierarchy until it finds a Path annotation. - * - * @param hierarchy Type hierarchy. - * @return Path or empty list. - */ - public List trpcPath(Collection hierarchy) { - return path(hierarchy, List.of("io.jooby.annotation.Trpc")); - } - private List path(Collection hierarchy, List annotations) { var prefix = Collections.emptyList(); var it = hierarchy.iterator(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java index 99ab9443ab..6a4a441a9c 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java @@ -87,26 +87,6 @@ public ProcessingEnvironment getProcessingEnvironment() { return processingEnvironment; } - /** - * Find path from trpc route method and router type. This method scan and expand path base on the - * annotation present at method or class level. - * - * @param owner Router type. - * @param exec Method. - * @param procedure Child path. - * @return List of possible paths. - */ - public List trpcPath(TypeElement owner, ExecutableElement exec, String procedure) { - var prefix = HttpPath.PATH.trpcPath(superTypes(owner)); - if (prefix.isEmpty()) { - return procedure.isEmpty() ? Collections.singletonList("/") : List.of(procedure); - } - return prefix.stream() - .map(root -> root.equals("/") ? procedure : root + procedure) - .distinct() - .toList(); - } - /** * Find path from route method and router type. This method scan and expand path base on the * annotation present at method or class level. 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 2462f640e4..944c8d992b 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 @@ -32,8 +32,9 @@ public class MvcRoute { private final boolean suspendFun; private boolean uncheckedCast; private final boolean hasBeanValidation; - private final List trpcMethods = new ArrayList<>(); private final Set pending = new HashSet<>(); + private boolean isTrpc = false; + private HttpMethod resolvedTrpcMethod = null; public MvcRoute(MvcContext context, MvcRouter router, ExecutableElement method) { this.context = context; @@ -75,7 +76,6 @@ public String getProjection() { .findFirst() .orElse(null); } - // look inside the method annotation var httpMethod = annotationMap.values().iterator().next(); var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); return projection.stream().findFirst().orElse(null); @@ -89,7 +89,6 @@ public boolean isProjection() { if (isProjection) { return true; } - // look inside the method annotation var httpMethod = annotationMap.values().iterator().next(); var projection = AnnotationSupport.findAnnotationValue(httpMethod, "projection"::equals); return !projection.isEmpty(); @@ -141,6 +140,7 @@ public List generateMapping(boolean kt) { + context.generateRouterName(router.getTargetType().getSimpleName().toString()) + "::" : "this::"; + for (var e : entries) { var lastHttpMethod = lastRoute && entries.get(entries.size() - 1).equals(e); var annotation = e.getKey(); @@ -148,23 +148,25 @@ public List generateMapping(boolean kt) { var dslMethod = annotation.getSimpleName().toString().toLowerCase(); var paths = context.path(router.getTargetType(), method, annotation); var targetMethod = methodName; + if (httpMethod == HttpMethod.tRPC) { - httpMethod = trpcMethod(method); - if (httpMethod == null) { + resolvedTrpcMethod = trpcMethod(method); + if (resolvedTrpcMethod == null) { throw new IllegalArgumentException( "tRPC method not found: " + method.getSimpleName() + "() in " + router.getTargetType()); } - dslMethod = httpMethod.name().toLowerCase(); + dslMethod = resolvedTrpcMethod.name().toLowerCase(); paths = List.of(trpcPath(method)); targetMethod = "trpc" + targetMethod.substring(0, 1).toUpperCase() + targetMethod.substring(1); - trpcMethods.add(new TrpcMethod(httpMethod, targetMethod)); - } else { - pending.add(methodName); + this.isTrpc = true; } + + pending.add(targetMethod); + for (var path : paths) { var lastLine = lastHttpMethod && paths.getLast().equals(path); block.add(javadocLink); @@ -180,25 +182,20 @@ public List generateMapping(boolean kt) { if (context.nonBlocking(getReturnTypeHandler()) || isSuspendFun()) { block.add(statement(indent(2), ".setNonBlocking(true)")); } - /* consumes */ mediaType(httpMethod::consumes) .ifPresent(consumes -> block.add(statement(indent(2), ".setConsumes(", consumes, ")"))); - /* produces */ mediaType(httpMethod::produces) .ifPresent(produces -> block.add(statement(indent(2), ".setProduces(", produces, ")"))); - /* dispatch */ dispatch() .ifPresent( dispatch -> block.add(statement(indent(2), ".setExecutorKey(", string(dispatch), ")"))); - /* attributes */ attributeGenerator .toSourceCode(kt, this, 2) .ifPresent( attributes -> block.add(statement(indent(2), ".setAttributes(", attributes, ")"))); var lineSep = lastLine ? lineSeparator() : lineSeparator() + lineSeparator(); if (context.generateMvcMethod()) { - /* mvcMethod */ block.add( CodeBlock.of( indent(2), @@ -240,12 +237,6 @@ private String methodReference(boolean kt, String thisRef, String methodName) { return thisRef + methodName + ")"; } - /** - * Ensure path start with a /(leading slash). - * - * @param path Path to process. - * @return Path with leading slash. - */ static String leadingSlash(String path) { if (path == null || path.isEmpty() || path.equals("/")) { return "/"; @@ -255,46 +246,143 @@ static String leadingSlash(String path) { public List generateHandlerCall(boolean kt) { var buffer = new ArrayList(); - var methodName = getGeneratedName(); + var methodName = + isTrpc + ? "trpc" + + getGeneratedName().substring(0, 1).toUpperCase() + + getGeneratedName().substring(1) + : getGeneratedName(); + if (pending.contains(methodName)) { - /* Parameters */ var paramList = new StringJoiner(", ", "(", ")"); - for (var parameter : getParameters(true)) { - String generatedParameter = parameter.generateMapping(kt); - if (parameter.isRequireBeanValidation()) { - generatedParameter = - CodeBlock.of( - "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); - } - - paramList.add(generatedParameter); - } var returnTypeGenerics = getReturnType().getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); var returnTypeString = type(kt, getReturnType().toString()); var customReturnType = getReturnType(); + if (customReturnType.isProjection()) { - // Override for projection returnTypeGenerics = ""; returnTypeString = Types.PROJECTED + "<" + returnType + ">"; } + // Add the TrpcResponse generic wrapper for the generated method return type + var methodReturnTypeString = returnTypeString; + if (isTrpc) { + methodReturnTypeString = + "io.jooby.trpc.TrpcResponse<" + + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString) + + ">"; + } + var nullable = - methodCallHeader(kt, "ctx", methodName, buffer, returnTypeGenerics, returnTypeString); - if (returnType.isVoid()) { - String statusCode; - if (annotationMap.size() == 1) { - statusCode = - annotationMap.keySet().iterator().next().getSimpleName().toString().equals("DELETE") - ? "NO_CONTENT" - : "OK"; + methodCallHeader( + kt, + "ctx", + methodName, + buffer, + returnTypeGenerics, + methodReturnTypeString, + isTrpc || !method.getThrownTypes().isEmpty()); + + int controllerIndent = 2; + + if (isTrpc && !parameters.isEmpty()) { + controllerIndent = 4; + buffer.add( + statement( + indent(2), + var(kt), + "parser = ctx.require(io.jooby.trpc.TrpcParser", + clazz(kt), + ")", + semicolon(kt))); + + if (resolvedTrpcMethod == HttpMethod.GET) { + buffer.add( + statement( + indent(2), + var(kt), + "input = ctx.query(", + string("input"), + ").value()", + semicolon(kt))); + if (kt) { + buffer.add( + statement( + indent(2), + "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)" + + " throw IllegalArgumentException(", + string("tRPC input must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" + + " input.charAt(input.length() - 1) != ']') throw new" + + " IllegalArgumentException(", + string("tRPC input must be a JSON array (tuple)"), + ");")); + } } else { - statusCode = null; + buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); + if (kt) { + buffer.add( + statement( + indent(2), + "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]" + + " != ']'.code.toByte()) throw IllegalArgumentException(", + string("tRPC body must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')" + + " throw new IllegalArgumentException(", + string("tRPC body must be a JSON array (tuple)"), + ");")); + } } - if (statusCode != null) { + + if (kt) { + buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); + } else { + buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); + } + + buffer.addAll(generateTrpcParameter(kt, paramList::add)); + } else if (!isTrpc) { + for (var parameter : getParameters(true)) { + String generatedParameter = parameter.generateMapping(kt); + if (parameter.isRequireBeanValidation()) { + generatedParameter = + CodeBlock.of( + "io.jooby.validation.BeanValidator.apply(", "ctx, ", generatedParameter, ")"); + } + paramList.add(generatedParameter); + } + } + + controllerVar(kt, buffer, controllerIndent); + + if (returnType.isVoid()) { + String statusCode = + annotationMap.size() == 1 + && annotationMap + .keySet() + .iterator() + .next() + .getSimpleName() + .toString() + .equals("DELETE") + ? "NO_CONTENT" + : "OK"; + + if (annotationMap.size() == 1) { buffer.add( statement( - indent(2), + indent(controllerIndent), "ctx.setResponseCode(io.jooby.StatusCode.", statusCode, ")", @@ -303,66 +391,135 @@ public List generateHandlerCall(boolean kt) { if (kt) { buffer.add( statement( - indent(2), + indent(controllerIndent), "ctx.setResponseCode(if (ctx.getRoute().getMethod().equals(", string("DELETE"), ")) io.jooby.StatusCode.NO_CONTENT else io.jooby.StatusCode.OK)")); } else { buffer.add( statement( - indent(2), + indent(controllerIndent), "ctx.setResponseCode(ctx.getRoute().getMethod().equals(", string("DELETE"), ") ? io.jooby.StatusCode.NO_CONTENT: io.jooby.StatusCode.OK)", semicolon(false))); } } - controllerVar(kt, buffer); + buffer.add( statement( - indent(2), "c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt))); - buffer.add(statement(indent(2), "return ctx.getResponseCode()", semicolon(kt))); + indent(controllerIndent), + "c.", + this.method.getSimpleName(), + paramList.toString(), + semicolon(kt))); + + if (isTrpc) { + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.trpc.TrpcResponse.empty()", + semicolon(kt))); + } else { + buffer.add( + statement(indent(controllerIndent), "return ctx.getResponseCode()", semicolon(kt))); + } } else if (returnType.is("io.jooby.StatusCode")) { - controllerVar(kt, buffer); buffer.add( statement( - indent(2), + indent(controllerIndent), kt ? "val" : "var", " statusCode = c.", this.method.getSimpleName(), paramList.toString(), semicolon(kt))); - buffer.add(statement(indent(2), "ctx.setResponseCode(statusCode)", semicolon(kt))); - buffer.add(statement(indent(2), "return statusCode", semicolon(kt))); + buffer.add( + statement(indent(controllerIndent), "ctx.setResponseCode(statusCode)", semicolon(kt))); + + if (isTrpc) { + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.trpc.TrpcResponse.of(statusCode)", + semicolon(kt))); + } else { + buffer.add(statement(indent(controllerIndent), "return statusCode", semicolon(kt))); + } } else { - controllerVar(kt, buffer); - var cast = + var castStr = customReturnType.isProjection() ? "" : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - var kotlinNotEnoughTypeInformation = !cast.isEmpty() && kt ? "" : ""; + + // Ensure projections are ignored by the kotlin hardcast, as they handle their own wrapper + var needsCast = + !castStr.isEmpty() + || (kt + && !customReturnType.isProjection() + && !customReturnType.getArguments().isEmpty()); + + var kotlinNotEnoughTypeInformation = !castStr.isEmpty() && kt ? "" : ""; var call = of( "c.", this.method.getSimpleName(), kotlinNotEnoughTypeInformation, paramList.toString()); - if (!cast.isEmpty()) { + + if (needsCast) { setUncheckedCast(true); call = kt ? call + " as " + returnTypeString : "(" + returnTypeString + ") " + call; } + if (customReturnType.isProjection()) { var projected = of(Types.PROJECTED, ".wrap(", call, ").include(", string(getProjection()), ")"); - buffer.add( - statement( - indent(2), "return ", projected, kt && nullable ? "!!" : "", semicolon(kt))); + if (isTrpc) { + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.trpc.TrpcResponse.of(", + projected, + kt && nullable ? "!!" : "", + ")", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + projected, + kt && nullable ? "!!" : "", + semicolon(kt))); + } } else { - buffer.add( - statement(indent(2), "return ", call, kt && nullable ? "!!" : "", semicolon(kt))); + if (isTrpc) { + buffer.add( + statement( + indent(controllerIndent), + "return io.jooby.trpc.TrpcResponse.of(", + call, + kt && nullable ? "!!" : "", + ")", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + kt && nullable ? "!!" : "", + semicolon(kt))); + } } } + + if (isTrpc && !parameters.isEmpty()) { + buffer.add(statement(indent(2), "}")); + } + buffer.add(statement("}", System.lineSeparator())); + if (uncheckedCast) { if (kt) { buffer.addFirst(statement("@Suppress(\"UNCHECKED_CAST\")")); @@ -371,29 +528,9 @@ public List generateHandlerCall(boolean kt) { } } } - for (var trpcMethod : trpcMethods) { - buffer.addAll(generateTrpcMethod(kt, trpcMethod)); - } return buffer; } - private boolean methodCallHeader( - boolean kt, - String contextVarname, - String methodName, - ArrayList buffer, - String returnTypeGenerics, - String returnTypeString) { - return methodCallHeader( - kt, - contextVarname, - methodName, - buffer, - returnTypeGenerics, - returnTypeString, - !method.getThrownTypes().isEmpty()); - } - private boolean methodCallHeader( boolean kt, String contextVarname, @@ -451,145 +588,17 @@ private boolean methodCallHeader( return nullable; } - private List generateTrpcMethod(boolean kt, TrpcMethod trpcMethod) { - var buffer = new ArrayList(); - - var returnTypeString = - "io.jooby.trpc.TrpcResponse<" - + (returnType.isVoid() ? (kt ? "Unit" : "Void") : type(kt, returnType.toString())) - + ">"; - - var nullable = methodCallHeader(kt, "ctx", trpcMethod.name, buffer, "", returnTypeString, true); - var hasArgs = !parameters.isEmpty(); - if (hasArgs) { - buffer.add( - statement( - indent(2), - var(kt), - "parser = ctx.require(io.jooby.trpc.TrpcParser", - clazz(kt), - ")", - semicolon(kt))); - if (trpcMethod.method == HttpMethod.GET) { - buffer.add( - statement( - indent(2), - var(kt), - "input = ctx.query(", - string("input"), - ").", - "value", - "()", - semicolon(kt))); - if (kt) { - buffer.add( - statement( - indent(2), - "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true) throw" - + " IllegalArgumentException(", - string("tRPC input must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" - + " input.charAt(input.length() - 1) != ']') throw new" - + " IllegalArgumentException(", - string("tRPC input must be a JSON array (tuple)"), - ");")); - } - } else { - buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); - if (kt) { - buffer.add( - statement( - indent(2), - "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1] !=" - + " ']'.code.toByte()) throw IllegalArgumentException(", - string("tRPC body must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']') throw" - + " new IllegalArgumentException(", - string("tRPC body must be a JSON array (tuple)"), - ");")); - } - } - } - var isVoid = returnType.isVoid() || returnType.is("kotlin.Unit"); - - var arguments = new ArrayList(); - var argumentStatements = generateTrpcParameter(kt, arguments::add); - int indent = 2; - if (hasArgs) { - indent = 4; - if (kt) { - buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); - } else { - buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); - } - buffer.addAll(argumentStatements); - } - controllerVar(kt, buffer, indent); - var call = - of( - "c.", - this.method.getSimpleName(), - "", - arguments.stream().collect(Collectors.joining(", ", "(", ")"))); - if (isVoid) { - buffer.add(statement(indent(indent), call, semicolon(kt))); - buffer.add( - statement( - indent(indent), "return ", "io.jooby.trpc.TrpcResponse.empty()", semicolon(kt))); - } else { - if (kt) { - var cast = !returnType.getArguments().isEmpty(); - buffer.add( - statement( - indent(indent), - "val result = ", - call, - cast ? " as " + type(true, returnType.toString()) : "")); - buffer.add( - statement( - indent(indent), - "return ", - "io.jooby.trpc.TrpcResponse.of(result", - nullable ? "!!" : "", - ")")); - } else { - buffer.add(statement(indent(indent), "var result = ", call, ";")); - buffer.add(statement(indent(indent), "return ", "io.jooby.trpc.TrpcResponse.of(result);")); - } - } - if (hasArgs) { - buffer.add(statement(indent(2), "}")); - } - - buffer.add(statement("}", System.lineSeparator())); - return buffer; - } - private List generateTrpcParameter(boolean kt, Consumer arguments) { var statements = new ArrayList(); for (var parameter : parameters) { var paramenterName = parameter.getName(); var type = type(kt, parameter.getType().toString()); - boolean isNullable = parameter.isNullable(kt); switch (parameter.getType().getRawType().toString()) { case "io.jooby.Context": - { - arguments.accept("ctx"); - } + arguments.accept("ctx"); break; - case "int", "long", "double", @@ -599,59 +608,55 @@ private List generateTrpcParameter(boolean kt, Consumer argument "java.lang.Long", "java.lang.Double", "java.lang.Boolean": - { - var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; - if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; - var readName = - "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); + var simpleType = type.startsWith("java.lang.") ? type.substring(10) : type; + if (simpleType.equals("Integer") || simpleType.equals("int")) simpleType = "Int"; + var readName = + "next" + Character.toUpperCase(simpleType.charAt(0)) + simpleType.substring(1); - if (isNullable) { - if (kt) { - statements.add( - statement( - indent(4), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.", - readName, - "(", - string(paramenterName), - ")")); - } else { - statements.add( - statement( - indent(4), - var(kt), - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : reader.", - readName, - "(", - string(paramenterName), - ")", - semicolon(kt))); - } + if (isNullable) { + if (kt) { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readName, + "(", + string(paramenterName), + ")")); } else { - // Non-Nullable: Direct call statements.add( statement( indent(4), var(kt), paramenterName, - " = reader.", + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.", readName, "(", string(paramenterName), ")", semicolon(kt))); } - arguments.accept(paramenterName); + } else { + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readName, + "(", + string(paramenterName), + ")", + semicolon(kt))); } + arguments.accept(paramenterName); break; - case "byte", "short", "float", @@ -660,183 +665,178 @@ private List generateTrpcParameter(boolean kt, Consumer argument "java.lang.Short", "java.lang.Float", "java.lang.Character": - { - var isChar = type.equals("char") || type.equals("java.lang.Character"); - var isFloat = type.equals("float") || type.equals("java.lang.Float"); - var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt"); + var isChar = type.equals("char") || type.equals("java.lang.Character"); + var isFloat = type.equals("float") || type.equals("java.lang.Float"); + var readMethod = isFloat ? "nextDouble" : (isChar ? "nextString" : "nextInt"); - if (isNullable) { - if (kt) { - var ktCast = - isChar - ? "?.get(0)" - : "?.to" - + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) - + type.replace("java.lang.", "").substring(1) - + "()"; - statements.add( - statement( - indent(4), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.", - readMethod, - "(", - string(paramenterName), - ")", - ktCast)); - } else { - var targetType = type.replace("java.lang.", ""); - var javaPrefix = isChar ? "" : "(" + targetType + ") "; - var javaSuffix = isChar ? ".charAt(0)" : ""; - statements.add( - statement( - indent(4), - var(kt), - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : ", - javaPrefix, - "reader.", - readMethod, - "(", - string(paramenterName), - ")", - javaSuffix, - semicolon(kt))); - } + if (isNullable) { + if (kt) { + var ktCast = + isChar + ? "?.get(0)" + : "?.to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast)); } else { - if (kt) { - var ktCast = - isChar - ? "[0]" - : ".to" - + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) - + type.replace("java.lang.", "").substring(1) - + "()"; - statements.add( - statement( - indent(4), - var(kt), - paramenterName, - " = reader.", - readMethod, - "(", - string(paramenterName), - ")", - ktCast, - semicolon(kt))); - } else { - var targetType = type.replace("java.lang.", ""); - var javaPrefix = isChar ? "" : "(" + targetType + ") "; - var javaSuffix = isChar ? ".charAt(0)" : ""; - statements.add( - statement( - indent(4), - var(kt), - paramenterName, - " = ", - javaPrefix, - "reader.", - readMethod, - "(", - string(paramenterName), - ")", - javaSuffix, - semicolon(kt))); - } + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); + } + } else { + if (kt) { + var ktCast = + isChar + ? "[0]" + : ".to" + + Character.toUpperCase(type.replace("java.lang.", "").charAt(0)) + + type.replace("java.lang.", "").substring(1) + + "()"; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = reader.", + readMethod, + "(", + string(paramenterName), + ")", + ktCast, + semicolon(kt))); + } else { + var targetType = type.replace("java.lang.", ""); + var javaPrefix = isChar ? "" : "(" + targetType + ") "; + var javaSuffix = isChar ? ".charAt(0)" : ""; + statements.add( + statement( + indent(4), + var(kt), + paramenterName, + " = ", + javaPrefix, + "reader.", + readMethod, + "(", + string(paramenterName), + ")", + javaSuffix, + semicolon(kt))); } - arguments.accept(paramenterName); } + arguments.accept(paramenterName); break; - default: - { - if (kt) { + if (kt) { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + "Decoder: io.jooby.trpc.TrpcDecoder<", + type, + "> = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { statements.add( statement( indent(4), "val ", paramenterName, - "Decoder: io.jooby.trpc.TrpcDecoder<", - type, - "> = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")", + " = if (reader.nextIsNull(", + string(paramenterName), + ")) null else reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)")); + } else { + statements.add( + statement( + indent(4), + "val ", + paramenterName, + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", + semicolon(kt))); + } + } else { + statements.add( + statement( + indent(4), + "io.jooby.trpc.TrpcDecoder<", + type, + "> ", + paramenterName, + "Decoder = parser.decoder(", + parameter.getType().toSourceCode(kt), + ")", + semicolon(kt))); + if (isNullable) { + statements.add( + statement( + indent(4), + parameter.getType().toString(), + " ", + paramenterName, + " = reader.nextIsNull(", + string(paramenterName), + ") ? null : reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", semicolon(kt))); - if (isNullable) { - statements.add( - statement( - indent(4), - "val ", - paramenterName, - " = if (reader.nextIsNull(", - string(paramenterName), - ")) null else reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)")); - } else { - statements.add( - statement( - indent(4), - "val ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } } else { statements.add( statement( indent(4), - "io.jooby.trpc.TrpcDecoder<", - type, - "> ", + parameter.getType().toString(), + " ", paramenterName, - "Decoder = parser.decoder(", - parameter.getType().toSourceCode(kt), - ")", + " = reader.nextObject(", + string(paramenterName), + ", ", + paramenterName, + "Decoder)", semicolon(kt))); - if (isNullable) { - statements.add( - statement( - indent(4), - parameter.getType().toString(), - " ", - paramenterName, - " = reader.nextIsNull(", - string(paramenterName), - ") ? null : reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } else { - statements.add( - statement( - indent(4), - parameter.getType().toString(), - " ", - paramenterName, - " = reader.nextObject(", - string(paramenterName), - ", ", - paramenterName, - "Decoder)", - semicolon(kt))); - } } - arguments.accept(paramenterName); } + arguments.accept(paramenterName); break; } } @@ -895,7 +895,6 @@ public List getJavaMethodSignature(boolean kt) { .map( it -> { var type = it.getType(); - // Kotlin requires his own types for primitives if (kt && type.isPrimitive()) { return type(kt, type.getRawType().toString()); } @@ -970,11 +969,6 @@ private Optional mediaType(Function> lookup) { .collect(Collectors.joining(", ", "java.util.List.of(", ")"))); } - /** - * Kotlin suspend function has a kotlin.coroutines.Continuation as last parameter. - * - * @return True for Kotlin suspend function. - */ public boolean isSuspendFun() { return suspendFun; } @@ -1046,6 +1040,4 @@ public String trpcPath(Element element) { .map(segment -> segment.startsWith("/") ? segment.substring(1) : segment) .collect(Collectors.joining("/", "/", "")); } - - record TrpcMethod(HttpMethod method, String name) {} } diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java index 5a2005dd60..cf9cd4a2c1 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 @@ -100,7 +100,7 @@ public String getPackageName() { * *
{@code
    * public class Controller_ implements MvcExtension {
-   *     ....
+   * ....
    * }
    *
    * }
From 1a8a492c28d05bf4953a9f05bfb81f7322a8e70b Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 6 Mar 2026 18:50:18 -0300 Subject: [PATCH 10/16] - support reactive responses on `tRPC` - more code cleanup --- .../io/jooby/internal/apt/MvcContext.java | 4 + .../java/io/jooby/internal/apt/MvcRoute.java | 90 +++++++++++++++++-- .../io/jooby/internal/apt/ReactiveType.java | 26 ++++-- .../java/io/jooby/i3863/MovieService.java | 8 ++ .../java/io/jooby/i3863/TrpcProtocolTest.java | 15 ++++ 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java index 6a4a441a9c..3a19fd61f9 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcContext.java @@ -127,6 +127,10 @@ public boolean nonBlocking(TypeMirror returnType) { return entry != null; } + public ReactiveType getReactiveType(TypeMirror type) { + return findMappingHandler(type); + } + private ReactiveType findMappingHandler(TypeMirror type) { for (var e : reactiveTypeMap.entrySet()) { var that = e.getKey(); diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java index 944c8d992b..3578edaf58 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 @@ -265,13 +265,33 @@ public List generateHandlerCall(boolean kt) { returnTypeString = Types.PROJECTED + "<" + returnType + ">"; } - // Add the TrpcResponse generic wrapper for the generated method return type + var reactive = isTrpc ? context.getReactiveType(returnType.getRawType()) : null; + var isReactiveVoid = false; + var innerReactiveType = "Object"; + + // 1. Resolve Target Signature var methodReturnTypeString = returnTypeString; if (isTrpc) { - methodReturnTypeString = - "io.jooby.trpc.TrpcResponse<" - + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString) - + ">"; + if (reactive != null) { + var rawReactiveType = type(kt, returnType.getRawType().toString()); + if (!returnType.getArguments().isEmpty()) { + innerReactiveType = type(kt, returnType.getArguments().get(0).getRawType().toString()); + if (innerReactiveType.equals("java.lang.Void") || innerReactiveType.equals("Void")) { + isReactiveVoid = true; + innerReactiveType = kt ? "Unit" : "Void"; + } + } else if (rawReactiveType.contains("Completable")) { + isReactiveVoid = true; + innerReactiveType = kt ? "Unit" : "Void"; + } + methodReturnTypeString = + rawReactiveType + ">"; + } else { + methodReturnTypeString = + "io.jooby.trpc.TrpcResponse<" + + (returnType.isVoid() ? (kt ? "Unit" : "Void") : returnTypeString) + + ">"; + } } var nullable = @@ -366,6 +386,7 @@ public List generateHandlerCall(boolean kt) { controllerVar(kt, buffer, controllerIndent); + // 2. Resolve Return Flow if (returnType.isVoid()) { String statusCode = annotationMap.size() == 1 @@ -451,7 +472,6 @@ public List generateHandlerCall(boolean kt) { ? "" : customReturnType.getArgumentsString(kt, false, Set.of(TypeKind.TYPEVAR)); - // Ensure projections are ignored by the kotlin hardcast, as they handle their own wrapper var needsCast = !castStr.isEmpty() || (kt @@ -480,7 +500,6 @@ public List generateHandlerCall(boolean kt) { indent(controllerIndent), "return io.jooby.trpc.TrpcResponse.of(", projected, - kt && nullable ? "!!" : "", ")", semicolon(kt))); } else { @@ -493,7 +512,62 @@ public List generateHandlerCall(boolean kt) { semicolon(kt))); } } else { - if (isTrpc) { + + if (isTrpc && reactive != null) { + if (isReactiveVoid) { + // Ensure empty void streams systematically resolve into an empty TrpcResponse + var handler = reactive.handlerType(); + if (handler.contains("Reactor")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".then(reactor.core.publisher.Mono.just(io.jooby.trpc.TrpcResponse.empty()))", + semicolon(kt))); + } else if (handler.contains("Mutiny")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".replaceWith(io.jooby.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else if (handler.contains("ReactiveSupport")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".thenApply(x -> io.jooby.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else if (handler.contains("Reactivex")) { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".toSingleDefault(io.jooby.trpc.TrpcResponse.empty())", + semicolon(kt))); + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + ".map(x -> io.jooby.trpc.TrpcResponse.empty())", + semicolon(kt))); + } + } else { + buffer.add( + statement( + indent(controllerIndent), + "return ", + call, + reactive.mapOperator(), + semicolon(kt))); + } + } else if (isTrpc) { buffer.add( statement( indent(controllerIndent), diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java index e30366b2fc..6bf2ba4c89 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/ReactiveType.java @@ -13,11 +13,14 @@ public class ReactiveType { private final String handlerType; private final String handler; private final Set reactiveTypes; + private final String mapOperator; - private ReactiveType(String handlerType, String handler, Set reactiveTypes) { + private ReactiveType( + String handlerType, String handler, Set reactiveTypes, String mapOperator) { this.handlerType = handlerType; this.handler = handler; this.reactiveTypes = reactiveTypes; + this.mapOperator = mapOperator; } public Set reactiveTypes() { @@ -32,27 +35,35 @@ public String handler() { return handler; } + public String mapOperator() { + return mapOperator; + } + public static List supportedTypes() { return List.of( new ReactiveType( "io.jooby.ReactiveSupport", "concurrent", - Set.of("java.util.concurrent.Flow", "java.util.concurrent.CompletionStage")), + Set.of("java.util.concurrent.Flow", "java.util.concurrent.CompletionStage"), + ".thenApply(io.jooby.trpc.TrpcResponse::of)"), // Vertx new ReactiveType( "io.jooby.vertx.VertxHandler", "vertx", - Set.of("io.vertx.core.Future", "io.vertx.core.Promise", "io.vertx.core.buffer.Buffer")), + Set.of("io.vertx.core.Future", "io.vertx.core.Promise", "io.vertx.core.buffer.Buffer"), + ".map(io.jooby.trpc.TrpcResponse::of)"), // Mutiny new ReactiveType( "io.jooby.mutiny.Mutiny", "mutiny", - Set.of("io.smallrye.mutiny.Uni", "io.smallrye.mutiny.Multi")), + Set.of("io.smallrye.mutiny.Uni", "io.smallrye.mutiny.Multi"), + ".map(io.jooby.trpc.TrpcResponse::of)"), // Reactor new ReactiveType( "io.jooby.reactor.Reactor", "reactor", - Set.of("reactor.core.publisher.Flux", "reactor.core.publisher.Mono")), + Set.of("reactor.core.publisher.Flux", "reactor.core.publisher.Mono"), + ".map(io.jooby.trpc.TrpcResponse::of)"), // Rxjava new ReactiveType( "io.jooby.rxjava3.Reactivex", @@ -60,8 +71,9 @@ public static List supportedTypes() { Set.of( "io.reactivex.rxjava3.core.Flowable", "io.reactivex.rxjava3.core.Maybe", - "io.reactivex.rxjava3.core.Observable", "io.reactivex.rxjava3.core.Single", - "io.reactivex.rxjava3.disposables.Disposable"))); + "io.reactivex.rxjava3.core.Observable", + "io.reactivex.rxjava3.core.Completable"), + ".map(io.jooby.trpc.TrpcResponse::of)")); } } diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java index 1aebba3a0e..faccc4418d 100644 --- a/tests/src/test/java/io/jooby/i3863/MovieService.java +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -10,6 +10,7 @@ import java.util.stream.Collectors; import io.jooby.annotation.Trpc; +import reactor.core.publisher.Mono; @Trpc("movies") public class MovieService { @@ -24,6 +25,13 @@ public Movie create(Movie movie) { return movie; } + /** Procedure: movies.create Takes a single complex object. */ + @Trpc.Mutation + public Mono createMono(Movie movie) { + // In a real app, save to DB. For now, just return it. + return Mono.just(movie); + } + @Trpc.Mutation public void resetIndex() {} diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java index 47c103635d..7c2b4fbd01 100644 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -101,6 +101,21 @@ void assertProtocolData(WebClient http) { assertThat(JsonPath.read(json, "$.result.data.year")).isEqualTo(1999); }); + // reactive + http.postJson( + "/trpc/movies.createMono", + "[{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}]", + rsp -> { + String json = rsp.body().string(); + + // 4. Validating the tRPC envelope and data using JsonPath + AssertJ + assertThat(rsp.code()).isEqualTo(200); + + assertThat(JsonPath.read(json, "$.result.data.id")).isEqualTo(1); + assertThat(JsonPath.read(json, "$.result.data.title")).isEqualTo("The Matrix"); + assertThat(JsonPath.read(json, "$.result.data.year")).isEqualTo(1999); + }); + http.post( "/trpc/movies.resetIndex", rsp -> { From 7fb4297c5306db4d1e817a86ef3dd7ca5bbcc612 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Fri, 6 Mar 2026 19:25:32 -0300 Subject: [PATCH 11/16] Refactor APT to unify tRPC generation and fully support reactive types This major refactor unifies the tRPC route generation with the standard MVC routing flow, eliminating code duplication and properly integrating tRPC endpoints with Jooby's native reactive pipeline. Key changes: * Unify Code Generation: Merged `generateTrpcMethod` into `generateHandlerCall` in `MvcRoute.java` to use a single, robust parameter extraction and method invocation flow. * Reactive Pipeline Support: Fixed an architectural issue where reactive types (CompletableFuture, Mono, Uni, etc.) were incorrectly wrapped in a synchronous TrpcResponse. The APT now generates `Publisher>`, injecting the proper `.map(TrpcResponse::of)` operator natively based on the library. * Hybrid Route Support: `MvcRouter.java` now correctly splits dual-purpose methods. If a method is annotated with both a standard HTTP method (e.g., `@GET`) and `@Trpc`, the processor generates two separate mappings and handlers (one traditional MVC, one strict tRPC). * tRPC Precedence & Validation: Enforced strict rules for tRPC annotations. `@Trpc.Query`/`@Trpc.Mutation` take precedence. A bare `@Trpc` annotation now requires an accompanying `@GET` or `@POST` annotation, otherwise it fails the build with a descriptive error. * Kotlin Codegen Fixes: Fixed Return type mismatches for generic parameterized types (adding `as Type` casts), fixed missing non-null assertions (`!!`), and ensured `Void`/`Unit` methods correctly emit `TrpcResponse.empty()`. * Cleanup: Removed dead `trpcPath` resolution methods from `MvcContext` and `HttpPath`, as well as the obsolete `TrpcMethod` record. --- .../java/io/jooby/internal/apt/MvcRoute.java | 37 ++++++++++++++----- .../java/io/jooby/internal/apt/MvcRouter.java | 6 ++- .../java/io/jooby/i3863/MovieService.java | 8 +++- 3 files changed, 38 insertions(+), 13 deletions(-) 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 3578edaf58..1b6a2e12ae 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 @@ -938,6 +938,12 @@ public MvcRoute addHttpMethod(TypeElement annotation) { ofNullable(findAnnotationByName(this.method, annotation.getQualifiedName().toString())) .orElseThrow(() -> new IllegalArgumentException("Annotation not found: " + annotation)); annotationMap.put(annotation, annotationMirror); + + // Eagerly flag as tRPC so equals/hashCode can differentiate hybrid methods early + if (HttpMethod.findByAnnotationName(annotation.getQualifiedName().toString()) + == HttpMethod.tRPC) { + this.isTrpc = true; + } return this; } @@ -984,13 +990,13 @@ public String getMethodName() { @Override public int hashCode() { - return method.toString().hashCode(); + return Objects.hash(method.toString(), isTrpc); } @Override public boolean equals(Object obj) { if (obj instanceof MvcRoute that) { - return this.method.toString().equals(that.method.toString()); + return this.method.toString().equals(that.method.toString()) && this.isTrpc == that.isTrpc; } return false; } @@ -1071,6 +1077,16 @@ 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; + } + if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation") + != null) { + return HttpMethod.POST; + } + + // 2. Base Precedence: @Trpc combined with standard HTTP annotations var trpc = AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc"); if (trpc != null) { if (HttpMethod.GET.matches(element)) { @@ -1079,14 +1095,15 @@ private HttpMethod trpcMethod(Element element) { if (HttpMethod.POST.matches(element)) { return HttpMethod.POST; } - return null; - } - if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Query") != null) { - return HttpMethod.GET; - } - if (AnnotationSupport.findAnnotationByName(element, "io.jooby.annotation.Trpc.Mutation") - != null) { - return HttpMethod.POST; + + // 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 @GET or @POST. Please annotate the method with" + + " @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET or @POST."); } 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 cf9cd4a2c1..ebbe396fe4 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRouter.java @@ -66,8 +66,12 @@ public String getGeneratedFilename() { } public MvcRouter put(TypeElement httpMethod, ExecutableElement route) { - var routeKey = route.toString(); + var isTrpc = + HttpMethod.findByAnnotationName(httpMethod.getQualifiedName().toString()) + == HttpMethod.tRPC; + var routeKey = (isTrpc ? "trpc" : "") + route.toString(); var existing = routes.get(routeKey); + if (existing == null) { routes.put(routeKey, new MvcRoute(context, this, route).addHttpMethod(httpMethod)); } else { diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java index faccc4418d..6e2a8a440e 100644 --- a/tests/src/test/java/io/jooby/i3863/MovieService.java +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -9,10 +9,11 @@ import java.util.Map; import java.util.stream.Collectors; -import io.jooby.annotation.Trpc; +import io.jooby.annotation.*; import reactor.core.publisher.Mono; @Trpc("movies") +@Path("/api/movies") public class MovieService { private final List database = @@ -20,6 +21,7 @@ public class MovieService { /** Procedure: movies.create Takes a single complex object. */ @Trpc.Mutation + @POST("/create") public Movie create(Movie movie) { // In a real app, save to DB. For now, just return it. return movie; @@ -33,6 +35,7 @@ public Mono createMono(Movie movie) { } @Trpc.Mutation + @PUT public void resetIndex() {} /** Procedure: movies.bulkCreate Takes a List of complex objects. */ @@ -49,7 +52,8 @@ public String ping() { /** Procedure: movies.getById Single primitive argument */ @Trpc.Query - public Movie getById(int id) { + @GET("/{id}") + public Movie getById(@PathParam int id) { return database.stream().filter(m -> m.id() == id).findFirst().orElse(null); } From ec14f042181a3ccbfa7c0d14802b1f32cb01e9d2 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 7 Mar 2026 10:46:21 -0300 Subject: [PATCH 12/16] Refactor tRPC protocol for seamless single-argument support This update removes the mandatory JSON array tuple wrapper for single-parameter tRPC procedures. Single arguments now map 1:1, creating a much more natural API for TypeScript clients while preserving the required tuple array for multi-argument methods. Key changes: * TrpcGenerator: Updated TypeScript generation to output raw types for single arguments (e.g., `input: Movie`) instead of wrapping them in tuples. Removed the `buildClassesDir` property to rely strictly on the `ClassLoader`, and aligned annotation precedence to match the APT rules. * APT / MvcRoute: The route generator now evaluates `isTuple` at compile-time (`parameters.size() > 1`) and passes this boolean directly into the `TrpcParser`, eliminating runtime ambiguity and hacky token detection. * JSON Readers: Updated `JacksonTrpcReader` and `AvajeTrpcReader` to accept the `isTuple` flag. The readers now cleanly branch between iterating over an array or reading a raw value directly from the root. * Testing: Overhauled `TrpcProtocolTest` to validate the new seamless protocol, covering raw objects, single-argument collections, multi-argument tuples, reactive payloads, and URL encoding compliance. --- .../main/java/io/jooby/trpc/TrpcParser.java | 6 +- .../java/io/jooby/internal/apt/MvcRoute.java | 89 ++++++---- .../internal/avaje/jsonb/AvajeTrpcParser.java | 8 +- .../internal/avaje/jsonb/AvajeTrpcReader.java | 49 +++--- .../internal/jackson3/JacksonTrpcParser.java | 8 +- .../internal/jackson3/JacksonTrpcReader.java | 30 ++-- .../java/io/jooby/trpc/TrpcGenerator.java | 126 +++++++------- .../jooby/trpc/i3863/TrpcGeneratorTest.java | 2 - .../java/io/jooby/i3863/MovieService.java | 11 +- .../java/io/jooby/i3863/TrpcProtocolTest.java | 164 ++++++++---------- .../test/java/io/jooby/test/WebClient.java | 22 ++- 11 files changed, 278 insertions(+), 237 deletions(-) diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java index 6352635fc1..b8e2e9d7ce 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java @@ -20,15 +20,17 @@ public interface TrpcParser { * Creates a sequential reader for parsing tRPC arguments from a JSON array. * * @param payload A JSON array containing the method arguments. + * @param isTuple If the payload is a tuple. * @return A reader for sequential argument extraction. */ - TrpcReader reader(byte[] payload); + TrpcReader reader(byte[] payload, boolean isTuple); /** * Creates a sequential reader for parsing tRPC arguments from a JSON array. * * @param payload A JSON array containing the method arguments. + * @param isTuple If the payload is a tuple. * @return A reader for sequential argument extraction. */ - TrpcReader reader(String payload); + TrpcReader reader(String payload, boolean isTuple); } 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 1b6a2e12ae..af21495e3b 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 @@ -326,49 +326,68 @@ public List generateHandlerCall(boolean kt) { string("input"), ").value()", semicolon(kt))); - if (kt) { - buffer.add( - statement( - indent(2), - "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)" - + " throw IllegalArgumentException(", - string("tRPC input must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" - + " input.charAt(input.length() - 1) != ']') throw new" - + " IllegalArgumentException(", - string("tRPC input must be a JSON array (tuple)"), - ");")); + + // Only enforce JSON array tuples if there are multiple arguments + if (parameters.size() > 1) { + if (kt) { + buffer.add( + statement( + indent(2), + "if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)" + + " throw IllegalArgumentException(", + string("tRPC input for multiple arguments must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input == null || input.length() < 2 || input.charAt(0) != '[' ||" + + " input.charAt(input.length() - 1) != ']') throw new" + + " IllegalArgumentException(", + string("tRPC input for multiple arguments must be a JSON array (tuple)"), + ");")); + } } } else { buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); - if (kt) { - buffer.add( - statement( - indent(2), - "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]" - + " != ']'.code.toByte()) throw IllegalArgumentException(", - string("tRPC body must be a JSON array (tuple)"), - ")")); - } else { - buffer.add( - statement( - indent(2), - "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')" - + " throw new IllegalArgumentException(", - string("tRPC body must be a JSON array (tuple)"), - ");")); + + // Only enforce JSON array tuples if there are multiple arguments + if (parameters.size() > 1) { + if (kt) { + buffer.add( + statement( + indent(2), + "if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]" + + " != ']'.code.toByte()) throw IllegalArgumentException(", + string("tRPC body for multiple arguments must be a JSON array (tuple)"), + ")")); + } else { + buffer.add( + statement( + indent(2), + "if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')" + + " throw new IllegalArgumentException(", + string("tRPC body for multiple arguments must be a JSON array (tuple)"), + ");")); + } } } + boolean isTuple = parameters.size() > 1; if (kt) { - buffer.add(statement(indent(2), "parser.reader(input).use { reader -> ")); + buffer.add( + statement( + indent(2), + "parser.reader(input, ", + String.valueOf(isTuple), + ").use { reader -> ")); } else { - buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {")); + buffer.add( + statement( + indent(2), + "try (var reader = parser.reader(input, ", + String.valueOf(isTuple), + ")) {")); } buffer.addAll(generateTrpcParameter(kt, paramList::add)); diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java index 93e58dbc9b..d7af242681 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java @@ -27,12 +27,12 @@ public TrpcDecoder decoder(Type type) { } @Override - public TrpcReader reader(byte[] payload) { - return new AvajeTrpcReader(jsonb.reader(payload)); + public TrpcReader reader(byte[] payload, boolean isTuple) { + return new AvajeTrpcReader(jsonb.reader(payload), isTuple); } @Override - public TrpcReader reader(String payload) { - return new AvajeTrpcReader(jsonb.reader(payload)); + public TrpcReader reader(String payload, boolean isTuple) { + return new AvajeTrpcReader(jsonb.reader(payload), isTuple); } } diff --git a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java index 7f856eca57..708ccc8f40 100644 --- a/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java +++ b/modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java @@ -13,44 +13,52 @@ public class AvajeTrpcReader implements TrpcReader { private final JsonReader reader; private boolean hasPeeked = false; + private final boolean isTuple; + private boolean isFirstRead = true; - public AvajeTrpcReader(JsonReader reader) { + public AvajeTrpcReader(JsonReader reader, boolean isTuple) { this.reader = reader; - reader.beginArray(); + this.isTuple = isTuple; + if (isTuple) { + reader.beginArray(); + } } @Override public boolean nextIsNull(String name) { if (!hasPeeked) { - if (!reader.hasNextElement()) { - throw new MissingValueException(name); - } - hasPeeked = true; // We successfully advanced the cursor to a value + ensureNextState(name); + hasPeeked = true; } if (reader.isNullValue()) { - // Avaje requires us to actively skip the null token to consume it reader.skipValue(); - hasPeeked = false; // Reset because the value is consumed + hasPeeked = false; return true; } - // It's not null. We leave hasPeeked = true so the next extraction method doesn't advance again. return false; } + private void ensureNextState(String name) { + if (isTuple) { + if (!reader.hasNextElement()) { + throw new MissingValueException(name); + } + } else { + if (!isFirstRead) { + throw new MissingValueException(name); + } + isFirstRead = false; + } + } + private void ensureNext(String name) { if (hasPeeked) { - // We already advanced the stream during nextIsNull(). - // Reset the flag since the caller is about to consume the value. hasPeeked = false; return; } - - // hasNextElement() checks for ']' and consumes the comma ',' if present. - if (!reader.hasNextElement()) { - throw new MissingValueException(name); - } + ensureNextState(name); } private void ensureNonNull(String name) { @@ -96,18 +104,15 @@ public String nextString(String name) { public T nextObject(String name, TrpcDecoder decoder) { ensureNext(name); ensureNonNull(name); - // Cast to access the underlying Avaje JsonType adapter AvajeTrpcDecoder avajeDecoder = (AvajeTrpcDecoder) decoder; - - // JsonType.fromJson(JsonReader) consumes exactly the tokens needed - // for the object, leaving the stream in the correct position. return avajeDecoder.typeAdapter.fromJson(reader); } @Override public void close() { - // Consume the closing ']' and close the underlying stream - reader.endArray(); + if (isTuple) { + reader.endArray(); + } reader.close(); } } diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java index 58b2792174..f6e8f90da0 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java @@ -32,12 +32,12 @@ public TrpcDecoder decoder(Type type) { } @Override - public TrpcReader reader(byte[] payload) { - return new JacksonTrpcReader(mapper.createParser(payload)); + public TrpcReader reader(byte[] payload, boolean isTuple) { + return new JacksonTrpcReader(mapper.createParser(payload), isTuple); } @Override - public TrpcReader reader(String payload) { - return new JacksonTrpcReader(mapper.createParser(payload)); + public TrpcReader reader(String payload, boolean isTuple) { + return new JacksonTrpcReader(mapper.createParser(payload), isTuple); } } diff --git a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java index e66cd3cc73..5f19573242 100644 --- a/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java +++ b/modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java @@ -14,10 +14,16 @@ public class JacksonTrpcReader implements TrpcReader { private final JsonParser parser; private boolean hasPeeked = false; + private final boolean isTuple; + private boolean isFirstRead = true; - public JacksonTrpcReader(JsonParser parser) { + public JacksonTrpcReader(JsonParser parser, boolean isTuple) { this.parser = parser; - parser.nextToken(); + this.isTuple = isTuple; + var token = parser.nextToken(); + if (isTuple && token != tools.jackson.core.JsonToken.START_ARRAY) { + throw new IllegalArgumentException("Expected tRPC tuple array"); + } } @Override @@ -32,18 +38,14 @@ public boolean nextIsNull(String name) { return true; } - // It's not null. We leave hasPeeked = true so extraction doesn't advance again. return false; } private void ensureNext(String name) { if (hasPeeked) { - // We already advanced the stream during nextIsNull(). - // Reset the flag since the caller is about to consume the value. hasPeeked = false; return; } - advance(name); } @@ -52,8 +54,16 @@ private void ensureNonNull(String name) { } private void advance(String name) { - JsonToken token = parser.nextToken(); - if (token == JsonToken.END_ARRAY || token == null) { + // If it's a seamless raw value, we are ALREADY on the token. Do not advance. + if (!isTuple) { + if (!isFirstRead) throw new MissingValueException(name); + isFirstRead = false; + // The constructor already positioned us on the root token. Do not advance. + return; + } + + var token = parser.nextToken(); + if (token == tools.jackson.core.JsonToken.END_ARRAY || token == null) { throw new MissingValueException(name); } } @@ -97,10 +107,6 @@ public String nextString(String name) { public T nextObject(String name, TrpcDecoder decoder) { ensureNext(name); ensureNonNull(name); - - // Cast back to our specific implementation to access the underlying ObjectReader. - // This allows us to read complex objects directly from the current position - // in the stream without any intermediate byte[] buffering or allocation. JacksonTrpcDecoder jacksonDecoder = (JacksonTrpcDecoder) decoder; return jacksonDecoder.reader.readValue(parser); } diff --git a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java index 8120a22f66..ec16011b4a 100644 --- a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java +++ b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.nio.file.Files; @@ -41,8 +42,7 @@ *

This tool bypasses the standard REST scanner of {@code typescript-generator}. Instead, it: * *

    - *
  1. Scans the compiled class directory (or explicitly added classes) for controllers marked - * with {@code @Trpc}. + *
  2. Scans the classpath via the provided ClassLoader for controllers marked with {@code @Trpc}. *
  3. Extracts only the input and return types (DTOs) of the matching methods. *
  4. Feeds those data models to the generator to produce clean TypeScript interfaces. *
  5. Uses a fast, recursive type resolver to accurately map Java methods to tRPC {@code { input, @@ -54,8 +54,7 @@ public class TrpcGenerator { private final Logger log = LoggerFactory.getLogger(getClass()); - private Path buildClassesDir; - private ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + private ClassLoader classLoader = getClass().getClassLoader(); private Path outputDir; private String outputFile = "trpc.d.ts"; private boolean expandLookup = false; @@ -92,7 +91,7 @@ public void generate() throws IOException { if (controllers.isEmpty()) { throw new IllegalStateException( "No controllers were found to generate. " - + "Ensure 'buildClassesDir' points to the compiled classes directory, " + + "Ensure the 'classLoader' has access to compiled classes, " + "or use 'addController(Class)' to manually register controllers for unit testing."); } @@ -185,6 +184,8 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw if (includeMethod) { var params = method.getGenericParameterTypes(); String tsInput = "void"; + + // Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple if (params.length == 1) { tsInput = resolveTsType(params[0]); } else if (params.length > 1) { @@ -194,9 +195,10 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw } String tsOutput = resolveTsType(method.getGenericReturnType()); + String procedureName = getProcedureName(method); ts.append(indent) - .append(method.getName()) + .append(procedureName) .append(": { input: ") .append(tsInput) .append("; output: ") @@ -228,8 +230,10 @@ private String resolveTsType(Type type) { var raw = pt.getRawType(); var rawName = raw.getTypeName(); - // Unwrap async types (CompletableFuture, Mono, Single, Future) - if (rawName.endsWith("CompletableFuture") + // Unwrap async and protocol wrapper types (TrpcResponse, CompletableFuture, Mono, Single, + // Future) + if (rawName.endsWith("TrpcResponse") + || rawName.endsWith("CompletableFuture") || rawName.endsWith("Single") || rawName.endsWith("Mono") || rawName.endsWith("Future")) { @@ -300,15 +304,13 @@ private String getClassName(Class clazz) { } /** - * Scans the build output directory to load and validate controller classes. + * Scans the classloader to load and validate controller classes. * * @return A set of valid controller classes found on disk. - * @throws IOException If the directory scan fails. */ private Set> discoverControllers() { var controllers = new LinkedHashSet>(); - // We scope the scan strictly to the build directory for maximum speed var classGraph = new ClassGraph() .enableClassInfo() @@ -316,12 +318,8 @@ private Set> discoverControllers() { .enableMethodInfo() .ignoreClassVisibility(); - if (buildClassesDir != null && Files.exists(buildClassesDir)) { - classGraph.overrideClasspath(buildClassesDir.toUri().toString()); - } else if (classLoader != null) { + if (classLoader != null) { classGraph.overrideClassLoaders(classLoader); - } else { - return controllers; } try (var scanResult = classGraph.scan()) { @@ -355,19 +353,56 @@ private Set> discoverControllers() { return controllers; } + /** Retrieves an annotation safely, supporting nested annotation syntax differences. */ + private Annotation getAnnotation(AnnotatedElement element, String annotationName) { + for (Annotation a : element.getAnnotations()) { + String name = a.annotationType().getName(); + if (name.equals(annotationName) + || name.replace('$', '.').equals(annotationName.replace('$', '.'))) { + return a; + } + } + return null; + } + /** - * ClassLoader-agnostic check to see if an element has the Trpc annotation. + * ClassLoader-agnostic check to see if an element has a Trpc annotation. Matches the APT + * precedence. * * @param element The class or method to inspect. - * @return True if annotated with {@code io.jooby.annotation.Trpc}. + * @return True if annotated with `@Trpc`, `@Trpc.Query`, or `@Trpc.Mutation`. */ private boolean isTrpcAnnotated(AnnotatedElement element) { - for (Annotation a : element.getAnnotations()) { - if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { - return true; + return getAnnotation(element, "io.jooby.annotation.Trpc") != null + || getAnnotation(element, "io.jooby.annotation.Trpc$Query") != null + || getAnnotation(element, "io.jooby.annotation.Trpc$Mutation") != null; + } + + /** + * Evaluates the exact tRPC procedure name based on annotation values, defaulting to the method + * name. Respects the precedence hierarchy: .Query / .Mutation > Base @Trpc. + */ + private String getProcedureName(Method method) { + String[] procedureAnnotations = { + "io.jooby.annotation.Trpc$Query", + "io.jooby.annotation.Trpc$Mutation", + "io.jooby.annotation.Trpc" + }; + + for (String annName : procedureAnnotations) { + Annotation a = getAnnotation(method, annName); + if (a != null) { + try { + var valueMethod = a.annotationType().getMethod("value"); + var value = (String) valueMethod.invoke(a); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception ignored) { + } } } - return false; + return method.getName(); } /** @@ -390,36 +425,25 @@ private boolean hasWebAnnotation(AnnotatedElement element) { /** * Extracts the target namespace for the tRPC router based on the controller. If the class is not - * annotated with @Trpc, it returns null (indicating root-level). + * annotated with @Trpc, or if its value is empty, it returns null (indicating the methods belong + * to the root namespace). * * @param controller The controller class. * @return The determined namespace string, or null for root-level. */ private String extractNamespace(Class controller) { - boolean hasClassLevelTrpc = false; - - for (Annotation a : controller.getAnnotations()) { - if (a.annotationType().getName().equals("io.jooby.annotation.Trpc")) { - hasClassLevelTrpc = true; - try { - var method = a.annotationType().getMethod("value"); - var value = (String) method.invoke(a); - // Explicit namespace provided: @Trpc("myNamespace") - if (value != null && !value.isBlank()) return value; - } catch (Exception ignored) { + Annotation trpc = getAnnotation(controller, "io.jooby.annotation.Trpc"); + if (trpc != null) { + try { + var method = trpc.annotationType().getMethod("value"); + var value = (String) method.invoke(trpc); + if (value != null && !value.isBlank()) { + return value; } + } catch (Exception ignored) { } } - - // No class-level annotation means these methods sit at the root of the router - if (!hasClassLevelTrpc) { - return null; - } - - // Class is annotated, but no explicit value provided. Derive from class name. - var name = controller.getSimpleName(); - name = Character.toLowerCase(name.charAt(0)) + name.substring(1); - return name.replace("Controller", "").replace("Resource", ""); + return null; // Root namespace } // --- Configuration API (Getters, Setters, and Builders) --- @@ -434,20 +458,6 @@ public void addController(Class controller) { this.manualControllers.add(controller); } - /** - * @return The directory where compiled class files are located. - */ - public Path getBuildClassesDir() { - return buildClassesDir; - } - - /** - * @param buildClassesDir The directory where compiled class files are located. - */ - public void setBuildClassesDir(Path buildClassesDir) { - this.buildClassesDir = buildClassesDir; - } - /** * @return The class loader used to load compiled controllers. */ diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java index 479c30f8dd..6e200a20a8 100644 --- a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java +++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java @@ -26,8 +26,6 @@ void shouldGenerateTrpcRouterAndModels() throws Exception { var testClassesDir = Paths.get(C3863.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - generator.setBuildClassesDir(testClassesDir); - generator.setClassLoader(Thread.currentThread().getContextClassLoader()); generator.setOutputDir(outputDir); generator.setOutputFile("api.d.ts"); diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java index 6e2a8a440e..302ebd71ba 100644 --- a/tests/src/test/java/io/jooby/i3863/MovieService.java +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -9,7 +9,9 @@ import java.util.Map; import java.util.stream.Collectors; +import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.annotation.*; +import io.jooby.exception.NotFoundException; import reactor.core.publisher.Mono; @Trpc("movies") @@ -51,10 +53,13 @@ public String ping() { } /** Procedure: movies.getById Single primitive argument */ - @Trpc.Query + @Trpc @GET("/{id}") - public Movie getById(@PathParam int id) { - return database.stream().filter(m -> m.id() == id).findFirst().orElse(null); + public @NonNull Movie getById(@PathParam int id) { + return database.stream() + .filter(m -> m.id() == id) + .findFirst() + .orElseThrow(() -> new NotFoundException("Movie not found: " + id)); } /** Procedure: movies.search Multi-argument (Tuple) */ diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java index 7c2b4fbd01..021ae6b8a5 100644 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -7,6 +7,8 @@ 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; @@ -33,202 +35,180 @@ void shouldTalkTrpc(ServerTestRunner runner, Extension jsonExtension) { .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":"pong"}} - """); + "{\"result\":{\"data\":{\"id\":1,\"title\":\"The Godfather\",\"year\":1972}}}"); }); + + // GetById (Not found) http.get( - "/trpc/movies.getById?input=[1]", + "/trpc/movies.getById?input=13", rsp -> { + assertThat(rsp.code()).isEqualTo(404); assertThat(rsp.body().string()) .isEqualToIgnoringNewLines( """ - {"result":{"data":{"id":1,"title":"The Godfather","year":1972}}} + {"error":{"message":"Movie not found: 13","code":-32004,"data":{"code":"NOT_FOUND","httpStatus":404,"path":"movies.getById"}}} """); }); - // Test Multi-Arg Query (Pulp Fiction 1994) - // input=["Pulp Fiction", 1994] + + // 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}]}} - """); + "{\"result\":{\"data\":[{\"id\":2,\"title\":\"Pulp Fiction\",\"year\":1994}]}}"); }); - // Test Multi-Arg Mutation - // Body: ["The Godfather", 5, "Amazing"] + + // AddReview (Multi-Argument Mutation) http.postJson( "/trpc/movies.addReview", "[\"The Godfather\", 5, \"Amazing\"]", rsp -> { var json = rsp.body().string(); - - String status = JsonPath.read(json, "$.result.data.status"); - int rating = JsonPath.read(json, "$.result.data.rating"); - - assertThat(status).isEqualTo("published"); - assertThat(rating).isEqualTo(5); + assertThat(rsp.code()).isEqualTo(200); + assertThat(JsonPath.read(json, "$.result.data.status")).isEqualTo("published"); + assertThat(JsonPath.read(json, "$.result.data.rating")).isEqualTo(5); }); - // 3. The tRPC payload (The Tuple) - // Notice the outer `[` and `]` wrapping the actual JSON object. + + // --- 2. Seamless vs. Tuple Wrappers --- + // Create (Strict Seamless Payload for single argument) http.postJson( "/trpc/movies.create", - "[{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}]", + "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}", rsp -> { String json = rsp.body().string(); - - // 4. Validating the tRPC envelope and data using JsonPath + AssertJ assertThat(rsp.code()).isEqualTo(200); - - assertThat(JsonPath.read(json, "$.result.data.id")).isEqualTo(1); assertThat(JsonPath.read(json, "$.result.data.title")).isEqualTo("The Matrix"); - assertThat(JsonPath.read(json, "$.result.data.year")).isEqualTo(1999); }); - // reactive + // 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}]", + "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}", rsp -> { String json = rsp.body().string(); - - // 4. Validating the tRPC envelope and data using JsonPath + AssertJ assertThat(rsp.code()).isEqualTo(200); - assertThat(JsonPath.read(json, "$.result.data.id")).isEqualTo(1); - assertThat(JsonPath.read(json, "$.result.data.title")).isEqualTo("The Matrix"); - assertThat(JsonPath.read(json, "$.result.data.year")).isEqualTo(1999); }); + // ResetIndex (Void return type) http.post( "/trpc/movies.resetIndex", rsp -> { - assertThat(rsp.body().string()) - .isEqualToIgnoringNewLines( - """ - {"result":{}} - """); + assertThat(rsp.code()).isEqualTo(200); + assertThat(rsp.body().string()).contains("\"result\""); }); - // 1. Test Error: Type Mismatch (Sending a String instead of an Integer for 'stars') + // --- 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); - - // tRPC puts error details inside an "error" envelope, not "result" assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); - assertThat(JsonPath.read(json, "$.error.data.httpStatus")).isEqualTo(400); - - // The message should complain about the parsing failure - String message = JsonPath.read(json, "$.error.message"); - assertThat(message).isNotEmpty(); + assertThat(JsonPath.read(json, "$.error.message")).isNotEmpty(); }); - // 2. Test Error: Missing Tuple Wrapper (Sending Object instead of Array tuple) - http.postJson( - "/trpc/movies.create", - "{\"id\": 1, \"title\": \"The Matrix\", \"year\": 1999}", + // 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.data.code")).isEqualTo("BAD_REQUEST"); - assertThat(JsonPath.read(json, "$.error.message")) - .isEqualTo("tRPC body must be a JSON array (tuple)"); - - // Ideally, the message explicitly states that an array was expected - assertThat(JsonPath.read(json, "$.error.message")).containsIgnoringCase("array"); + .contains("tRPC input for multiple arguments must be a JSON array (tuple)"); }); - // 3. Test Error: Procedure Not Found (404) + // 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"); - assertThat(JsonPath.read(json, "$.error.data.httpStatus")).isEqualTo(404); }); - // 1. Validating a nullable parameter is accepted when passing `null` - // Assuming `movies.search` has a signature like: search(String title, Integer year) - // where `year` is nullable. Sending: ["The Godfather", null] + // --- 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()) - .isEqualToIgnoringNewLines( - """ - {"result":{"data":[{"id":1,"title":"The Godfather","year":1972}]}} - """); + assertThat(rsp.body().string()).contains("\"The Godfather\""); }); - // 2. Validating a required (non-nullable) parameter rejects `null` - // Assuming `movies.getById` has signature: getById(int id) - // Sending: [null] + // Validating a required (primitive) parameter rejects null (Seamless path) http.get( - "/trpc/movies.getById?input=[null]", + "/trpc/movies.getById?input=null", rsp -> { String json = rsp.body().string(); + System.out.println(json); assertThat(rsp.code()).isEqualTo(400); - - // Should trigger a parsing error because a primitive cannot be null assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); - assertThat(JsonPath.read(json, "$.error.message")).containsIgnoringCase("id"); }); - // 3. Validating MissingValueException (Not enough arguments) - // Assuming `movies.addReview` requires 3 arguments: addReview(String title, int stars, String - // comment) - // Sending only 2: ["The Godfather", 5] + // 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); - - // Should trigger Jooby's MissingValueException because the array ended too early - assertThat(JsonPath.read(json, "$.error.data.code")).isEqualTo("BAD_REQUEST"); assertThat(JsonPath.read(json, "$.error.message")) .containsIgnoringCase("comment"); }); - // 4. Validating explicit `null` on an Object/POJO (if allowed) - // Assuming a method like: updateMetadata(int id, Metadata data) where `data` is nullable - // Sending: [1, null] + // Validating explicit null on an Object/POJO http.postJson( "/trpc/movies.updateMetadata", "[1, null]", rsp -> { - String json = rsp.body().string(); - System.out.println(json); assertThat(rsp.code()).isEqualTo(200); - // Validates the generator correctly used nextIsNull for the POJO decoder - assertThat(json).contains("\"result\""); + assertThat(rsp.body().string()).contains("\"result\""); }); } } diff --git a/tests/src/test/java/io/jooby/test/WebClient.java b/tests/src/test/java/io/jooby/test/WebClient.java index d1ff7247aa..8ed789c3c9 100644 --- a/tests/src/test/java/io/jooby/test/WebClient.java +++ b/tests/src/test/java/io/jooby/test/WebClient.java @@ -16,6 +16,7 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; @@ -227,10 +228,16 @@ public Request invoke(String method, String path) { } public Request invoke(String method, String path, RequestBody body) { - okhttp3.Request.Builder req = new okhttp3.Request.Builder(); + return invoke(method, path, Map.of(), body); + } + + public Request invoke(String method, String path, Map query, RequestBody body) { + var req = new okhttp3.Request.Builder(); req.method(method, body); setRequestHeaders(req); - req.url(scheme + "://localhost:" + port + path); + var url = HttpUrl.parse(scheme + "://localhost:" + port + path).newBuilder(); + query.forEach((name, value) -> url.addQueryParameter(name, value.toString())); + req.url(url.build()); return new Request(req); } @@ -247,7 +254,11 @@ private void setRequestHeaders(okhttp3.Request.Builder req) { } public Request get(String path) { - return invoke("GET", path, null); + return get(path, Map.of()); + } + + public Request get(String path, Map query) { + return invoke("GET", path, query, null); } public ServerSentMessageIterator sse(String path) { @@ -296,6 +307,11 @@ public void get(String path, SneakyThrows.Consumer callback) { get(path).execute(callback); } + public void get( + String path, Map query, SneakyThrows.Consumer callback) { + get(path, query).execute(callback); + } + public void syncWebSocket(String path, SneakyThrows.Consumer consumer) { okhttp3.Request.Builder req = new okhttp3.Request.Builder(); req.url("ws://localhost:" + port + path); From 4b748a5026cc7c54499cb1b9fae3af71e2ea2e9d Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 7 Mar 2026 12:18:07 -0300 Subject: [PATCH 13/16] Fix tRPC reactive type leaks and support all HTTP mutations This update hardens the TypeScript generation pipeline to prevent internal JVM and reactive wrapper types from leaking into the client definitions, and expands tRPC mutation support to cover all standard state-changing HTTP verbs. Key changes: * TrpcGenerator: Fixed a bug where `CompletableFuture`, `Mono`, `Single`, and other async wrappers were being emitted in the `trpc.d.ts` output. Implemented a deep recursive unwrapping mechanism to extract the true underlying DTOs from complex generic, wildcard, and array signatures. * TrpcGenerator: Added a Jackson "firewall" to the typescript-generator settings, explicitly mapping all known async wrappers to `any` to prevent indirect discovery via reflection. * TrpcGenerator: Dynamically extracts generic type parameters (e.g., `Future`) via reflection to satisfy typescript-generator's strict custom mapping validation, while safely ignoring wrappers not present on the compilation classpath. * TrpcGenerator: Made the `AppRouter` output 100% deterministic to prevent test flakiness. Procedures are now grouped by namespace, visually separated into `// queries` and `// mutations`, and sorted alphabetically. * MvcRoute (APT) & TrpcGenerator: Expanded mutation detection. Methods annotated with base `@Trpc` alongside `@PUT`, `@PATCH`, or `@DELETE` are now correctly categorized as mutations in the TypeScript router and mapped to HTTP POST proxy routes by the Jooby annotation processor. --- .../java/io/jooby/internal/apt/MvcRoute.java | 12 +- modules/jooby-trpc/pom.xml | 5 + .../java/io/jooby/trpc/TrpcGenerator.java | 249 +++++++++++++++--- .../test/java/io/jooby/trpc/i3863/C3863.java | 19 +- .../jooby/trpc/i3863/TrpcGeneratorTest.java | 5 + .../java/io/jooby/i3863/MovieService.java | 9 +- .../java/io/jooby/i3863/TrpcProtocolTest.java | 8 + 7 files changed, 263 insertions(+), 44 deletions(-) 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 af21495e3b..f78764cf23 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 @@ -1111,7 +1111,12 @@ private HttpMethod trpcMethod(Element element) { if (HttpMethod.GET.matches(element)) { return HttpMethod.GET; } - if (HttpMethod.POST.matches(element)) { + + // 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; } @@ -1121,8 +1126,9 @@ private HttpMethod trpcMethod(Element element) { + element.getSimpleName() + "() in " + element.getEnclosingElement().getSimpleName() - + " is annotated with @Trpc but lacks @GET or @POST. Please annotate the method with" - + " @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET or @POST."); + + " is annotated with @Trpc but lacks a valid HTTP method annotation. Please annotate" + + " the method with @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET, @POST," + + " @PUT, @PATCH, or @DELETE."); } return null; } diff --git a/modules/jooby-trpc/pom.xml b/modules/jooby-trpc/pom.xml index 9e84263f92..3978b4b6d1 100644 --- a/modules/jooby-trpc/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -81,6 +81,11 @@ mockito-core test + + io.projectreactor + reactor-core + 3.8.3 + org.assertj assertj-core diff --git a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java index ec16011b4a..a326c5235f 100644 --- a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java +++ b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java @@ -52,6 +52,32 @@ */ public class TrpcGenerator { + private static final Set WRAPPERS = + Set.of( + // Protocol Envelope + "io.jooby.trpc.TrpcResponse", + // JDK Async + "java.util.concurrent.CompletableFuture", + "java.util.concurrent.CompletionStage", + "java.util.concurrent.Future", + // Reactor + "reactor.core.publisher.Mono", + "reactor.core.publisher.Flux", + // Mutiny + "io.smallrye.mutiny.Uni", + "io.smallrye.mutiny.Multi", + // RxJava 2 & 3 + "io.reactivex.Single", + "io.reactivex.Maybe", + "io.reactivex.Observable", + "io.reactivex.Flowable", + "io.reactivex.Completable", + "io.reactivex.rxjava3.core.Single", + "io.reactivex.rxjava3.core.Maybe", + "io.reactivex.rxjava3.core.Observable", + "io.reactivex.rxjava3.core.Flowable", + "io.reactivex.rxjava3.core.Completable"); + private final Logger log = LoggerFactory.getLogger(getClass()); private ClassLoader classLoader = getClass().getClassLoader(); @@ -103,9 +129,9 @@ public void generate() throws IOException { if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); if (includeMethod) { - typesToGenerate.add(method.getGenericReturnType()); + addTypeToGenerate(typesToGenerate, method.getGenericReturnType()); for (var param : method.getGenericParameterTypes()) { - typesToGenerate.add(param); + addTypeToGenerate(typesToGenerate, param); } } } @@ -125,6 +151,31 @@ public void generate() throws IOException { // 2. Generate standard interfaces (DTOs only) if (!typesToGenerate.isEmpty()) { TypeScriptGenerator.setLogger(asSlf4j(log)); + + /// FIREWALL: Force the generator to ignore all reactive wrappers if discovered indirectly. + // typescript-generator strictly demands exact generic signatures (e.g. Future). + // We dynamically append the type variables using reflection to satisfy its parser. + for (String wrapper : WRAPPERS) { + try { + Class clazz = Class.forName(wrapper, false, classLoader); + StringBuilder key = new StringBuilder(wrapper); + var typeParams = clazz.getTypeParameters(); + + if (typeParams.length > 0) { + key.append("<"); + for (int i = 0; i < typeParams.length; i++) { + if (i > 0) key.append(", "); + key.append(typeParams[i].getName()); + } + key.append(">"); + } + + settings.customTypeMappings.put(key.toString(), "any"); + } catch (ClassNotFoundException | NoClassDefFoundError ignored) { + // Class not on classpath, safe to ignore + } + } + var generator = new TypeScriptGenerator(settings); var input = Input.from(typesToGenerate.toArray(new Type[0])); generator.generateTypeScript(input, Output.to(finalOutput.toFile())); @@ -155,6 +206,45 @@ protected void write(Level level, String message) { }; } + private void addTypeToGenerate(Set types, Type type) { + if (type == void.class || type == Void.class) return; + + if (type instanceof ParameterizedType pt) { + var rawName = pt.getRawType().getTypeName(); + + // Unwrap async and protocol wrapper types + if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) { + addTypeToGenerate(types, pt.getActualTypeArguments()[0]); + return; + } + + // Decompose standard generic types to ensure inner DTOs are discovered safely + addTypeToGenerate(types, pt.getRawType()); + for (Type arg : pt.getActualTypeArguments()) { + addTypeToGenerate(types, arg); + } + return; + } else if (type instanceof Class clazz) { + // Ignore bare async types (like RxJava Completable) that signify void + if (WRAPPERS.contains(clazz.getName())) return; + + if (clazz.isArray()) { + addTypeToGenerate(types, clazz.getComponentType()); + return; + } + } else if (type instanceof java.lang.reflect.WildcardType wt) { + for (Type bound : wt.getUpperBounds()) { + if (bound != Object.class) addTypeToGenerate(types, bound); + } + return; + } else if (type instanceof java.lang.reflect.GenericArrayType gat) { + addTypeToGenerate(types, gat.getGenericComponentType()); + return; + } + + types.add(type); + } + /** * Constructs and appends the tRPC {@code AppRouter} mapping to the bottom of the generated file. * @@ -168,46 +258,71 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw ts.append("\n// --- tRPC Router Mapping ---\n\n"); ts.append("export type AppRouter = {\n"); + // 1. Group by namespace using a TreeMap to guarantee deterministic alphabetical order + Map> namespaces = new java.util.TreeMap<>(); + for (var controller : controllers) { var namespace = extractNamespace(controller); - String indent = " "; // Default indent for root methods + String key = namespace == null ? "" : namespace; - if (namespace != null) { - ts.append(" ").append(namespace).append(": {\n"); - indent = " "; // Increase indent for nested methods - } + namespaces.computeIfAbsent(key, k -> new ArrayList<>()); for (var method : controller.getDeclaredMethods()) { boolean includeMethod = isTrpcAnnotated(method); if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); if (includeMethod) { - var params = method.getGenericParameterTypes(); - String tsInput = "void"; - - // Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple - if (params.length == 1) { - tsInput = resolveTsType(params[0]); - } else if (params.length > 1) { - var tuple = new ArrayList(); - for (var p : params) tuple.add(resolveTsType(p)); - tsInput = "[" + String.join(", ", tuple) + "]"; - } + namespaces.get(key).add(method); + } + } + } + + // 2. Generate the TypeScript output + for (var entry : namespaces.entrySet()) { + String namespace = entry.getKey(); + List methods = entry.getValue(); - String tsOutput = resolveTsType(method.getGenericReturnType()); - String procedureName = getProcedureName(method); + if (methods.isEmpty()) continue; - ts.append(indent) - .append(procedureName) - .append(": { input: ") - .append(tsInput) - .append("; output: ") - .append(tsOutput) - .append(" };\n"); + String indent = " "; + if (!namespace.isEmpty()) { + ts.append(" ").append(namespace).append(": {\n"); + indent = " "; + } + + // Separate into queries and mutations for deterministic grouping + List queries = new ArrayList<>(); + List mutations = new ArrayList<>(); + + for (Method m : methods) { + if (isMutation(m)) { + mutations.add(m); + } else { + queries.add(m); + } + } + + // Sort alphabetically by procedure name to prevent reflection-order test flakiness + java.util.Comparator byName = java.util.Comparator.comparing(this::getProcedureName); + queries.sort(byName); + mutations.sort(byName); + + if (!queries.isEmpty()) { + ts.append(indent).append("// queries\n"); + for (Method method : queries) { + appendProcedure(ts, indent, method); + } + } + + if (!mutations.isEmpty()) { + if (!queries.isEmpty()) ts.append("\n"); // Add a blank line between groups if both exist + ts.append(indent).append("// mutations\n"); + for (Method method : mutations) { + appendProcedure(ts, indent, method); } } - if (namespace != null) { + if (!namespace.isEmpty()) { ts.append(" };\n"); } } @@ -216,6 +331,61 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw Files.writeString(finalOutput, ts.toString(), StandardOpenOption.APPEND); } + private void appendProcedure(StringBuilder ts, String indent, Method method) { + var params = method.getGenericParameterTypes(); + String tsInput = "void"; + + // Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple + if (params.length == 1) { + tsInput = resolveTsType(params[0]); + } else if (params.length > 1) { + var tuple = new ArrayList(); + for (var p : params) tuple.add(resolveTsType(p)); + tsInput = "[" + String.join(", ", tuple) + "]"; + } + + String tsOutput = resolveTsType(method.getGenericReturnType()); + String procedureName = getProcedureName(method); + + ts.append(indent) + .append(procedureName) + .append(": { input: ") + .append(tsInput) + .append("; ") + .append("output: ") + .append(tsOutput) + .append(" };\n"); + } + + private boolean isMutation(Method method) { + // 1. Explicit tRPC mutation annotation + if (getAnnotation(method, "io.jooby.annotation.Trpc$Mutation") != null) { + return true; + } + // 2. Explicit tRPC query annotation + if (getAnnotation(method, "io.jooby.annotation.Trpc$Query") != null) { + return false; + } + + // 3. Base @Trpc combined with standard HTTP mutation annotations + String[] httpMutations = { + "io.jooby.annotation.POST", "io.jooby.annotation.PUT", + "io.jooby.annotation.PATCH", "io.jooby.annotation.DELETE", + "jakarta.ws.rs.POST", "jakarta.ws.rs.PUT", + "jakarta.ws.rs.PATCH", "jakarta.ws.rs.DELETE", + "javax.ws.rs.POST", "javax.ws.rs.PUT", + "javax.ws.rs.PATCH", "javax.ws.rs.DELETE" + }; + + for (String ann : httpMutations) { + if (getAnnotation(method, ann) != null) { + return true; + } + } + + return false; // Default to query + } + /** * Fast, recursive type resolver to map Java types directly to TypeScript signatures. Understands * Jooby async types, standard collections, and primitive mappings. @@ -230,13 +400,8 @@ private String resolveTsType(Type type) { var raw = pt.getRawType(); var rawName = raw.getTypeName(); - // Unwrap async and protocol wrapper types (TrpcResponse, CompletableFuture, Mono, Single, - // Future) - if (rawName.endsWith("TrpcResponse") - || rawName.endsWith("CompletableFuture") - || rawName.endsWith("Single") - || rawName.endsWith("Mono") - || rawName.endsWith("Future")) { + // Unwrap async and protocol wrapper types + if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) { return resolveTsType(pt.getActualTypeArguments()[0]); } @@ -259,6 +424,17 @@ private String resolveTsType(Type type) { } } + if (type instanceof java.lang.reflect.WildcardType wt) { + if (wt.getUpperBounds().length > 0 && wt.getUpperBounds()[0] != Object.class) { + return resolveTsType(wt.getUpperBounds()[0]); + } + return "any"; + } + + if (type instanceof java.lang.reflect.GenericArrayType gat) { + return resolveTsType(gat.getGenericComponentType()) + "[]"; + } + if (type instanceof Class clazz) { if (clazz.isArray()) { if (clazz.getComponentType() == byte.class) @@ -266,6 +442,9 @@ private String resolveTsType(Type type) { return resolveTsType(clazz.getComponentType()) + "[]"; } + // Handle bare async types (like RxJava Completable) as void returns + if (WRAPPERS.contains(clazz.getName())) return "void"; + if (clazz == String.class || clazz == char.class || clazz == Character.class diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java index a9e64941dc..e4b659dd1f 100644 --- a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java +++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java @@ -5,10 +5,10 @@ */ package io.jooby.trpc.i3863; -import io.jooby.annotation.GET; -import io.jooby.annotation.POST; -import io.jooby.annotation.Path; -import io.jooby.annotation.Trpc; +import java.util.concurrent.CompletableFuture; + +import io.jooby.annotation.*; +import reactor.core.publisher.Mono; @Path("/users") @Trpc("users") // Custom namespace @@ -26,6 +26,17 @@ public U3863 createUser(U3863 user) { return user; } + @PUT + @Trpc + public CompletableFuture createFuture(U3863 user) { + return CompletableFuture.completedFuture(user); + } + + @Trpc.Mutation + public Mono createMono(U3863 user) { + return Mono.just(user); + } + @GET("/internal") public String internalEndpoint() { return "This should not be exposed to tRPC"; diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java index 6e200a20a8..2d4174e373 100644 --- a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java +++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/TrpcGeneratorTest.java @@ -50,7 +50,12 @@ export interface U3863 { export type AppRouter = { users: { + // queries getUser: { input: string; output: U3863 }; + + // mutations + createFuture: { input: U3863; output: U3863 }; + createMono: { input: U3863; output: U3863 }; createUser: { input: U3863; output: U3863 }; }; }; diff --git a/tests/src/test/java/io/jooby/i3863/MovieService.java b/tests/src/test/java/io/jooby/i3863/MovieService.java index 302ebd71ba..55e00837db 100644 --- a/tests/src/test/java/io/jooby/i3863/MovieService.java +++ b/tests/src/test/java/io/jooby/i3863/MovieService.java @@ -81,9 +81,14 @@ public Map addReview(String movieTitle, int stars, String commen } /** Procedure: movies.addReview Mix of String and int (Mutation) */ - @Trpc.Mutation - public Metadata updateMetadata(int id, Metadata metadata) { + @Trpc + @PUT("/{id}/metadata") + public Metadata updateMetadata(@PathParam int id, Metadata metadata) { // Business logic... return metadata; } + + @Trpc + @DELETE("/{id}") + public void deleteMovie(@PathParam int id) {} } diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java index 021ae6b8a5..26f299e1cd 100644 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -210,5 +210,13 @@ void assertProtocolData(WebClient http) { 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\""); + }); } } From 930527becf4f9716014b65da343b07bdf1fe9693 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 7 Mar 2026 19:39:29 -0300 Subject: [PATCH 14/16] feat(trpc): add Maven/Gradle plugins and filter framework parameters This commit introduces the build tool plugins required to execute the tRPC TypeScript generator automatically during the build lifecycle, and fixes a bug regarding how framework-specific parameters are handled in the tRPC network payload. Key changes: * MvcRoute (APT) & TrpcGenerator: Explicitly filter out `io.jooby.Context` and `kotlin.coroutines.Continuation` when evaluating tRPC parameters. This prevents the backend from incorrectly expecting a JSON array (tuple) when a method mixes a single payload argument with framework injections, and ensures the TypeScript signatures remain clean. * Maven Plugin: Added `TrpcMojo` bound to the `process-classes` phase. Exposes full configuration for the generator, including `jsonLibrary` (Jackson2, JSON-B, Gson), `customTypeMappings`, and `outputDir` (defaulting to `target/classes`). * Gradle Plugin: Added `TrpcTask` and registered the `io.jooby.trpc` plugin in the `jooby-gradle-plugin` build script, ensuring feature parity with the Maven implementation. --- .../java/io/jooby/internal/apt/MvcRoute.java | 19 +- modules/jooby-gradle-plugin/build.gradle | 14 +- .../java/io/jooby/gradle/JoobyPlugin.java | 13 ++ .../main/java/io/jooby/gradle/TrpcTask.java | 166 ++++++++++++++++++ modules/jooby-maven-plugin/pom.xml | 6 + .../main/java/io/jooby/maven/TrpcMojo.java | 143 +++++++++++++++ .../java/io/jooby/trpc/TrpcGenerator.java | 87 +++------ .../test/java/io/jooby/trpc/i3863/C3863.java | 3 +- 8 files changed, 381 insertions(+), 70 deletions(-) create mode 100644 modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java create mode 100644 modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java 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 f78764cf23..55f77d60d1 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java @@ -317,6 +317,18 @@ public List generateHandlerCall(boolean kt) { ")", semicolon(kt))); + // Calculate actual tRPC payload parameters (ignore Context and Coroutines) + long trpcPayloadCount = + parameters.stream() + .filter( + p -> { + String type = p.getType().getRawType().toString(); + return !type.equals("io.jooby.Context") + && !p.getType().is("kotlin.coroutines.Continuation"); + }) + .count(); + boolean isTuple = trpcPayloadCount > 1; + if (resolvedTrpcMethod == HttpMethod.GET) { buffer.add( statement( @@ -327,8 +339,7 @@ public List generateHandlerCall(boolean kt) { ").value()", semicolon(kt))); - // Only enforce JSON array tuples if there are multiple arguments - if (parameters.size() > 1) { + if (isTuple) { // <-- Use calculated isTuple if (kt) { buffer.add( statement( @@ -351,8 +362,7 @@ public List generateHandlerCall(boolean kt) { } else { buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt))); - // Only enforce JSON array tuples if there are multiple arguments - if (parameters.size() > 1) { + if (isTuple) { // <-- Use calculated isTuple if (kt) { buffer.add( statement( @@ -373,7 +383,6 @@ public List generateHandlerCall(boolean kt) { } } - boolean isTuple = parameters.size() > 1; if (kt) { buffer.add( statement( diff --git a/modules/jooby-gradle-plugin/build.gradle b/modules/jooby-gradle-plugin/build.gradle index 15e01f2317..70f69d5f3e 100644 --- a/modules/jooby-gradle-plugin/build.gradle +++ b/modules/jooby-gradle-plugin/build.gradle @@ -26,6 +26,7 @@ group = "io.jooby" dependencies { implementation "io.jooby:jooby-run:$version" implementation "io.jooby:jooby-openapi:$version" + implementation "io.jooby:jooby-trpc:$version" implementation "com.github.spotbugs:spotbugs-annotations:4.7.2" } @@ -33,9 +34,9 @@ dependencies { gradlePlugin { website = 'https://jooby.io' vcsUrl = 'https://github.com/jooby-project/jooby' - description = 'Jooby is a modern, performant and easy to use web framework for Java and Kotlin ' + - 'built on top of your favorite web server. The joobyRun task allows to restart your ' + - 'application on code changes without exiting the JVM' + description = 'Jooby is a modular, high-performance web framework for Java and Kotlin. Designed ' + + 'for simplicity and speed, it gives you the freedom to build on your favorite server with a ' + + 'clean, modern API.' plugins { joobyRun { id = 'io.jooby.run' @@ -51,5 +52,12 @@ gradlePlugin { tags = ['jooby', 'openAPI'] description = 'Generates an Open-API compatible output from your application' } + trpc { + id = 'io.jooby.trpc' + implementationClass = 'io.jooby.gradle.JoobyPlugin' + displayName = 'Jooby tRPC plugin' + tags = ['jooby', 'trpc', 'typescript'] + description = 'Generates a tRPC-compatible TypeScript API definition from your application' + } } } diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java index 8145c88d97..786a0956ec 100644 --- a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/JoobyPlugin.java @@ -28,6 +28,7 @@ public JoobyPlugin() { @Override public void apply(Project project) { openAPI(project); + trpc(project); joobyRun(project); joobyTestRun(project); @@ -64,4 +65,16 @@ private void openAPI(Project project) { project.getTasks().create(openAPIOptions); } + + private void trpc(Project project) { + Map options = new HashMap<>(); + options.put(Task.TASK_TYPE, OpenAPITask.class); + options.put(Task.TASK_DEPENDS_ON, "classes"); + options.put(Task.TASK_NAME, "tRPC"); + options + .put(Task.TASK_DESCRIPTION, "tRPC Generator"); + options.put(Task.TASK_GROUP, "jooby"); + + project.getTasks().create(options); + } } diff --git a/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java new file mode 100644 index 0000000000..74c2442954 --- /dev/null +++ b/modules/jooby-gradle-plugin/src/main/java/io/jooby/gradle/TrpcTask.java @@ -0,0 +1,166 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.gradle; + +import cz.habarta.typescript.generator.DateMapping; +import cz.habarta.typescript.generator.EnumMapping; +import cz.habarta.typescript.generator.JsonLibrary; +import io.jooby.trpc.TrpcGenerator; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; + +import java.io.File; +import java.util.List; +import java.util.Map; + +/** + * Generate a tRpc script file from a jooby application. + * + * Usage: https://jooby.io/modules/trpc + * + * @author edgar + * @since 4.0.17 + */ +public class TrpcTask extends BaseTask { + + private Map customTypeMappings; + + private Map customTypeNaming; + + private JsonLibrary jsonLibrary = JsonLibrary.jackson2; + + private DateMapping mapDate = DateMapping.asString; + + private EnumMapping mapEnum = EnumMapping.asInlineUnion; + + private File outputDir; + + private String outputFile = "trpc.d.ts"; + + private List importDeclarations; + + /** + * Creates a tRPC task. + */ + public TrpcTask() {} + + /** + * Generate tRPC files from Jooby application. + * + * @throws Throwable If something goes wrong. + */ + @TaskAction + public void generate() throws Throwable { + var projects = getProjects(); + var classLoader = createClassLoader(projects); + + // Default to the compiled classes directory if the user hasn't overridden outputDir + var outDir = outputDir != null ? outputDir.toPath() : classes(getProject(), false); + + var generator = new TrpcGenerator(); + generator.setClassLoader(classLoader); + generator.setOutputDir(outDir); + generator.setOutputFile(outputFile); + + if (customTypeMappings != null) { + generator.setCustomTypeMappings(customTypeMappings); + } + generator.setJsonLibrary(jsonLibrary); + generator.setMapDate(mapDate); + generator.setMapEnum(mapEnum); + if (importDeclarations != null) { + generator.setImportDeclarations(importDeclarations); + } + if (customTypeNaming != null) { + generator.setCustomTypeNaming(customTypeNaming); + } + + getLogger().info("Generating: " + outDir.resolve(outputFile)); + + generator.generate(); + } + + @Input + @Optional + public Map getCustomTypeMappings() { + return customTypeMappings; + } + + public void setCustomTypeMappings(Map customTypeMappings) { + this.customTypeMappings = customTypeMappings; + } + + @Input + @Optional + public Map getCustomTypeNaming() { + return customTypeNaming; + } + + public void setCustomTypeNaming(Map customTypeNaming) { + this.customTypeNaming = customTypeNaming; + } + + @Input + @Optional + public JsonLibrary getJsonLibrary() { + return jsonLibrary; + } + + public void setJsonLibrary(JsonLibrary jsonLibrary) { + this.jsonLibrary = jsonLibrary; + } + + @Input + @Optional + public DateMapping getMapDate() { + return mapDate; + } + + public void setMapDate(DateMapping mapDate) { + this.mapDate = mapDate; + } + + @Input + @Optional + public EnumMapping getMapEnum() { + return mapEnum; + } + + public void setMapEnum(EnumMapping mapEnum) { + this.mapEnum = mapEnum; + } + + @Input + @Optional + public File getOutputDir() { + return outputDir; + } + + public void setOutputDir(File outputDir) { + this.outputDir = outputDir; + } + + @Input + @Optional + public String getOutputFile() { + return outputFile; + } + + public void setOutputFile(String outputFile) { + this.outputFile = outputFile; + } + + @Input + @Optional + public List getImportDeclarations() { + return importDeclarations; + } + + public void setImportDeclarations(List importDeclarations) { + this.importDeclarations = importDeclarations; + } +} diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index af499970ce..e564790411 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -29,6 +29,12 @@ ${jooby.version} + + io.jooby + jooby-trpc + ${jooby.version} + + org.apache.maven maven-plugin-api diff --git a/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java new file mode 100644 index 0000000000..bd767ff292 --- /dev/null +++ b/modules/jooby-maven-plugin/src/main/java/io/jooby/maven/TrpcMojo.java @@ -0,0 +1,143 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.maven; + +import static org.apache.maven.plugins.annotations.LifecyclePhase.PROCESS_CLASSES; +import static org.apache.maven.plugins.annotations.ResolutionScope.COMPILE_PLUS_RUNTIME; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +import cz.habarta.typescript.generator.DateMapping; +import cz.habarta.typescript.generator.EnumMapping; +import cz.habarta.typescript.generator.JsonLibrary; +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.trpc.TrpcGenerator; + +/** + * Generate a tRpc script file from a jooby application. + * + *

    Usage: https://jooby.io/modules/trpc + * + * @author edgar + * @since 4.0.17 + */ +@Mojo( + name = "tRPC", + threadSafe = true, + requiresDependencyResolution = COMPILE_PLUS_RUNTIME, + aggregator = true, + defaultPhase = PROCESS_CLASSES) +public class TrpcMojo extends BaseMojo { + + /** Custom mapping overrides translating Java types to raw TypeScript strings. */ + @Parameter private Map customTypeMappings; + + @Parameter private Map customTypeNaming; + + /** The target JSON library used to parse field annotations. Defaults to jackson2. */ + @Parameter(defaultValue = "jackson2", property = "jooby.trpc.jsonLibrary") + private JsonLibrary jsonLibrary = JsonLibrary.jackson2; + + @Parameter(defaultValue = "asString", property = "jooby.trpc.mapDate") + private DateMapping mapDate = DateMapping.asString; + + @Parameter(defaultValue = "asInlineUnion", property = "jooby.trpc.mapEnum") + private EnumMapping mapEnum = EnumMapping.asInlineUnion; + + @Parameter(defaultValue = "${project.build.outputDirectory}", property = "jooby.trpc.outputDir") + private File outputDir; + + @Parameter(defaultValue = "trpc.d.ts", property = "jooby.trpc.outputFile") + private String outputFile; + + @Parameter private List importDeclarations; + + @Override + protected void doExecute(@NonNull List projects, @NonNull String mainClass) + throws Exception { + var classLoader = createClassLoader(projects); + + var generator = new TrpcGenerator(); + generator.setClassLoader(classLoader); + generator.setOutputDir(outputDir.toPath()); + generator.setOutputFile(outputFile); + + if (customTypeMappings != null) { + generator.setCustomTypeMappings(customTypeMappings); + } + generator.setJsonLibrary(jsonLibrary); + generator.setMapDate(mapDate); + generator.setMapEnum(mapEnum); + if (importDeclarations != null) { + generator.setImportDeclarations(importDeclarations); + } + if (customTypeNaming != null) { + generator.setCustomTypeNaming(customTypeNaming); + } + + getLog().info("Generating: " + outputDir.toPath().resolve(outputFile)); + + generator.generate(); + } + + public String getOutputFile() { + return outputFile; + } + + public void setOutputFile(String outputFile) { + this.outputFile = outputFile; + } + + public File getOutputDir() { + return outputDir; + } + + public void setOutputDir(File outputDir) { + this.outputDir = outputDir; + } + + public Map getCustomTypeMappings() { + return customTypeMappings; + } + + public void setCustomTypeMappings(Map customTypeMappings) { + this.customTypeMappings = customTypeMappings; + } + + public Map getCustomTypeNaming() { + return customTypeNaming; + } + + public void setCustomTypeNaming(Map customTypeNaming) { + this.customTypeNaming = customTypeNaming; + } + + public JsonLibrary getJsonLibrary() { + return jsonLibrary; + } + + public DateMapping getMapDate() { + return mapDate; + } + + public EnumMapping getMapEnum() { + return mapEnum; + } + + public List getImportDeclarations() { + return importDeclarations; + } + + public void setImportDeclarations(List importDeclarations) { + this.importDeclarations = importDeclarations; + } +} diff --git a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java index a326c5235f..0052180766 100644 --- a/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java +++ b/modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java @@ -83,7 +83,6 @@ public class TrpcGenerator { private ClassLoader classLoader = getClass().getClassLoader(); private Path outputDir; private String outputFile = "trpc.d.ts"; - private boolean expandLookup = false; private final Set> manualControllers = new LinkedHashSet<>(); @@ -125,13 +124,14 @@ public void generate() throws IOException { var typesToGenerate = new LinkedHashSet(); for (var controller : controllers) { for (var method : controller.getDeclaredMethods()) { - boolean includeMethod = isTrpcAnnotated(method); - if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); - - if (includeMethod) { + if (isTrpcAnnotated(method)) { addTypeToGenerate(typesToGenerate, method.getGenericReturnType()); for (var param : method.getGenericParameterTypes()) { - addTypeToGenerate(typesToGenerate, param); + String typeName = param.getTypeName(); + if (!typeName.equals("io.jooby.Context") + && !typeName.startsWith("kotlin.coroutines.Continuation")) { + addTypeToGenerate(typesToGenerate, param); + } } } } @@ -155,10 +155,10 @@ public void generate() throws IOException { /// FIREWALL: Force the generator to ignore all reactive wrappers if discovered indirectly. // typescript-generator strictly demands exact generic signatures (e.g. Future). // We dynamically append the type variables using reflection to satisfy its parser. - for (String wrapper : WRAPPERS) { + for (var wrapper : WRAPPERS) { try { - Class clazz = Class.forName(wrapper, false, classLoader); - StringBuilder key = new StringBuilder(wrapper); + var clazz = Class.forName(wrapper, false, classLoader); + var key = new StringBuilder(wrapper); var typeParams = clazz.getTypeParameters(); if (typeParams.length > 0) { @@ -268,10 +268,7 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw namespaces.computeIfAbsent(key, k -> new ArrayList<>()); for (var method : controller.getDeclaredMethods()) { - boolean includeMethod = isTrpcAnnotated(method); - if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); - - if (includeMethod) { + if (isTrpcAnnotated(method)) { namespaces.get(key).add(method); } } @@ -332,15 +329,22 @@ private void appendTrpcRouter(Path finalOutput, Set> controllers) throw } private void appendProcedure(StringBuilder ts, String indent, Method method) { - var params = method.getGenericParameterTypes(); - String tsInput = "void"; + // Filter out framework parameters so they don't appear in the TypeScript signature + var payloadParams = new ArrayList(); + for (var p : method.getGenericParameterTypes()) { + String typeName = p.getTypeName(); + if (!typeName.equals("io.jooby.Context") + && !typeName.startsWith("kotlin.coroutines.Continuation")) { + payloadParams.add(p); + } + } - // Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple - if (params.length == 1) { - tsInput = resolveTsType(params[0]); - } else if (params.length > 1) { + String tsInput = "void"; + if (payloadParams.size() == 1) { + tsInput = resolveTsType(payloadParams.get(0)); + } else if (payloadParams.size() > 1) { var tuple = new ArrayList(); - for (var p : params) tuple.add(resolveTsType(p)); + for (var p : payloadParams) tuple.add(resolveTsType(p)); tsInput = "[" + String.join(", ", tuple) + "]"; } @@ -506,18 +510,12 @@ private Set> discoverControllers() { try { var clazz = classInfo.loadClass(false); // loads without initializing! - boolean includeClass = isTrpcAnnotated(clazz); - if (!includeClass && expandLookup) includeClass = hasWebAnnotation(clazz); - - if (includeClass) { + if (isTrpcAnnotated(clazz)) { controllers.add(clazz); } else { // Check methods if the class itself isn't annotated for (var method : clazz.getDeclaredMethods()) { - boolean includeMethod = isTrpcAnnotated(method); - if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method); - - if (includeMethod) { + if (isTrpcAnnotated(method)) { controllers.add(clazz); break; } @@ -584,24 +582,6 @@ private String getProcedureName(Method method) { return method.getName(); } - /** - * ClassLoader-agnostic check for standard web routing annotations. - * - * @param element The class or method to inspect. - * @return True if a JAX-RS or Jooby web annotation is present. - */ - private boolean hasWebAnnotation(AnnotatedElement element) { - for (Annotation a : element.getAnnotations()) { - var name = a.annotationType().getName(); - if (name.startsWith("io.jooby.annotation.") - || name.startsWith("jakarta.ws.rs.") - || name.startsWith("javax.ws.rs.")) { - return true; - } - } - return false; - } - /** * Extracts the target namespace for the tRPC router based on the controller. If the class is not * annotated with @Trpc, or if its value is empty, it returns null (indicating the methods belong @@ -680,21 +660,6 @@ public void setOutputFile(String outputFile) { if (outputFile != null && !outputFile.isBlank()) this.outputFile = outputFile; } - /** - * @return True if standard Jooby and JAX-RS annotations are included in the generation. - */ - public boolean isExpandLookup() { - return expandLookup; - } - - /** - * @param expandLookup Set to true to generate endpoints for standard web annotations even - * without @Trpc. - */ - public void setExpandLookup(boolean expandLookup) { - this.expandLookup = expandLookup; - } - /** * @return The target JSON library for data model generation. */ diff --git a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java index e4b659dd1f..1c7347cfa2 100644 --- a/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java +++ b/modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java @@ -7,6 +7,7 @@ import java.util.concurrent.CompletableFuture; +import io.jooby.Context; import io.jooby.annotation.*; import reactor.core.publisher.Mono; @@ -16,7 +17,7 @@ public class C3863 { @GET("/{id}") @Trpc - public U3863 getUser(String id) { + public U3863 getUser(Context ctx, String id) { return new U3863(id, "user"); } From ecb3a1cd95595d0c9085ac6ad58636ecfc9ddce7 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sat, 7 Mar 2026 20:38:36 -0300 Subject: [PATCH 15/16] - document new features --- docs/asciidoc/tRPC.adoc | 184 ++++++++++++++++++ docs/asciidoc/web.adoc | 2 + .../main/java/io/jooby/annotation/Trpc.java | 69 ++++++- .../main/java/io/jooby/trpc/TrpcDecoder.java | 31 ++- .../java/io/jooby/trpc/TrpcErrorCode.java | 70 ++++++- .../java/io/jooby/trpc/TrpcErrorHandler.java | 42 ++++ .../java/io/jooby/trpc/TrpcException.java | 70 +++++++ .../main/java/io/jooby/trpc/TrpcModule.java | 52 ++++- .../main/java/io/jooby/trpc/TrpcParser.java | 48 ++++- .../main/java/io/jooby/trpc/TrpcReader.java | 80 +++++++- .../main/java/io/jooby/trpc/TrpcResponse.java | 32 +++ 11 files changed, 654 insertions(+), 26 deletions(-) create mode 100644 docs/asciidoc/tRPC.adoc diff --git a/docs/asciidoc/tRPC.adoc b/docs/asciidoc/tRPC.adoc new file mode 100644 index 0000000000..15973342dc --- /dev/null +++ b/docs/asciidoc/tRPC.adoc @@ -0,0 +1,184 @@ +=== tRPC + +The tRPC module provides end-to-end type safety by integrating the https://trpc.io/[tRPC] protocol directly into Jooby. + +Because the `io.jooby.trpc` package is included in Jooby core, there are no extra dependencies to add to your project. This integration allows you to write standard Java/Kotlin controllers and consume them directly in the browser using the official `@trpc/client`—complete with 100% type safety, autocomplete, and zero manual client generation. + +==== Usage + +Because tRPC relies heavily on JSON serialization to communicate with the frontend client, a JSON module **must** be installed prior to the `TrpcModule`. + +NOTE: Currently, Jooby only provides the required `TrpcParser` SPI implementation for two JSON engines: **Jackson 2/3** and **AvajeJsonbModule** . Using other JSON modules (like Gson) will result in a missing service exception at startup. + +[source, java] +---- +import io.jooby.Jooby; +import io.jooby.json.JacksonModule; +import io.jooby.trpc.TrpcModule; + +public class App extends Jooby { + { + install(new JacksonModule()); // <1> + + install(new TrpcModule()); // <2> + + install(new MovieService_()); // <3> + } +} +---- + +1. Install a supported JSON engine (Jackson or Avaje) +2. Install the tRPC extension +3. Register your @Trpc annotated controllers (using the APT generated route) + +==== Writing a Service + +You can define your procedures using explicit tRPC annotations or a hybrid approach combining tRPC with standard HTTP methods: + +* **Explicit Annotations:** Use `@Trpc.Query` (maps to `GET`) and `@Trpc.Mutation` (maps to `POST`). +* **Hybrid Annotations:** Combine the base `@Trpc` annotation with Jooby's standard HTTP annotations. A `@GET` resolves to a tRPC query, while state-changing methods (`@POST`, `@PUT`, `@DELETE`) resolve to tRPC mutations. + +.MovieService +[source, java] +---- +import io.jooby.annotation.Trpc; +import io.jooby.annotation.DELETE; + +public record Movie(int id, String title, int year) {} + +@Trpc("movies") // Defines the 'movies' namespace +public class MovieService { + + // 1. Explicit tRPC Query + @Trpc.Query + public Movie getById(int id) { + return new Movie(id, "Pulp Fiction", 1994); + } + + // 2. Explicit tRPC Mutation + @Trpc.Mutation + public Movie create(Movie movie) { + // Save to database logic here + return movie; + } + + // 3. Hybrid Mutation + @Trpc + @DELETE + public void delete(int id) { + // Delete from database + } +} +---- + +==== Build Tool Configuration + +To generate the `trpc.d.ts` TypeScript definitions, you must configure the Jooby build plugin for your project. The generator parses your source code and emits the definitions during the compilation phase. + +.pom.xml +[source, xml, role = "primary", subs="verbatim,attributes"] +---- + + io.jooby + jooby-maven-plugin + ${jooby.version} + + + + trpc + + + + + jackson2 + ${project.build.outputDirectory} + + +---- + +.gradle.build +[source, groovy, role = "secondary", subs="verbatim,attributes"] +---- +plugins { + id 'io.jooby.trpc' version "${joobyVersion}" +} + +trpc { + // Optional settings + jsonLibrary = 'jackson2' +} +---- + +==== Consuming the API (Frontend) + +Once the project is compiled, the build plugin generates a `trpc.d.ts` file containing your exact `AppRouter` shape. You can then use the official client in your TypeScript frontend: + +[source, bash] +---- +npm install @trpc/client +---- + +[source, typescript] +---- +import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import type { AppRouter } from './target/classes/trpc'; // Path to generated file + +// Initialize the strongly-typed client +export const trpc = createTRPCProxyClient({ + links: [ + httpLink({ + url: 'http://localhost:8080/trpc', + }), + ], +}); + +// 100% Type-safe! IDEs will autocomplete namespaces, inputs, and outputs. +const movie = await trpc.movies.getById.query(1); +console.log(`Fetched: ${movie.title} (${movie.year})`); +---- + +==== Advanced Configuration + +===== Custom Exception Mapping +The tRPC protocol expects specific JSON-RPC error codes (e.g., `-32600` for Bad Request). `TrpcModule` automatically registers a specialized error handler to format these errors. + +If you throw custom domain exceptions, you can map them directly to tRPC error codes using the service registry so the frontend client receives the correct error state: + +[source, java] +---- +import io.jooby.trpc.TrpcErrorCode; + +{ + install(new TrpcModule()); + + // Map your custom business exception to a standard tRPC error code + getServices().mapOf(Class.class, TrpcErrorCode.class) + .put(IllegalArgumentException.class, TrpcErrorCode.BAD_REQUEST) + .put(MovieNotFoundException.class, TrpcErrorCode.NOT_FOUND); +} +---- + +===== Custom TypeScript Mappings +Sometimes you have custom Java types (like `java.util.UUID` or `java.math.BigDecimal`) that you want translated into specific TypeScript primitives. You can define these overrides in your build tool: + +**Maven:** +[source, xml] +---- + + + string + number + + +---- + +**Gradle:** +[source, groovy] +---- +trpc { + customTypeMappings = [ + 'java.util.UUID': 'string', + 'java.math.BigDecimal': 'number' + ] +} +---- diff --git a/docs/asciidoc/web.adoc b/docs/asciidoc/web.adoc index 6f63aa8da1..ffd262d3ce 100644 --- a/docs/asciidoc/web.adoc +++ b/docs/asciidoc/web.adoc @@ -10,4 +10,6 @@ include::session.adoc[] include::server-sent-event.adoc[] +include::tRPC.adoc[] + include::websocket.adoc[] diff --git a/jooby/src/main/java/io/jooby/annotation/Trpc.java b/jooby/src/main/java/io/jooby/annotation/Trpc.java index d85b3cab25..a3988077ff 100644 --- a/jooby/src/main/java/io/jooby/annotation/Trpc.java +++ b/jooby/src/main/java/io/jooby/annotation/Trpc.java @@ -11,40 +11,89 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** Marks a controller class or a specific route method for tRPC TypeScript generation. */ +/** + * Marks a controller class or a specific route method for tRPC TypeScript generation. + * + *

    When applied to a class, it defines a namespace for the tRPC router. All tRPC-annotated + * methods within the class will be grouped under this namespace in the generated TypeScript {@code + * AppRouter}. + * + *

    Defining Procedures: + * + *

    There are two ways to expose a method as a tRPC procedure: + * + *

      + *
    • Explicit tRPC Annotations: Use {@link Trpc.Query} for read-only operations (mapped + * to HTTP GET) and {@link Trpc.Mutation} for state-changing operations (mapped to HTTP POST). + *
    • Hybrid HTTP Annotations: Combine the base {@code @Trpc} annotation with standard + * HTTP annotations. A {@code @GET} annotation maps to a query, while {@code @POST}, + * {@code @PUT}, {@code @PATCH}, and {@code @DELETE} map to a mutation. + *
    + * + *

    Network Payloads: + * + *

    Because tRPC natively supports only a single input payload, Java methods with multiple + * parameters will automatically require a JSON array (Tuple) from the frontend client. Framework + * parameters like {@code io.jooby.Context} are ignored during payload calculation. + * + *

    Example: + * + *

    {@code
    + * @Trpc("movies") // Defines the 'movies' namespace
    + * public class MovieService {
    + *
    + * @Trpc.Query // Becomes 'movies.list' query
    + * public List list() { ... }
    + *
    + * @Trpc // Hybrid approach: Becomes 'movies.delete' mutation
    + * @DELETE
    + * public void delete(int id) { ... }
    + * }
    + * }
    + */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Trpc { + /** + * Marks a method as a tRPC mutation. + * + *

    Mutations are used for creating, updating, or deleting data. Under the hood, Jooby will + * automatically expose this method as an HTTP POST route on the {@code /trpc} endpoint. + */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented @interface Mutation { /** - * Custom name for the tRPC procedure. + * Custom name for the tRPC mutation. * - *

    If applied to a method, this overrides the generated procedure name. If applied to a - * class, this overrides the generated namespace/router name. + *

    This overrides the generated procedure name in the TypeScript router. * * @return The custom procedure name. Empty by default, which means the generator will use the - * Java method or class name. + * Java method name. */ String value() default ""; } + /** + * Marks a method as a tRPC query. + * + *

    Queries are strictly used for fetching data. Under the hood, Jooby will automatically expose + * this method as an HTTP GET route on the {@code /trpc} endpoint. + */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented @interface Query { /** - * Custom name for the tRPC procedure. + * Custom name for the tRPC query. * - *

    If applied to a method, this overrides the generated procedure name. If applied to a - * class, this overrides the generated namespace/router name. + *

    This overrides the generated procedure name in the TypeScript router. * * @return The custom procedure name. Empty by default, which means the generator will use the - * Java method or class name. + * Java method name. */ String value() default ""; } @@ -53,7 +102,7 @@ * Custom name for the tRPC procedure or namespace. * *

    If applied to a method, this overrides the generated procedure name. If applied to a class, - * this overrides the generated namespace/router name. + * this overrides the generated namespace in the {@code AppRouter}. * * @return The custom procedure or namespace name. Empty by default, which means the generator * will use the Java method or class name. diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java index faccf4d940..43f7491813 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcDecoder.java @@ -5,9 +5,38 @@ */ package io.jooby.trpc; -/** A pre-resolved decoder used at RUNTIME for single-argument methods. */ +/** + * A pre-resolved decoder used at runtime to deserialize tRPC network payloads into complex Java + * objects. + * + *

    This interface is heavily utilized by the Jooby Annotation Processor (APT). When compiling + * tRPC-annotated controllers, the APT generates highly optimized routing code that resolves the + * appropriate {@code TrpcDecoder} for each method argument. By pre-resolving these decoders, Jooby + * efficiently parses incoming JSON payloads without incurring reflection overhead on every request. + * + *

    Note: Primitive types and standard wrappers (like {@code int}, {@code String}, {@code + * boolean}) are typically handled directly by the {@code TrpcReader} rather than requiring a + * dedicated decoder. + * + * @param The target Java type this decoder produces. + */ public interface TrpcDecoder { + + /** + * Decodes a raw byte array payload into the target Java object. + * + * @param name The name of the parameter being decoded (useful for error reporting or wrapping). + * @param payload The raw JSON byte array received from the network. + * @return The fully deserialized Java object. + */ T decode(String name, byte[] payload); + /** + * Decodes a string payload into the target Java object. + * + * @param name The name of the parameter being decoded (useful for error reporting or wrapping). + * @param payload The JSON string received from the network. + * @return The fully deserialized Java object. + */ T decode(String name, String payload); } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java index cb86ef84a0..c10b92b68f 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorCode.java @@ -7,39 +7,107 @@ import io.jooby.StatusCode; +/** + * Maps standard Jooby HTTP status codes to official tRPC error codes. + * + *

    The tRPC specification dictates that failed requests must return specific JSON-RPC style + * integer codes (e.g., -32600) in the JSON response payload, alongside the standard HTTP status + * codes. This enumeration defines the canonical mapping between Jooby's {@link StatusCode} and the + * expected tRPC error shape. + * + *

    When an exception is thrown within a tRPC route, Jooby uses this mapping to format the error + * response so the frontend {@code @trpc/client} can correctly parse and reconstruct the {@code + * TRPCClientError}. + */ public enum TrpcErrorCode { + + /** Invalid routing or parameters. Mapped to HTTP 400. */ BAD_REQUEST(-32600, StatusCode.BAD_REQUEST), + + /** Invalid JSON was received by the server. Mapped to HTTP 400. */ PARSE_ERROR(-32700, StatusCode.BAD_REQUEST), + + /** Internal server error. Mapped to HTTP 500. */ INTERNAL_SERVER_ERROR(-32603, StatusCode.SERVER_ERROR), + + /** Missing or invalid authentication. Mapped to HTTP 401. */ UNAUTHORIZED(-32001, StatusCode.UNAUTHORIZED), + + /** Authenticated user lacks required permissions. Mapped to HTTP 403. */ FORBIDDEN(-32003, StatusCode.FORBIDDEN), + + /** The requested resource or tRPC procedure was not found. Mapped to HTTP 404. */ NOT_FOUND(-32004, StatusCode.NOT_FOUND), + + /** + * The HTTP method used is not supported by the procedure (e.g., GET on a mutation). Mapped to + * HTTP 405. + */ METHOD_NOT_SUPPORTED(-32005, StatusCode.METHOD_NOT_ALLOWED), + + /** The request took too long to process. Mapped to HTTP 408. */ TIMEOUT(-32008, StatusCode.REQUEST_TIMEOUT), + + /** State conflict, such as a duplicate database entry. Mapped to HTTP 409. */ CONFLICT(-32009, StatusCode.CONFLICT), + + /** The client's preconditions were not met. Mapped to HTTP 412. */ PRECONDITION_FAILED(-32012, StatusCode.PRECONDITION_FAILED), + + /** The incoming request payload exceeds the allowed limits. Mapped to HTTP 413. */ PAYLOAD_TOO_LARGE(-32013, StatusCode.REQUEST_ENTITY_TOO_LARGE), + + /** The payload format is valid, but the content is semantically incorrect. Mapped to HTTP 422. */ UNPROCESSABLE_CONTENT(-32022, StatusCode.UNPROCESSABLE_ENTITY), + + /** Rate limiting applied. Mapped to HTTP 429. */ TOO_MANY_REQUESTS(-32029, StatusCode.TOO_MANY_REQUESTS), + + /** The client disconnected before the server could respond. Mapped to HTTP 499. */ CLIENT_CLOSED_REQUEST(-32099, StatusCode.CLIENT_CLOSED_REQUEST); private final int rpcCode; private final StatusCode statusCode; + /** + * Defines a tRPC error code mapping. + * + * @param rpcCode The JSON-RPC 2.0 compatible integer code specified by tRPC. + * @param statusCode The HTTP status code to return in the response headers. + */ TrpcErrorCode(int rpcCode, StatusCode statusCode) { this.rpcCode = rpcCode; this.statusCode = statusCode; } + /** + * Retrieves the JSON-RPC style integer code. + * + * @return The tRPC integer code (e.g., -32600). + */ public int getRpcCode() { return rpcCode; } + /** + * Retrieves the corresponding HTTP status code. + * + * @return The Jooby {@link StatusCode}. + */ public StatusCode getStatusCode() { return statusCode; } - /** Helper to map a standard Jooby HTTP status code to the closest tRPC equivalent. */ + /** + * Resolves the closest tRPC error code for a given Jooby HTTP status code. + * + *

    If an exact match is not found for the provided HTTP status, this method falls back to + * {@link #INTERNAL_SERVER_ERROR}. + * + * @param status The Jooby HTTP status code. + * @return The corresponding {@code TrpcErrorCode}, or {@code INTERNAL_SERVER_ERROR} if no match + * exists. + */ public static TrpcErrorCode of(StatusCode status) { for (var code : values()) { if (code.statusCode.value() == status.value()) { diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java index 983fa8b8a8..aef5d3124b 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcErrorHandler.java @@ -14,7 +14,33 @@ import io.jooby.Reified; import io.jooby.StatusCode; +/** + * A specialized error handler that formats exceptions into tRPC-compliant JSON responses. + * + *

    This handler strictly listens for requests where the path begins with {@code /trpc/}. When an + * exception occurs during a tRPC procedure, this handler ensures the response matches the exact + * JSON envelope expected by the frontend {@code @trpc/client}, preventing the client from crashing + * due to unexpected HTML or plain-text error pages. + * + *

    Custom Exception Mapping: + * + *

    By default, this handler translates standard Jooby {@link StatusCode}s into their + * corresponding {@link TrpcErrorCode}. However, you can register a custom {@code Map, + * TrpcErrorCode>} in the Jooby registry to map your own domain-specific exceptions directly to tRPC + * codes. + * + *

    If the thrown exception is already a {@link TrpcException}, it is rendered directly without + * modification. + */ public class TrpcErrorHandler implements ErrorHandler { + + /** + * Applies the tRPC error formatting if the request is a tRPC call. + * + * @param ctx The current routing context. + * @param cause The exception that was thrown during the request lifecycle. + * @param code The default HTTP status code resolved by Jooby. + */ @Override public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) { if (ctx.getRequestPath().startsWith("/trpc/")) { @@ -22,17 +48,33 @@ public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull Statu if (cause instanceof TrpcException) { trpcError = (TrpcException) cause; } else { + // Attempt to look up any user-defined exception mappings from the registry Map, TrpcErrorCode> customMapping = ctx.require(Reified.map(Class.class, TrpcErrorCode.class)); + + // Extract the target procedure name from the URL path var procedure = ctx.getRequestPath().replace("/trpc/", ""); + + // Build the tRPC exception, falling back to the default HTTP status mapping if no custom + // map matches trpcError = new TrpcException( procedure, errorCode(customMapping, cause).orElse(TrpcErrorCode.of(code)), cause); } + + // Render the response using the exact structure expected by the @trpc/client ctx.setResponseCode(trpcError.getStatusCode()).render(trpcError.toMap()); } } + /** + * Evaluates the given exception against the registered custom exception mappings. + * + * @param mappings A map of Exception classes to specific tRPC error codes. + * @param x The exception to evaluate. + * @return An {@code Optional} containing the matched {@code TrpcErrorCode}, or empty if no match + * is found. + */ private Optional errorCode(Map, TrpcErrorCode> mappings, Throwable x) { for (var mapping : mappings.entrySet()) { if (mapping.getKey().isInstance(x)) { diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcException.java b/jooby/src/main/java/io/jooby/trpc/TrpcException.java index a0e02461fe..592805bb64 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcException.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcException.java @@ -12,38 +12,108 @@ import io.jooby.StatusCode; +/** + * A specialized runtime exception that encapsulates a tRPC error. + * + *

    This exception is thrown when a tRPC procedure fails, either due to framework-level issues + * (like invalid JSON parsing or missing arguments) or user-thrown domain errors. It holds all the + * necessary context—the procedure name, the specific {@link TrpcErrorCode}, and the underlying + * cause—required to construct a compliant tRPC error response. + * + *

    The frontend {@code @trpc/client} relies on a very specific JSON envelope to correctly + * reconstruct the {@code TRPCClientError} on the client side. The {@link #toMap()} method handles + * translating this Java exception into that exact nested map structure. + */ public class TrpcException extends RuntimeException { private final String procedure; private final TrpcErrorCode errorCode; + /** + * Constructs a new tRPC exception using a standard Jooby HTTP status code. + * + * @param procedure The name of the tRPC procedure that failed (e.g., "movies.getById"). + * @param code The Jooby HTTP status code, which will be mapped to its closest tRPC equivalent. + * @param cause The underlying exception that caused this failure. + */ public TrpcException(String procedure, StatusCode code, Throwable cause) { this(procedure, TrpcErrorCode.of(code), cause); } + /** + * Constructs a new tRPC exception using a specific tRPC error code. + * + * @param procedure The name of the tRPC procedure that failed. + * @param code The explicit tRPC error code. + * @param cause The underlying exception that caused this failure. + */ public TrpcException(String procedure, TrpcErrorCode code, Throwable cause) { super(procedure + ": " + code.name(), cause); this.procedure = procedure; this.errorCode = code; } + /** + * Constructs a new tRPC exception without an underlying cause. + * + * @param procedure The name of the tRPC procedure that failed. + * @param code The explicit tRPC error code. + */ public TrpcException(String procedure, TrpcErrorCode code) { super(procedure + ": " + code.name()); this.procedure = procedure; this.errorCode = code; } + /** + * Constructs a new tRPC exception using a standard Jooby HTTP status code without an underlying + * cause. + * + * @param procedure The name of the tRPC procedure that failed. + * @param code The Jooby HTTP status code. + */ public TrpcException(String procedure, StatusCode code) { this(procedure, TrpcErrorCode.of(code)); } + /** + * Gets the HTTP status code associated with this error. + * + * @return The Jooby {@link StatusCode} to be set in the HTTP response headers. + */ public StatusCode getStatusCode() { return errorCode.getStatusCode(); } + /** + * Gets the name of the tRPC procedure that threw this exception. + * + * @return The procedure name (e.g., "movies.getById"). + */ public String getProcedure() { return procedure; } + /** + * Serializes the exception into the exact JSON structure expected by the tRPC protocol. + * + *

    The resulting map translates into the following JSON shape: + * + *

    {@code
    +   * {
    +   * "error": {
    +   * "message": "The descriptive error message",
    +   * "code": -32600,
    +   * "data": {
    +   * "code": "BAD_REQUEST",
    +   * "httpStatus": 400,
    +   * "path": "movies.getById"
    +   * }
    +   * }
    +   * }
    +   * }
    + * + * @return A nested map representing the JSON error envelope. + */ public Map toMap() { Map data = new LinkedHashMap<>(); data.put("code", errorCode.name()); diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java index 23cec03253..f3877861b0 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcModule.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcModule.java @@ -9,15 +9,65 @@ import io.jooby.Extension; import io.jooby.Jooby; +/** + * Jooby extension that enables tRPC support for the application. + * + *

    This module is responsible for bootstrapping the required tRPC infrastructure, specifically + * the specialized error handling and the parameter parsing mechanisms needed to handle tRPC network + * payloads. + * + *

    Prerequisites: + * + *

    Because tRPC relies heavily on JSON serialization, a JSON module (such as {@code + * JacksonModule}, or {@code AvajeJsonbModule}) must be installed before installing this + * module. The JSON module automatically registers the underlying {@link TrpcParser} that this + * extension requires. + * + *

    Usage: + * + *

    {@code
    + * {
    + * // 1. Install a JSON engine (Prerequisite)
    + * install(new JacksonModule());
    + *
    + * // 2. Install the tRPC extension
    + * install(new TrpcModule());
    + *
    + * // 3. Register your @Trpc annotated controllers
    + * mvc(new MovieService_());
    + * }
    + * }
    + */ public class TrpcModule implements Extension { + + /** + * Installs the tRPC extension into the Jooby application. + * + *

    During installation, this method performs the following setup: + * + *

      + *
    • Validates that a {@link TrpcParser} is available in the service registry. + *
    • Initializes the registry map for custom {@code Class} to {@link TrpcErrorCode} mappings, + * allowing developers to map domain exceptions to specific tRPC errors. + *
    • Registers the {@link TrpcErrorHandler} globally to intercept and correctly format + * exceptions thrown from {@code /trpc/*} endpoints. + *
    + * + * @param app The current Jooby application. + * @throws Exception If a required service (such as the {@code TrpcParser}) is missing from the + * registry. + */ @Override public void install(@NonNull Jooby app) throws Exception { var services = app.getServices(); + // Ensure a JSON module has provided the necessary parser services.require(TrpcParser.class); - // Custom mapping for TrpcErrorCode + + // Initialize the custom exception mapping registry services.mapOf(Class.class, TrpcErrorCode.class); + // Register the specialized JSON-RPC error formatter app.error(new TrpcErrorHandler()); } } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java index b8e2e9d7ce..757c9c21f3 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcParser.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcParser.java @@ -8,28 +8,58 @@ import java.lang.reflect.Type; /** - * Factory provided by the JSON module (Jackson or Avaje). Used by the APT-generated routes AT - * STARTUP to cache deserializers. + * The core JSON parsing SPI (Service Provider Interface) for tRPC. + * + *

    This factory is implemented by Jooby's JSON modules (such as Jackson, Gson, JSON-B, or Avaje) + * and serves as the bridge between incoming tRPC network payloads and the application's Java + * objects. + * + *

    Startup Optimization: + * + *

    The Jooby Annotation Processor (APT) generates highly optimized routing code that relies on + * this interface. At application startup, the generated routes use this parser to eagerly resolve + * and cache type-specific {@link TrpcDecoder}s. This ensures that during actual HTTP requests, + * payloads are deserialized with near-zero reflection overhead. */ public interface TrpcParser { - /** Resolves and caches the deserializer for a specific type during route initialization. */ + /** + * Resolves and caches a type-specific deserializer during route initialization. + * + *

    This method is invoked by the APT-generated code when the application starts. By eagerly + * requesting a decoder for complex generic types, the framework avoids expensive reflection + * lookups during runtime request processing. + * + * @param type The target Java type (e.g., {@code List}, {@code User}) to decode. + * @param The expected Java type. + * @return A highly optimized decoder capable of parsing network payloads into the target type. + */ TrpcDecoder decoder(Type type); /** - * Creates a sequential reader for parsing tRPC arguments from a JSON array. + * Creates a stateful, sequential reader for extracting tRPC arguments from a raw byte payload. + * + *

    Because tRPC network payloads can either be a single value or a JSON array (tuple) of + * multiple arguments, this reader manages the cursor position to sequentially hand off the + * correct JSON segment to the pre-resolved {@link TrpcDecoder}s. * - * @param payload A JSON array containing the method arguments. - * @param isTuple If the payload is a tuple. + * @param payload The raw JSON byte array received from the network. + * @param isTuple {@code true} if the payload is a JSON array representing multiple arguments; + * {@code false} if it represents a single, standalone argument. * @return A reader for sequential argument extraction. */ TrpcReader reader(byte[] payload, boolean isTuple); /** - * Creates a sequential reader for parsing tRPC arguments from a JSON array. + * Creates a stateful, sequential reader for extracting tRPC arguments from a string payload. + * + *

    Because tRPC network payloads can either be a single value or a JSON array (tuple) of + * multiple arguments, this reader manages the cursor position to sequentially hand off the + * correct JSON segment to the pre-resolved {@link TrpcDecoder}s. * - * @param payload A JSON array containing the method arguments. - * @param isTuple If the payload is a tuple. + * @param payload The JSON string received from the network. + * @param isTuple {@code true} if the payload is a JSON array representing multiple arguments; + * {@code false} if it represents a single, standalone argument. * @return A reader for sequential argument extraction. */ TrpcReader reader(String payload, boolean isTuple); diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java index 76683f6262..a2a1646a00 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcReader.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcReader.java @@ -8,30 +8,102 @@ import io.jooby.exception.MissingValueException; /** - * A sequential reader used at RUNTIME for multi-argument methods. Allows the APT code to extract - * arguments one by one, preserving primitives. + * A stateful, sequential reader used at runtime to extract arguments from a tRPC network payload. + * + *

    When a tRPC client sends a request for a multi-argument method, it packs the arguments into a + * JSON array (a tuple). This reader acts as a cursor over that array. The Jooby Annotation + * Processor (APT) generates routing code that calls the {@code next*()} methods on this interface + * sequentially, perfectly matching the order of parameters in the target Java method. + * + *

    Zero-Boxing Performance: + * + *

    To maximize throughput, this interface provides dedicated methods for extracting primitive + * types (e.g., {@link #nextInt(String)}, {@link #nextBoolean(String)}). This allows the underlying + * JSON parser (like Jackson or AvajeJsonb) to read numbers and booleans directly off the byte + * stream without ever boxing them into heap-allocated objects like {@code Integer} or {@code + * Boolean}. + * + *

    Because it holds resources (like an open {@code JsonParser}), it implements {@link + * AutoCloseable} and must be closed after the final argument is read. */ public interface TrpcReader extends AutoCloseable { + /** + * Peeks at the next token in the JSON stream to determine if it is a literal {@code null}. + * + * @param name The logical name of the parameter being evaluated (used for context or error + * reporting). + * @return {@code true} if the next token is null; {@code false} otherwise. + */ boolean nextIsNull(String name); - // 2. Default helper for non-nullable primitives to fail fast + /** + * Asserts that the next token in the sequence is not null. + * + *

    This is a fast-fail mechanism generated by the APT for non-nullable primitive arguments + * (e.g., {@code int}, {@code double}) or explicitly required parameters. + * + * @param name The logical name of the parameter. + * @throws MissingValueException If the next token evaluates to null. + */ default void requireNext(String name) { if (nextIsNull(name)) { throw new MissingValueException(name); } } + /** + * Reads the next token in the stream as a primitive 32-bit integer. + * + * @param name The logical name of the parameter. + * @return The extracted integer value. + */ int nextInt(String name); + /** + * Reads the next token in the stream as a primitive 64-bit integer. + * + * @param name The logical name of the parameter. + * @return The extracted long value. + */ long nextLong(String name); + /** + * Reads the next token in the stream as a primitive boolean. + * + * @param name The logical name of the parameter. + * @return The extracted boolean value. + */ boolean nextBoolean(String name); + /** + * Reads the next token in the stream as a primitive 64-bit floating-point number. + * + * @param name The logical name of the parameter. + * @return The extracted double value. + */ double nextDouble(String name); + /** + * Reads the next token in the stream as a String. + * + * @param name The logical name of the parameter. + * @return The extracted String value, or {@code null} if the token is a JSON null. + */ String nextString(String name); - // Object extractor using a pre-resolved decoder + /** + * Reads the next token (which may be a complex JSON object or array) and deserializes it using a + * pre-resolved type decoder. + * + *

    This method delegates the heavy lifting of mapping arbitrary JSON structures to the + * underlying JSON engine (Jackson, Gson, etc.) via the provided {@link TrpcDecoder}. + * + * @param name The logical name of the parameter. + * @param decoder The optimized decoder created during application startup to handle this specific + * type. + * @param The target Java type. + * @return The fully deserialized Java object, or {@code null} if the token is a JSON null. + */ T nextObject(String name, TrpcDecoder decoder); } diff --git a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java index e3631a46ff..5bfcc9938f 100644 --- a/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java +++ b/jooby/src/main/java/io/jooby/trpc/TrpcResponse.java @@ -8,12 +8,44 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; +/** + * A standardized envelope for successful tRPC responses. + * + *

    Unlike standard REST endpoints which might return raw JSON objects or arrays directly, the + * tRPC protocol requires all successful procedure calls to wrap their actual payload inside a + * specific JSON envelope (specifically, a {@code data} property). + * + *

    This immutable record ensures that the Jooby routing engine serializes the returned Java + * objects into the exact shape expected by the frontend {@code @trpc/client}, preventing parsing + * errors on the browser side. + * + * @param The type of the underlying data being returned. + * @param data The actual payload to serialize to the client, or {@code null} if the procedure has + * no return value. + */ public record TrpcResponse(@Nullable T data) { + /** + * Wraps a non-null payload into a compliant tRPC success envelope. + * + * @param data The actual data to return to the client (e.g., a specific {@code Movie} or {@code + * List}). + * @param The type of the data. + * @return A tRPC response envelope containing the provided data. + */ public static @NonNull TrpcResponse of(@NonNull T data) { return new TrpcResponse<>(data); } + /** + * Creates an empty tRPC success envelope. + * + *

    This is typically used by the Jooby routing engine to construct compliant network responses + * for procedures that return {@code void} or explicitly return no data. + * + * @param The inferred type (usually {@code Void} or {@code Object}). + * @return A tRPC response envelope where the data property is explicitly null. + */ public static @NonNull TrpcResponse empty() { return new TrpcResponse<>(null); } From ced533163cc644fb758c49420f62c425abd7be46 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Sun, 8 Mar 2026 10:07:42 -0300 Subject: [PATCH 16/16] - add jackson 2 implementation --- modules/jooby-jackson/pom.xml | 4 + .../internal/jackson/JacksonTrpcDecoder.java | 39 +++++ .../internal/jackson/JacksonTrpcParser.java | 51 ++++++ .../internal/jackson/JacksonTrpcReader.java | 153 ++++++++++++++++++ .../JacksonTrpcResponseSerializer.java | 38 +++++ .../java/io/jooby/jackson/JacksonModule.java | 11 ++ .../java/io/jooby/i3863/TrpcProtocolTest.java | 6 + 7 files changed, 302 insertions(+) create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java create mode 100644 modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index e1eeea18d6..ab61531b36 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -71,6 +71,10 @@ 1.37 test + + jakarta.json.bind + jakarta.json.bind-api + diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java new file mode 100644 index 0000000000..54f7c19806 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcDecoder.java @@ -0,0 +1,39 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.ObjectReader; +import io.jooby.SneakyThrows; +import io.jooby.trpc.TrpcDecoder; + +public class JacksonTrpcDecoder implements TrpcDecoder { + + final ObjectReader reader; + + public JacksonTrpcDecoder(ObjectReader reader) { + this.reader = reader; + } + + @Override + public T decode(String name, byte[] payload) { + try { + return reader.readValue(payload); + } catch (IOException x) { + throw SneakyThrows.propagate(x); + } + } + + @Override + public T decode(String name, String payload) { + try { + return reader.readValue(payload); + } catch (IOException x) { + throw SneakyThrows.propagate(x); + } + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java new file mode 100644 index 0000000000..9a733f930b --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcParser.java @@ -0,0 +1,51 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import java.io.IOException; +import java.lang.reflect.Type; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jooby.SneakyThrows; +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcReader; + +public class JacksonTrpcParser implements TrpcParser { + + private final ObjectMapper mapper; + + public JacksonTrpcParser(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public TrpcDecoder decoder(Type type) { + // Resolve the type exactly once at startup + var javaType = mapper.constructType(type); + var reader = mapper.readerFor(javaType).without(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + return new JacksonTrpcDecoder<>(reader); + } + + @Override + public TrpcReader reader(byte[] payload, boolean isTuple) { + try { + return new JacksonTrpcReader(mapper.createParser(payload), isTuple); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public TrpcReader reader(String payload, boolean isTuple) { + try { + return new JacksonTrpcReader(mapper.createParser(payload), isTuple); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java new file mode 100644 index 0000000000..c265810520 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcReader.java @@ -0,0 +1,153 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import io.jooby.SneakyThrows; +import io.jooby.exception.MissingValueException; +import io.jooby.trpc.TrpcDecoder; +import io.jooby.trpc.TrpcReader; + +public class JacksonTrpcReader implements TrpcReader { + private final JsonParser parser; + private boolean hasPeeked = false; + private final boolean isTuple; + private boolean isFirstRead = true; + + public JacksonTrpcReader(JsonParser parser, boolean isTuple) { + this.parser = parser; + this.isTuple = isTuple; + var token = nextToken(); + if (isTuple && token != JsonToken.START_ARRAY) { + throw new IllegalArgumentException("Expected tRPC tuple array"); + } + } + + private JsonToken nextToken() { + try { + return parser.nextToken(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public boolean nextIsNull(String name) { + if (!hasPeeked) { + advance(name); + hasPeeked = true; + } + + if (parser.currentToken() == JsonToken.VALUE_NULL) { + hasPeeked = false; // Consume the null token + return true; + } + + return false; + } + + private void ensureNext(String name) { + if (hasPeeked) { + hasPeeked = false; + return; + } + advance(name); + } + + private void ensureNonNull(String name) { + if (parser.currentToken() == JsonToken.VALUE_NULL) throw new MissingValueException(name); + } + + private void advance(String name) { + // If it's a seamless raw value, we are ALREADY on the token. Do not advance. + if (!isTuple) { + if (!isFirstRead) throw new MissingValueException(name); + isFirstRead = false; + // The constructor already positioned us on the root token. Do not advance. + return; + } + + var token = nextToken(); + if (token == JsonToken.END_ARRAY || token == null) { + throw new MissingValueException(name); + } + } + + @Override + public int nextInt(String name) { + ensureNext(name); + ensureNonNull(name); + try { + return parser.getIntValue(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public long nextLong(String name) { + ensureNext(name); + ensureNonNull(name); + try { + return parser.getLongValue(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public boolean nextBoolean(String name) { + ensureNext(name); + ensureNonNull(name); + try { + return parser.getBooleanValue(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public double nextDouble(String name) { + ensureNext(name); + ensureNonNull(name); + try { + return parser.getDoubleValue(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public String nextString(String name) { + ensureNext(name); + ensureNonNull(name); + try { + return parser.getText(); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public T nextObject(String name, TrpcDecoder decoder) { + try { + ensureNext(name); + ensureNonNull(name); + JacksonTrpcDecoder jacksonDecoder = (JacksonTrpcDecoder) decoder; + return jacksonDecoder.reader.readValue(parser); + } catch (IOException e) { + throw SneakyThrows.propagate(e); + } + } + + @Override + public void close() throws Exception { + parser.close(); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java new file mode 100644 index 0000000000..d7dc2552d1 --- /dev/null +++ b/modules/jooby-jackson/src/main/java/io/jooby/internal/jackson/JacksonTrpcResponseSerializer.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import io.jooby.trpc.TrpcResponse; + +public class JacksonTrpcResponseSerializer extends StdSerializer { + public JacksonTrpcResponseSerializer() { + super(TrpcResponse.class); + } + + @Override + public void serialize(TrpcResponse value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeStartObject(); // { + + gen.writeFieldName("result"); + gen.writeStartObject(); // "result": { + + var data = value.data(); + // Only write the "data" key if the method actually returned something (not void/Unit) + if (data != null) { + gen.writeFieldName("data"); + gen.writePOJO(data); + } + + gen.writeEndObject(); + gen.writeEndObject(); + } +} diff --git a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java index 401b80115b..30ebbd86b2 100644 --- a/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java +++ b/modules/jooby-jackson/src/main/java/io/jooby/jackson/JacksonModule.java @@ -30,7 +30,11 @@ import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import edu.umd.cs.findbugs.annotations.NonNull; import io.jooby.*; +import io.jooby.internal.jackson.JacksonTrpcParser; +import io.jooby.internal.jackson.JacksonTrpcResponseSerializer; import io.jooby.output.Output; +import io.jooby.trpc.TrpcParser; +import io.jooby.trpc.TrpcResponse; /** * JSON module using Jackson: https://jooby.io/modules/jackson2. @@ -150,6 +154,9 @@ public void install(@NonNull Jooby application) { application.errorCode(JsonParseException.class, StatusCode.BAD_REQUEST); application.errorCode(MismatchedInputException.class, StatusCode.BAD_REQUEST); + // tRPC + services.put(TrpcParser.class, new JacksonTrpcParser(mapper)); + // Filter var defaultProvider = new SimpleFilterProvider().setFailOnUnknownId(false); mapper.addMixIn(Object.class, ProjectionMixIn.class); @@ -221,6 +228,10 @@ public Object decode(Context ctx, Type type) throws Exception { .addModule(new JavaTimeModule()); Stream.of(modules).forEach(builder::addModule); + // tRPC + var trpcModule = new SimpleModule(); + trpcModule.addSerializer(TrpcResponse.class, new JacksonTrpcResponseSerializer()); + builder.addModule(trpcModule); return builder.build(); } diff --git a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java index 26f299e1cd..3ee57fddbc 100644 --- a/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java +++ b/tests/src/test/java/io/jooby/i3863/TrpcProtocolTest.java @@ -12,6 +12,7 @@ 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; @@ -25,6 +26,11 @@ 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());