Skip to content

Commit ec14f04

Browse files
committed
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.
1 parent 7fb4297 commit ec14f04

11 files changed

Lines changed: 278 additions & 237 deletions

File tree

jooby/src/main/java/io/jooby/trpc/TrpcParser.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@ public interface TrpcParser {
2020
* Creates a sequential reader for parsing tRPC arguments from a JSON array.
2121
*
2222
* @param payload A JSON array containing the method arguments.
23+
* @param isTuple If the payload is a tuple.
2324
* @return A reader for sequential argument extraction.
2425
*/
25-
TrpcReader reader(byte[] payload);
26+
TrpcReader reader(byte[] payload, boolean isTuple);
2627

2728
/**
2829
* Creates a sequential reader for parsing tRPC arguments from a JSON array.
2930
*
3031
* @param payload A JSON array containing the method arguments.
32+
* @param isTuple If the payload is a tuple.
3133
* @return A reader for sequential argument extraction.
3234
*/
33-
TrpcReader reader(String payload);
35+
TrpcReader reader(String payload, boolean isTuple);
3436
}

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

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -326,49 +326,68 @@ public List<String> generateHandlerCall(boolean kt) {
326326
string("input"),
327327
").value()",
328328
semicolon(kt)));
329-
if (kt) {
330-
buffer.add(
331-
statement(
332-
indent(2),
333-
"if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)"
334-
+ " throw IllegalArgumentException(",
335-
string("tRPC input must be a JSON array (tuple)"),
336-
")"));
337-
} else {
338-
buffer.add(
339-
statement(
340-
indent(2),
341-
"if (input == null || input.length() < 2 || input.charAt(0) != '[' ||"
342-
+ " input.charAt(input.length() - 1) != ']') throw new"
343-
+ " IllegalArgumentException(",
344-
string("tRPC input must be a JSON array (tuple)"),
345-
");"));
329+
330+
// Only enforce JSON array tuples if there are multiple arguments
331+
if (parameters.size() > 1) {
332+
if (kt) {
333+
buffer.add(
334+
statement(
335+
indent(2),
336+
"if (input?.trim()?.let { it.startsWith('[') && it.endsWith(']') } != true)"
337+
+ " throw IllegalArgumentException(",
338+
string("tRPC input for multiple arguments must be a JSON array (tuple)"),
339+
")"));
340+
} else {
341+
buffer.add(
342+
statement(
343+
indent(2),
344+
"if (input == null || input.length() < 2 || input.charAt(0) != '[' ||"
345+
+ " input.charAt(input.length() - 1) != ']') throw new"
346+
+ " IllegalArgumentException(",
347+
string("tRPC input for multiple arguments must be a JSON array (tuple)"),
348+
");"));
349+
}
346350
}
347351
} else {
348352
buffer.add(statement(indent(2), var(kt), "input = ctx.body().bytes()", semicolon(kt)));
349-
if (kt) {
350-
buffer.add(
351-
statement(
352-
indent(2),
353-
"if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]"
354-
+ " != ']'.code.toByte()) throw IllegalArgumentException(",
355-
string("tRPC body must be a JSON array (tuple)"),
356-
")"));
357-
} else {
358-
buffer.add(
359-
statement(
360-
indent(2),
361-
"if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')"
362-
+ " throw new IllegalArgumentException(",
363-
string("tRPC body must be a JSON array (tuple)"),
364-
");"));
353+
354+
// Only enforce JSON array tuples if there are multiple arguments
355+
if (parameters.size() > 1) {
356+
if (kt) {
357+
buffer.add(
358+
statement(
359+
indent(2),
360+
"if (input.size < 2 || input[0] != '['.code.toByte() || input[input.size - 1]"
361+
+ " != ']'.code.toByte()) throw IllegalArgumentException(",
362+
string("tRPC body for multiple arguments must be a JSON array (tuple)"),
363+
")"));
364+
} else {
365+
buffer.add(
366+
statement(
367+
indent(2),
368+
"if (input.length < 2 || input[0] != '[' || input[input.length - 1] != ']')"
369+
+ " throw new IllegalArgumentException(",
370+
string("tRPC body for multiple arguments must be a JSON array (tuple)"),
371+
");"));
372+
}
365373
}
366374
}
367375

376+
boolean isTuple = parameters.size() > 1;
368377
if (kt) {
369-
buffer.add(statement(indent(2), "parser.reader(input).use { reader -> "));
378+
buffer.add(
379+
statement(
380+
indent(2),
381+
"parser.reader(input, ",
382+
String.valueOf(isTuple),
383+
").use { reader -> "));
370384
} else {
371-
buffer.add(statement(indent(2), "try (var reader = parser.reader(input)) {"));
385+
buffer.add(
386+
statement(
387+
indent(2),
388+
"try (var reader = parser.reader(input, ",
389+
String.valueOf(isTuple),
390+
")) {"));
372391
}
373392

374393
buffer.addAll(generateTrpcParameter(kt, paramList::add));

modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcParser.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ public <T> TrpcDecoder<T> decoder(Type type) {
2727
}
2828

2929
@Override
30-
public TrpcReader reader(byte[] payload) {
31-
return new AvajeTrpcReader(jsonb.reader(payload));
30+
public TrpcReader reader(byte[] payload, boolean isTuple) {
31+
return new AvajeTrpcReader(jsonb.reader(payload), isTuple);
3232
}
3333

3434
@Override
35-
public TrpcReader reader(String payload) {
36-
return new AvajeTrpcReader(jsonb.reader(payload));
35+
public TrpcReader reader(String payload, boolean isTuple) {
36+
return new AvajeTrpcReader(jsonb.reader(payload), isTuple);
3737
}
3838
}

modules/jooby-avaje-jsonb/src/main/java/io/jooby/internal/avaje/jsonb/AvajeTrpcReader.java

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,44 +13,52 @@
1313
public class AvajeTrpcReader implements TrpcReader {
1414
private final JsonReader reader;
1515
private boolean hasPeeked = false;
16+
private final boolean isTuple;
17+
private boolean isFirstRead = true;
1618

17-
public AvajeTrpcReader(JsonReader reader) {
19+
public AvajeTrpcReader(JsonReader reader, boolean isTuple) {
1820
this.reader = reader;
19-
reader.beginArray();
21+
this.isTuple = isTuple;
22+
if (isTuple) {
23+
reader.beginArray();
24+
}
2025
}
2126

2227
@Override
2328
public boolean nextIsNull(String name) {
2429
if (!hasPeeked) {
25-
if (!reader.hasNextElement()) {
26-
throw new MissingValueException(name);
27-
}
28-
hasPeeked = true; // We successfully advanced the cursor to a value
30+
ensureNextState(name);
31+
hasPeeked = true;
2932
}
3033

3134
if (reader.isNullValue()) {
32-
// Avaje requires us to actively skip the null token to consume it
3335
reader.skipValue();
34-
hasPeeked = false; // Reset because the value is consumed
36+
hasPeeked = false;
3537
return true;
3638
}
3739

38-
// It's not null. We leave hasPeeked = true so the next extraction method doesn't advance again.
3940
return false;
4041
}
4142

43+
private void ensureNextState(String name) {
44+
if (isTuple) {
45+
if (!reader.hasNextElement()) {
46+
throw new MissingValueException(name);
47+
}
48+
} else {
49+
if (!isFirstRead) {
50+
throw new MissingValueException(name);
51+
}
52+
isFirstRead = false;
53+
}
54+
}
55+
4256
private void ensureNext(String name) {
4357
if (hasPeeked) {
44-
// We already advanced the stream during nextIsNull().
45-
// Reset the flag since the caller is about to consume the value.
4658
hasPeeked = false;
4759
return;
4860
}
49-
50-
// hasNextElement() checks for ']' and consumes the comma ',' if present.
51-
if (!reader.hasNextElement()) {
52-
throw new MissingValueException(name);
53-
}
61+
ensureNextState(name);
5462
}
5563

5664
private void ensureNonNull(String name) {
@@ -96,18 +104,15 @@ public String nextString(String name) {
96104
public <T> T nextObject(String name, TrpcDecoder<T> decoder) {
97105
ensureNext(name);
98106
ensureNonNull(name);
99-
// Cast to access the underlying Avaje JsonType adapter
100107
AvajeTrpcDecoder<T> avajeDecoder = (AvajeTrpcDecoder<T>) decoder;
101-
102-
// JsonType.fromJson(JsonReader) consumes exactly the tokens needed
103-
// for the object, leaving the stream in the correct position.
104108
return avajeDecoder.typeAdapter.fromJson(reader);
105109
}
106110

107111
@Override
108112
public void close() {
109-
// Consume the closing ']' and close the underlying stream
110-
reader.endArray();
113+
if (isTuple) {
114+
reader.endArray();
115+
}
111116
reader.close();
112117
}
113118
}

modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcParser.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ public <T> TrpcDecoder<T> decoder(Type type) {
3232
}
3333

3434
@Override
35-
public TrpcReader reader(byte[] payload) {
36-
return new JacksonTrpcReader(mapper.createParser(payload));
35+
public TrpcReader reader(byte[] payload, boolean isTuple) {
36+
return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
3737
}
3838

3939
@Override
40-
public TrpcReader reader(String payload) {
41-
return new JacksonTrpcReader(mapper.createParser(payload));
40+
public TrpcReader reader(String payload, boolean isTuple) {
41+
return new JacksonTrpcReader(mapper.createParser(payload), isTuple);
4242
}
4343
}

modules/jooby-jackson3/src/main/java/io/jooby/internal/jackson3/JacksonTrpcReader.java

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
public class JacksonTrpcReader implements TrpcReader {
1515
private final JsonParser parser;
1616
private boolean hasPeeked = false;
17+
private final boolean isTuple;
18+
private boolean isFirstRead = true;
1719

18-
public JacksonTrpcReader(JsonParser parser) {
20+
public JacksonTrpcReader(JsonParser parser, boolean isTuple) {
1921
this.parser = parser;
20-
parser.nextToken();
22+
this.isTuple = isTuple;
23+
var token = parser.nextToken();
24+
if (isTuple && token != tools.jackson.core.JsonToken.START_ARRAY) {
25+
throw new IllegalArgumentException("Expected tRPC tuple array");
26+
}
2127
}
2228

2329
@Override
@@ -32,18 +38,14 @@ public boolean nextIsNull(String name) {
3238
return true;
3339
}
3440

35-
// It's not null. We leave hasPeeked = true so extraction doesn't advance again.
3641
return false;
3742
}
3843

3944
private void ensureNext(String name) {
4045
if (hasPeeked) {
41-
// We already advanced the stream during nextIsNull().
42-
// Reset the flag since the caller is about to consume the value.
4346
hasPeeked = false;
4447
return;
4548
}
46-
4749
advance(name);
4850
}
4951

@@ -52,8 +54,16 @@ private void ensureNonNull(String name) {
5254
}
5355

5456
private void advance(String name) {
55-
JsonToken token = parser.nextToken();
56-
if (token == JsonToken.END_ARRAY || token == null) {
57+
// If it's a seamless raw value, we are ALREADY on the token. Do not advance.
58+
if (!isTuple) {
59+
if (!isFirstRead) throw new MissingValueException(name);
60+
isFirstRead = false;
61+
// The constructor already positioned us on the root token. Do not advance.
62+
return;
63+
}
64+
65+
var token = parser.nextToken();
66+
if (token == tools.jackson.core.JsonToken.END_ARRAY || token == null) {
5767
throw new MissingValueException(name);
5868
}
5969
}
@@ -97,10 +107,6 @@ public String nextString(String name) {
97107
public <T> T nextObject(String name, TrpcDecoder<T> decoder) {
98108
ensureNext(name);
99109
ensureNonNull(name);
100-
101-
// Cast back to our specific implementation to access the underlying ObjectReader.
102-
// This allows us to read complex objects directly from the current position
103-
// in the stream without any intermediate byte[] buffering or allocation.
104110
JacksonTrpcDecoder<T> jacksonDecoder = (JacksonTrpcDecoder<T>) decoder;
105111
return jacksonDecoder.reader.readValue(parser);
106112
}

0 commit comments

Comments
 (0)