5252 */
5353public 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
0 commit comments