Skip to content

Commit 51316fe

Browse files
committed
feat(jsonrpc): implement Jackson and Avaje engines with strict spec compliance
- Add `JsonRpcParser`, `JsonRpcDecoder`, and `JsonRpcReader` implementations for Jackson 2, Jackson 3, and Avaje JSON-B. - Implement custom serializers (`StdSerializer` for Jackson, `JsonAdapter` for Avaje) for `JsonRpcResponse` and `ErrorDetail` to enforce JSON-RPC 2.0 mutual exclusivity (payloads MUST NOT contain both `result` and `error`). - Add robust type coercion to JSON readers to reject invalid primitives and throw `IllegalArgumentException` instead of failing silently. - Refactor `JsonRpcProtocolTest` into `AbstractJsonRpcProtocolTest` to seamlessly run the entire protocol compliance suite against all registered JSON engines.
1 parent 772fcd5 commit 51316fe

File tree

17 files changed

+581
-62
lines changed

17 files changed

+581
-62
lines changed

modules/jooby-avaje-jsonb/src/main/java/io/jooby/avaje/jsonb/AvajeJsonbModule.java

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
import io.avaje.jsonb.JsonView;
1616
import io.avaje.jsonb.Jsonb;
1717
import io.jooby.*;
18-
import io.jooby.internal.avaje.jsonb.AvajeTrpcParser;
19-
import io.jooby.internal.avaje.jsonb.AvajeTrpcResponseAdapter;
20-
import io.jooby.internal.avaje.jsonb.BufferedJsonOutput;
18+
import io.jooby.internal.avaje.jsonb.*;
19+
import io.jooby.jsonrpc.JsonRpcParser;
20+
import io.jooby.jsonrpc.JsonRpcRequest;
21+
import io.jooby.jsonrpc.JsonRpcResponse;
2122
import io.jooby.output.Output;
2223
import io.jooby.trpc.TrpcErrorCode;
2324
import io.jooby.trpc.TrpcParser;
@@ -82,7 +83,7 @@ public AvajeJsonbModule(@NonNull Jsonb jsonb) {
8283

8384
/** Creates a new Avaje-JsonB module. */
8485
public AvajeJsonbModule() {
85-
this(Jsonb.builder().add(TrpcResponse.class, trpcResponseAdapter()).build());
86+
this(builder().build());
8687
}
8788

8889
@Override
@@ -98,6 +99,8 @@ public void install(@NonNull Jooby application) throws Exception {
9899
services
99100
.mapOf(Class.class, TrpcErrorCode.class)
100101
.put(JsonDataException.class, TrpcErrorCode.BAD_REQUEST);
102+
// JSON-RPC
103+
services.put(JsonRpcParser.class, new AvajeJsonRpcParser(jsonb));
101104
}
102105

103106
@Override
@@ -156,12 +159,12 @@ private void encodeProjection(JsonWriter writer, Projected<?> projected) {
156159
view.toJson(value, writer);
157160
}
158161

159-
/**
160-
* Custom adapter for {@link TrpcResponse}.
161-
*
162-
* @return Custom adapter for {@link TrpcResponse}.
163-
*/
164-
public static Jsonb.AdapterBuilder trpcResponseAdapter() {
165-
return AvajeTrpcResponseAdapter::new;
162+
public static Jsonb.Builder builder() {
163+
var jsonb = Jsonb.builder();
164+
jsonb.add(TrpcResponse.class, AvajeTrpcResponseAdapter::new);
165+
jsonb.add(JsonRpcRequest.class, AvajeJsonRpcRequestAdapter::new);
166+
jsonb.add(JsonRpcResponse.class, AvajeJsonRpcResponseAdapter::new);
167+
jsonb.add(JsonRpcResponse.ErrorDetail.class, AvajeJsonRpcErrorAdapter::new);
168+
return jsonb;
166169
}
167170
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.avaje.jsonb;
7+
8+
import io.avaje.jsonb.JsonType;
9+
import io.avaje.jsonb.Jsonb;
10+
import io.jooby.jsonrpc.JsonRpcDecoder;
11+
import io.jooby.jsonrpc.JsonRpcErrorCode;
12+
import io.jooby.jsonrpc.JsonRpcException;
13+
14+
public class AvajeJsonRpcDecoder<T> implements JsonRpcDecoder<T> {
15+
16+
private final Jsonb jsonb;
17+
private final JsonType<T> typeAdapter;
18+
19+
public AvajeJsonRpcDecoder(Jsonb jsonb, JsonType<T> typeAdapter) {
20+
this.jsonb = jsonb;
21+
this.typeAdapter = typeAdapter;
22+
}
23+
24+
@Override
25+
public T decode(String name, Object node) {
26+
try {
27+
if (node == null) {
28+
return null;
29+
}
30+
// Convert the Map/List/primitive back to JSON, then to the target type
31+
// This leverages Avaje's exact mappings without needing a tree traversal model
32+
String json = jsonb.toJson(node);
33+
return typeAdapter.fromJson(json);
34+
} catch (Exception x) {
35+
throw new JsonRpcException(
36+
JsonRpcErrorCode.INVALID_PARAMS,
37+
"Invalid params: unable to map parameter '" + name + "'",
38+
x);
39+
}
40+
}
41+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.avaje.jsonb;
7+
8+
import io.avaje.json.JsonAdapter;
9+
import io.avaje.json.JsonReader;
10+
import io.avaje.json.JsonWriter;
11+
import io.avaje.jsonb.Jsonb;
12+
import io.jooby.jsonrpc.JsonRpcResponse.ErrorDetail;
13+
14+
public class AvajeJsonRpcErrorAdapter implements JsonAdapter<ErrorDetail> {
15+
16+
private final Jsonb jsonb;
17+
18+
public AvajeJsonRpcErrorAdapter(Jsonb jsonb) {
19+
this.jsonb = jsonb;
20+
}
21+
22+
@Override
23+
public void toJson(JsonWriter writer, ErrorDetail error) {
24+
writer.beginObject();
25+
26+
writer.name("code");
27+
writer.value(error.getCode());
28+
29+
writer.name("message");
30+
writer.value(error.getMessage());
31+
32+
if (error.getData() != null) {
33+
writer.name("data");
34+
jsonb.adapter(Object.class).toJson(writer, error.getData());
35+
}
36+
37+
writer.endObject();
38+
}
39+
40+
@Override
41+
public ErrorDetail fromJson(JsonReader reader) {
42+
throw new UnsupportedOperationException("Servers don't deserialize error responses");
43+
}
44+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.avaje.jsonb;
7+
8+
import java.lang.reflect.Type;
9+
10+
import io.avaje.jsonb.Jsonb;
11+
import io.jooby.jsonrpc.JsonRpcDecoder;
12+
import io.jooby.jsonrpc.JsonRpcParser;
13+
import io.jooby.jsonrpc.JsonRpcReader;
14+
15+
public class AvajeJsonRpcParser implements JsonRpcParser {
16+
17+
private final Jsonb jsonb;
18+
19+
public AvajeJsonRpcParser(Jsonb jsonb) {
20+
this.jsonb = jsonb;
21+
}
22+
23+
@Override
24+
public <T> JsonRpcDecoder<T> decoder(Type type) {
25+
return new AvajeJsonRpcDecoder<>(jsonb, jsonb.type(type));
26+
}
27+
28+
@Override
29+
public JsonRpcReader reader(Object params) {
30+
// params will be either a List (positional) or a Map (named)
31+
return new AvajeJsonRpcReader(params);
32+
}
33+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.avaje.jsonb;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import io.jooby.exception.MissingValueException;
12+
import io.jooby.exception.TypeMismatchException;
13+
import io.jooby.jsonrpc.JsonRpcDecoder;
14+
import io.jooby.jsonrpc.JsonRpcReader;
15+
16+
public class AvajeJsonRpcReader implements JsonRpcReader {
17+
18+
private final Map<String, Object> map;
19+
private final List<Object> list;
20+
private int index = 0;
21+
22+
@SuppressWarnings("unchecked")
23+
public AvajeJsonRpcReader(Object params) {
24+
if (params instanceof List) {
25+
this.list = (List<Object>) params;
26+
this.map = null;
27+
} else if (params instanceof Map) {
28+
this.map = (Map<String, Object>) params;
29+
this.list = null;
30+
} else {
31+
this.map = null;
32+
this.list = null;
33+
}
34+
}
35+
36+
private Object peek(String name) {
37+
if (list != null && index < list.size()) {
38+
return list.get(index);
39+
} else if (map != null) {
40+
return map.get(name);
41+
}
42+
return null;
43+
}
44+
45+
private Object consume(String name) {
46+
if (list != null && index < list.size()) {
47+
return list.get(index++);
48+
} else if (map != null) {
49+
return map.get(name);
50+
}
51+
return null;
52+
}
53+
54+
private Object require(String name) {
55+
Object value = consume(name);
56+
if (value == null) {
57+
throw new MissingValueException(name);
58+
}
59+
return value;
60+
}
61+
62+
@Override
63+
public boolean nextIsNull(String name) {
64+
return peek(name) == null;
65+
}
66+
67+
@Override
68+
public int nextInt(String name) {
69+
Object val = require(name);
70+
if (val instanceof Number n) return n.intValue();
71+
throw new TypeMismatchException(name, int.class);
72+
}
73+
74+
@Override
75+
public long nextLong(String name) {
76+
Object val = require(name);
77+
if (val instanceof Number n) return n.longValue();
78+
throw new TypeMismatchException(name, long.class);
79+
}
80+
81+
@Override
82+
public boolean nextBoolean(String name) {
83+
Object val = require(name);
84+
if (val instanceof Boolean b) return b;
85+
throw new TypeMismatchException(name, boolean.class);
86+
}
87+
88+
@Override
89+
public double nextDouble(String name) {
90+
Object val = require(name);
91+
if (val instanceof Number n) return n.doubleValue();
92+
throw new TypeMismatchException(name, double.class);
93+
}
94+
95+
@Override
96+
public String nextString(String name) {
97+
return require(name).toString();
98+
}
99+
100+
@Override
101+
public <T> T nextObject(String name, JsonRpcDecoder<T> decoder) {
102+
Object val = require(name);
103+
return decoder.decode(name, val);
104+
}
105+
106+
@Override
107+
public void close() {
108+
// Nothing to close for in-memory collections
109+
}
110+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.avaje.jsonb;
7+
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import io.avaje.json.JsonAdapter;
12+
import io.avaje.json.JsonReader;
13+
import io.avaje.json.JsonWriter;
14+
import io.avaje.jsonb.JsonType;
15+
import io.avaje.jsonb.Jsonb;
16+
import io.jooby.jsonrpc.JsonRpcRequest;
17+
18+
public class AvajeJsonRpcRequestAdapter implements JsonAdapter<JsonRpcRequest> {
19+
20+
private final JsonType<Object> anyType;
21+
22+
public AvajeJsonRpcRequestAdapter(Jsonb jsonb) {
23+
// The Object.class adapter parses JSON arrays to Lists and objects to Maps
24+
this.anyType = jsonb.type(Object.class);
25+
}
26+
27+
@Override
28+
public JsonRpcRequest fromJson(JsonReader reader) {
29+
Object payload = anyType.fromJson(reader);
30+
31+
if (payload instanceof List<?> list) {
32+
// Spec: Empty array must return a single Invalid Request object (-32600)
33+
if (list.isEmpty()) {
34+
JsonRpcRequest invalid = new JsonRpcRequest();
35+
invalid.setMethod(null);
36+
invalid.setBatch(false);
37+
return invalid;
38+
}
39+
40+
JsonRpcRequest batch = new JsonRpcRequest();
41+
for (Object element : list) {
42+
batch.add(parseSingle(element));
43+
}
44+
return batch;
45+
} else {
46+
return parseSingle(payload);
47+
}
48+
}
49+
50+
private JsonRpcRequest parseSingle(Object node) {
51+
JsonRpcRequest req = new JsonRpcRequest();
52+
53+
if (!(node instanceof Map<?, ?> map)) {
54+
req.setMethod(null); // Triggers -32600 Invalid Request
55+
return req;
56+
}
57+
58+
// 1. Extract ID
59+
Object idVal = map.get("id");
60+
if (idVal != null) {
61+
if (idVal instanceof Number n) {
62+
req.setId(n);
63+
} else if (idVal instanceof String s) {
64+
req.setId(s);
65+
}
66+
}
67+
68+
// 2. Validate JSON-RPC version
69+
Object versionVal = map.get("jsonrpc");
70+
if (!"2.0".equals(versionVal)) {
71+
req.setMethod(null);
72+
return req;
73+
}
74+
75+
// 3. Extract Method
76+
Object methodVal = map.get("method");
77+
if (methodVal instanceof String s) {
78+
req.setMethod(s);
79+
} else {
80+
req.setMethod(null);
81+
}
82+
83+
// 4. Extract Params (Must be Map or List per spec)
84+
Object paramsVal = map.get("params");
85+
if (paramsVal != null) {
86+
if (paramsVal instanceof Map || paramsVal instanceof List) {
87+
req.setParams(paramsVal);
88+
} else {
89+
req.setMethod(null); // Primitive params -> -32600
90+
}
91+
}
92+
93+
return req;
94+
}
95+
96+
@Override
97+
public void toJson(JsonWriter writer, JsonRpcRequest value) {
98+
// We only deserialize inbound requests, servers don't emit them
99+
throw new UnsupportedOperationException("Serialization of JsonRpcRequest is not required");
100+
}
101+
}

0 commit comments

Comments
 (0)