Skip to content

Commit 4b748a5

Browse files
committed
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<V>`) 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.
1 parent ec14f04 commit 4b748a5

7 files changed

Lines changed: 263 additions & 44 deletions

File tree

modules/jooby-apt/src/main/java/io/jooby/internal/apt/MvcRoute.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,12 @@ private HttpMethod trpcMethod(Element element) {
11111111
if (HttpMethod.GET.matches(element)) {
11121112
return HttpMethod.GET;
11131113
}
1114-
if (HttpMethod.POST.matches(element)) {
1114+
1115+
// Map all state-changing HTTP annotations to a tRPC POST mutation
1116+
if (HttpMethod.POST.matches(element)
1117+
|| HttpMethod.PUT.matches(element)
1118+
|| HttpMethod.PATCH.matches(element)
1119+
|| HttpMethod.DELETE.matches(element)) {
11151120
return HttpMethod.POST;
11161121
}
11171122

@@ -1121,8 +1126,9 @@ private HttpMethod trpcMethod(Element element) {
11211126
+ element.getSimpleName()
11221127
+ "() in "
11231128
+ element.getEnclosingElement().getSimpleName()
1124-
+ " is annotated with @Trpc but lacks @GET or @POST. Please annotate the method with"
1125-
+ " @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET or @POST.");
1129+
+ " is annotated with @Trpc but lacks a valid HTTP method annotation. Please annotate"
1130+
+ " the method with @Trpc.Query, @Trpc.Mutation, or combine @Trpc with @GET, @POST,"
1131+
+ " @PUT, @PATCH, or @DELETE.");
11261132
}
11271133
return null;
11281134
}

modules/jooby-trpc/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@
8181
<artifactId>mockito-core</artifactId>
8282
<scope>test</scope>
8383
</dependency>
84+
<dependency>
85+
<groupId>io.projectreactor</groupId>
86+
<artifactId>reactor-core</artifactId>
87+
<version>3.8.3</version>
88+
</dependency>
8489
<dependency>
8590
<groupId>org.assertj</groupId>
8691
<artifactId>assertj-core</artifactId>

modules/jooby-trpc/src/main/java/io/jooby/trpc/TrpcGenerator.java

Lines changed: 214 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,32 @@
5252
*/
5353
public class TrpcGenerator {
5454

55+
private static final Set<String> WRAPPERS =
56+
Set.of(
57+
// Protocol Envelope
58+
"io.jooby.trpc.TrpcResponse",
59+
// JDK Async
60+
"java.util.concurrent.CompletableFuture",
61+
"java.util.concurrent.CompletionStage",
62+
"java.util.concurrent.Future",
63+
// Reactor
64+
"reactor.core.publisher.Mono",
65+
"reactor.core.publisher.Flux",
66+
// Mutiny
67+
"io.smallrye.mutiny.Uni",
68+
"io.smallrye.mutiny.Multi",
69+
// RxJava 2 & 3
70+
"io.reactivex.Single",
71+
"io.reactivex.Maybe",
72+
"io.reactivex.Observable",
73+
"io.reactivex.Flowable",
74+
"io.reactivex.Completable",
75+
"io.reactivex.rxjava3.core.Single",
76+
"io.reactivex.rxjava3.core.Maybe",
77+
"io.reactivex.rxjava3.core.Observable",
78+
"io.reactivex.rxjava3.core.Flowable",
79+
"io.reactivex.rxjava3.core.Completable");
80+
5581
private final Logger log = LoggerFactory.getLogger(getClass());
5682

5783
private ClassLoader classLoader = getClass().getClassLoader();
@@ -103,9 +129,9 @@ public void generate() throws IOException {
103129
if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method);
104130

105131
if (includeMethod) {
106-
typesToGenerate.add(method.getGenericReturnType());
132+
addTypeToGenerate(typesToGenerate, method.getGenericReturnType());
107133
for (var param : method.getGenericParameterTypes()) {
108-
typesToGenerate.add(param);
134+
addTypeToGenerate(typesToGenerate, param);
109135
}
110136
}
111137
}
@@ -125,6 +151,31 @@ public void generate() throws IOException {
125151
// 2. Generate standard interfaces (DTOs only)
126152
if (!typesToGenerate.isEmpty()) {
127153
TypeScriptGenerator.setLogger(asSlf4j(log));
154+
155+
/// FIREWALL: Force the generator to ignore all reactive wrappers if discovered indirectly.
156+
// typescript-generator strictly demands exact generic signatures (e.g. Future<V>).
157+
// We dynamically append the type variables using reflection to satisfy its parser.
158+
for (String wrapper : WRAPPERS) {
159+
try {
160+
Class<?> clazz = Class.forName(wrapper, false, classLoader);
161+
StringBuilder key = new StringBuilder(wrapper);
162+
var typeParams = clazz.getTypeParameters();
163+
164+
if (typeParams.length > 0) {
165+
key.append("<");
166+
for (int i = 0; i < typeParams.length; i++) {
167+
if (i > 0) key.append(", ");
168+
key.append(typeParams[i].getName());
169+
}
170+
key.append(">");
171+
}
172+
173+
settings.customTypeMappings.put(key.toString(), "any");
174+
} catch (ClassNotFoundException | NoClassDefFoundError ignored) {
175+
// Class not on classpath, safe to ignore
176+
}
177+
}
178+
128179
var generator = new TypeScriptGenerator(settings);
129180
var input = Input.from(typesToGenerate.toArray(new Type[0]));
130181
generator.generateTypeScript(input, Output.to(finalOutput.toFile()));
@@ -155,6 +206,45 @@ protected void write(Level level, String message) {
155206
};
156207
}
157208

209+
private void addTypeToGenerate(Set<Type> types, Type type) {
210+
if (type == void.class || type == Void.class) return;
211+
212+
if (type instanceof ParameterizedType pt) {
213+
var rawName = pt.getRawType().getTypeName();
214+
215+
// Unwrap async and protocol wrapper types
216+
if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) {
217+
addTypeToGenerate(types, pt.getActualTypeArguments()[0]);
218+
return;
219+
}
220+
221+
// Decompose standard generic types to ensure inner DTOs are discovered safely
222+
addTypeToGenerate(types, pt.getRawType());
223+
for (Type arg : pt.getActualTypeArguments()) {
224+
addTypeToGenerate(types, arg);
225+
}
226+
return;
227+
} else if (type instanceof Class<?> clazz) {
228+
// Ignore bare async types (like RxJava Completable) that signify void
229+
if (WRAPPERS.contains(clazz.getName())) return;
230+
231+
if (clazz.isArray()) {
232+
addTypeToGenerate(types, clazz.getComponentType());
233+
return;
234+
}
235+
} else if (type instanceof java.lang.reflect.WildcardType wt) {
236+
for (Type bound : wt.getUpperBounds()) {
237+
if (bound != Object.class) addTypeToGenerate(types, bound);
238+
}
239+
return;
240+
} else if (type instanceof java.lang.reflect.GenericArrayType gat) {
241+
addTypeToGenerate(types, gat.getGenericComponentType());
242+
return;
243+
}
244+
245+
types.add(type);
246+
}
247+
158248
/**
159249
* Constructs and appends the tRPC {@code AppRouter} mapping to the bottom of the generated file.
160250
*
@@ -168,46 +258,71 @@ private void appendTrpcRouter(Path finalOutput, Set<Class<?>> controllers) throw
168258
ts.append("\n// --- tRPC Router Mapping ---\n\n");
169259
ts.append("export type AppRouter = {\n");
170260

261+
// 1. Group by namespace using a TreeMap to guarantee deterministic alphabetical order
262+
Map<String, List<Method>> namespaces = new java.util.TreeMap<>();
263+
171264
for (var controller : controllers) {
172265
var namespace = extractNamespace(controller);
173-
String indent = " "; // Default indent for root methods
266+
String key = namespace == null ? "" : namespace;
174267

175-
if (namespace != null) {
176-
ts.append(" ").append(namespace).append(": {\n");
177-
indent = " "; // Increase indent for nested methods
178-
}
268+
namespaces.computeIfAbsent(key, k -> new ArrayList<>());
179269

180270
for (var method : controller.getDeclaredMethods()) {
181271
boolean includeMethod = isTrpcAnnotated(method);
182272
if (!includeMethod && expandLookup) includeMethod = hasWebAnnotation(method);
183273

184274
if (includeMethod) {
185-
var params = method.getGenericParameterTypes();
186-
String tsInput = "void";
187-
188-
// Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple
189-
if (params.length == 1) {
190-
tsInput = resolveTsType(params[0]);
191-
} else if (params.length > 1) {
192-
var tuple = new ArrayList<String>();
193-
for (var p : params) tuple.add(resolveTsType(p));
194-
tsInput = "[" + String.join(", ", tuple) + "]";
195-
}
275+
namespaces.get(key).add(method);
276+
}
277+
}
278+
}
279+
280+
// 2. Generate the TypeScript output
281+
for (var entry : namespaces.entrySet()) {
282+
String namespace = entry.getKey();
283+
List<Method> methods = entry.getValue();
196284

197-
String tsOutput = resolveTsType(method.getGenericReturnType());
198-
String procedureName = getProcedureName(method);
285+
if (methods.isEmpty()) continue;
199286

200-
ts.append(indent)
201-
.append(procedureName)
202-
.append(": { input: ")
203-
.append(tsInput)
204-
.append("; output: ")
205-
.append(tsOutput)
206-
.append(" };\n");
287+
String indent = " ";
288+
if (!namespace.isEmpty()) {
289+
ts.append(" ").append(namespace).append(": {\n");
290+
indent = " ";
291+
}
292+
293+
// Separate into queries and mutations for deterministic grouping
294+
List<Method> queries = new ArrayList<>();
295+
List<Method> mutations = new ArrayList<>();
296+
297+
for (Method m : methods) {
298+
if (isMutation(m)) {
299+
mutations.add(m);
300+
} else {
301+
queries.add(m);
302+
}
303+
}
304+
305+
// Sort alphabetically by procedure name to prevent reflection-order test flakiness
306+
java.util.Comparator<Method> byName = java.util.Comparator.comparing(this::getProcedureName);
307+
queries.sort(byName);
308+
mutations.sort(byName);
309+
310+
if (!queries.isEmpty()) {
311+
ts.append(indent).append("// queries\n");
312+
for (Method method : queries) {
313+
appendProcedure(ts, indent, method);
314+
}
315+
}
316+
317+
if (!mutations.isEmpty()) {
318+
if (!queries.isEmpty()) ts.append("\n"); // Add a blank line between groups if both exist
319+
ts.append(indent).append("// mutations\n");
320+
for (Method method : mutations) {
321+
appendProcedure(ts, indent, method);
207322
}
208323
}
209324

210-
if (namespace != null) {
325+
if (!namespace.isEmpty()) {
211326
ts.append(" };\n");
212327
}
213328
}
@@ -216,6 +331,61 @@ private void appendTrpcRouter(Path finalOutput, Set<Class<?>> controllers) throw
216331
Files.writeString(finalOutput, ts.toString(), StandardOpenOption.APPEND);
217332
}
218333

334+
private void appendProcedure(StringBuilder ts, String indent, Method method) {
335+
var params = method.getGenericParameterTypes();
336+
String tsInput = "void";
337+
338+
// Seamless tRPC: single arguments are raw, multiple arguments are packed in a tuple
339+
if (params.length == 1) {
340+
tsInput = resolveTsType(params[0]);
341+
} else if (params.length > 1) {
342+
var tuple = new ArrayList<String>();
343+
for (var p : params) tuple.add(resolveTsType(p));
344+
tsInput = "[" + String.join(", ", tuple) + "]";
345+
}
346+
347+
String tsOutput = resolveTsType(method.getGenericReturnType());
348+
String procedureName = getProcedureName(method);
349+
350+
ts.append(indent)
351+
.append(procedureName)
352+
.append(": { input: ")
353+
.append(tsInput)
354+
.append("; ")
355+
.append("output: ")
356+
.append(tsOutput)
357+
.append(" };\n");
358+
}
359+
360+
private boolean isMutation(Method method) {
361+
// 1. Explicit tRPC mutation annotation
362+
if (getAnnotation(method, "io.jooby.annotation.Trpc$Mutation") != null) {
363+
return true;
364+
}
365+
// 2. Explicit tRPC query annotation
366+
if (getAnnotation(method, "io.jooby.annotation.Trpc$Query") != null) {
367+
return false;
368+
}
369+
370+
// 3. Base @Trpc combined with standard HTTP mutation annotations
371+
String[] httpMutations = {
372+
"io.jooby.annotation.POST", "io.jooby.annotation.PUT",
373+
"io.jooby.annotation.PATCH", "io.jooby.annotation.DELETE",
374+
"jakarta.ws.rs.POST", "jakarta.ws.rs.PUT",
375+
"jakarta.ws.rs.PATCH", "jakarta.ws.rs.DELETE",
376+
"javax.ws.rs.POST", "javax.ws.rs.PUT",
377+
"javax.ws.rs.PATCH", "javax.ws.rs.DELETE"
378+
};
379+
380+
for (String ann : httpMutations) {
381+
if (getAnnotation(method, ann) != null) {
382+
return true;
383+
}
384+
}
385+
386+
return false; // Default to query
387+
}
388+
219389
/**
220390
* Fast, recursive type resolver to map Java types directly to TypeScript signatures. Understands
221391
* Jooby async types, standard collections, and primitive mappings.
@@ -230,13 +400,8 @@ private String resolveTsType(Type type) {
230400
var raw = pt.getRawType();
231401
var rawName = raw.getTypeName();
232402

233-
// Unwrap async and protocol wrapper types (TrpcResponse, CompletableFuture, Mono, Single,
234-
// Future)
235-
if (rawName.endsWith("TrpcResponse")
236-
|| rawName.endsWith("CompletableFuture")
237-
|| rawName.endsWith("Single")
238-
|| rawName.endsWith("Mono")
239-
|| rawName.endsWith("Future")) {
403+
// Unwrap async and protocol wrapper types
404+
if (WRAPPERS.contains(rawName) && pt.getActualTypeArguments().length > 0) {
240405
return resolveTsType(pt.getActualTypeArguments()[0]);
241406
}
242407

@@ -259,13 +424,27 @@ private String resolveTsType(Type type) {
259424
}
260425
}
261426

427+
if (type instanceof java.lang.reflect.WildcardType wt) {
428+
if (wt.getUpperBounds().length > 0 && wt.getUpperBounds()[0] != Object.class) {
429+
return resolveTsType(wt.getUpperBounds()[0]);
430+
}
431+
return "any";
432+
}
433+
434+
if (type instanceof java.lang.reflect.GenericArrayType gat) {
435+
return resolveTsType(gat.getGenericComponentType()) + "[]";
436+
}
437+
262438
if (type instanceof Class<?> clazz) {
263439
if (clazz.isArray()) {
264440
if (clazz.getComponentType() == byte.class)
265441
return "string"; // Common byte[] to base64 string
266442
return resolveTsType(clazz.getComponentType()) + "[]";
267443
}
268444

445+
// Handle bare async types (like RxJava Completable) as void returns
446+
if (WRAPPERS.contains(clazz.getName())) return "void";
447+
269448
if (clazz == String.class
270449
|| clazz == char.class
271450
|| clazz == Character.class

modules/jooby-trpc/src/test/java/io/jooby/trpc/i3863/C3863.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
*/
66
package io.jooby.trpc.i3863;
77

8-
import io.jooby.annotation.GET;
9-
import io.jooby.annotation.POST;
10-
import io.jooby.annotation.Path;
11-
import io.jooby.annotation.Trpc;
8+
import java.util.concurrent.CompletableFuture;
9+
10+
import io.jooby.annotation.*;
11+
import reactor.core.publisher.Mono;
1212

1313
@Path("/users")
1414
@Trpc("users") // Custom namespace
@@ -26,6 +26,17 @@ public U3863 createUser(U3863 user) {
2626
return user;
2727
}
2828

29+
@PUT
30+
@Trpc
31+
public CompletableFuture<U3863> createFuture(U3863 user) {
32+
return CompletableFuture.completedFuture(user);
33+
}
34+
35+
@Trpc.Mutation
36+
public Mono<U3863> createMono(U3863 user) {
37+
return Mono.just(user);
38+
}
39+
2940
@GET("/internal")
3041
public String internalEndpoint() {
3142
return "This should not be exposed to tRPC";

0 commit comments

Comments
 (0)