Skip to content

Commit f95a779

Browse files
committed
Message IDs can be strings or numbers
1 parent bf8bdbf commit f95a779

File tree

7 files changed

+135
-13
lines changed

7 files changed

+135
-13
lines changed

src/main/java/io/moderne/jsonrpc/JsonRpc.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public class JsonRpc {
3131
private volatile boolean shutdown = false;
3232

3333
private final MessageHandler messageHandler;
34-
private final Map<String, CompletableFuture<JsonRpcSuccess>> openRequests = new ConcurrentHashMap<>();
34+
private final Map<Object, CompletableFuture<JsonRpcSuccess>> openRequests = new ConcurrentHashMap<>();
3535

3636
public <P> JsonRpc rpc(String name, JsonRpcMethod<P> method) {
3737
methods.put(name, method);
@@ -55,12 +55,12 @@ public JsonRpc bind() {
5555
@Override
5656
protected void compute() {
5757
while (!shutdown) {
58-
String requestId = null;
58+
Object requestId = null;
5959
try {
6060
JsonRpcMessage msg = messageHandler.receive();
6161
if (msg instanceof JsonRpcResponse) {
6262
JsonRpcResponse response = (JsonRpcResponse) msg;
63-
String id = response.getId();
63+
Object id = response.getId();
6464
if (id != null) {
6565
CompletableFuture<JsonRpcSuccess> responseFuture = openRequests.remove(id);
6666
if (response instanceof JsonRpcError) {

src/main/java/io/moderne/jsonrpc/JsonRpcError.java

+10-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.moderne.jsonrpc;
1717

18+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
1819
import lombok.EqualsAndHashCode;
1920
import lombok.Value;
2021
import org.jspecify.annotations.Nullable;
@@ -25,7 +26,9 @@
2526
@Value
2627
@EqualsAndHashCode(callSuper = false)
2728
public class JsonRpcError extends JsonRpcResponse {
28-
String id;
29+
@JsonDeserialize(using = JsonRpcIdDeserializer.class)
30+
Object id;
31+
2932
Detail error;
3033

3134
@Value
@@ -37,27 +40,27 @@ public static class Detail {
3740
String data;
3841
}
3942

40-
public static JsonRpcError parseError(String id) {
43+
public static JsonRpcError parseError(Object id) {
4144
return new JsonRpcError(id, new Detail(-32700, "Parse error", null));
4245
}
4346

44-
public static JsonRpcError invalidRequest(String id, String message) {
47+
public static JsonRpcError invalidRequest(Object id, String message) {
4548
return new JsonRpcError(id, new Detail(-32600, "Invalid Request: " + message, null));
4649
}
4750

48-
public static JsonRpcError methodNotFound(String id, String method) {
51+
public static JsonRpcError methodNotFound(Object id, String method) {
4952
return new JsonRpcError(id, new Detail(-32601, "Method not found: " + method, null));
5053
}
5154

52-
public static JsonRpcError invalidParams(String id) {
55+
public static JsonRpcError invalidParams(Object id) {
5356
return new JsonRpcError(id, new Detail(-32602, "Invalid params", null));
5457
}
5558

56-
public static JsonRpcError internalError(String id, String message) {
59+
public static JsonRpcError internalError(Object id, String message) {
5760
return new JsonRpcError(id, new Detail(-32603, "Internal error: " + message, null));
5861
}
5962

60-
public static JsonRpcError internalError(String id, Throwable t) {
63+
public static JsonRpcError internalError(Object id, Throwable t) {
6164
StringWriter sw = new StringWriter();
6265
t.printStackTrace(new PrintWriter(sw));
6366
return new JsonRpcError(id, new Detail(-32603, "Internal error: " + t.getMessage(), sw.toString()));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.moderne.jsonrpc;
17+
18+
import com.fasterxml.jackson.core.JsonParser;
19+
import com.fasterxml.jackson.core.ObjectCodec;
20+
import com.fasterxml.jackson.databind.DeserializationContext;
21+
import com.fasterxml.jackson.databind.JsonDeserializer;
22+
import com.fasterxml.jackson.databind.JsonNode;
23+
24+
import java.io.IOException;
25+
26+
public class JsonRpcIdDeserializer extends JsonDeserializer<Object> {
27+
28+
@Override
29+
public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
30+
ObjectCodec codec = jsonParser.getCodec();
31+
JsonNode jsonNode = codec.readTree(jsonParser);
32+
if (jsonNode.isNumber()) {
33+
// The assumption here is that the id is either a String or an Integer, and likely
34+
// an Integer that is no larger than JavaScripts `Number.MAX_SAFE_INTEGER` since
35+
// any JSON-RPC client interacting with a JavaScript peer wouldn't be able to send
36+
// integer values larger than that without JavaScript converting that integer to a
37+
// float, losing precision, and therefore not being able to associate requests/responses
38+
// with the correct id.
39+
return jsonNode.asInt();
40+
} else if (jsonNode.isTextual()) {
41+
return jsonNode.asText();
42+
} else if (jsonNode.isNull()) {
43+
return null;
44+
} else {
45+
throw new IOException("A JSON-RPC ID according to the spec \"MUST contain a String, Number, or NULL value if included\". See §4 of https://www.jsonrpc.org/specification.");
46+
}
47+
}
48+
}

src/main/java/io/moderne/jsonrpc/JsonRpcMessage.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@
2121
public abstract class JsonRpcMessage {
2222
private final String jsonrpc = "2.0";
2323

24-
public abstract String getId();
24+
public abstract Object getId();
2525
}

src/main/java/io/moderne/jsonrpc/JsonRpcRequest.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.moderne.jsonrpc;
1717

18+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
1819
import io.moderne.jsonrpc.internal.SnowflakeId;
1920
import lombok.EqualsAndHashCode;
2021
import lombok.Value;
@@ -23,7 +24,9 @@
2324
@Value
2425
@EqualsAndHashCode(callSuper = false)
2526
public class JsonRpcRequest extends JsonRpcMessage {
26-
String id;
27+
@JsonDeserialize(using = JsonRpcIdDeserializer.class)
28+
Object id;
29+
2730
String method;
2831

2932
/**

src/main/java/io/moderne/jsonrpc/JsonRpcSuccess.java

+6-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.fasterxml.jackson.annotation.JsonInclude;
1919
import com.fasterxml.jackson.databind.DeserializationFeature;
2020
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
2122
import com.fasterxml.jackson.databind.cfg.ConstructorDetector;
2223
import com.fasterxml.jackson.databind.json.JsonMapper;
2324
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@@ -38,7 +39,11 @@ public class JsonRpcSuccess extends JsonRpcResponse {
3839
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
3940
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
4041

41-
String id;
42+
/**
43+
* String or Integer
44+
*/
45+
@JsonDeserialize(using = JsonRpcIdDeserializer.class)
46+
Object id;
4247

4348
/**
4449
* No need for polymorphic deserialization here, since the result type will
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.moderne.jsonrpc.formatter;
17+
18+
import io.moderne.jsonrpc.JsonRpcMessage;
19+
import org.junit.jupiter.api.Disabled;
20+
import org.junit.jupiter.api.Test;
21+
22+
import java.io.ByteArrayInputStream;
23+
import java.io.IOException;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
27+
28+
public class JsonMessageFormatterTest {
29+
JsonMessageFormatter formatter = new JsonMessageFormatter();
30+
31+
@Test
32+
void idAsString() throws IOException {
33+
assertThat(message("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}").getId()).isEqualTo("1");
34+
}
35+
36+
@Test
37+
void idAsNumber() throws IOException {
38+
assertThat(message("{\"jsonrpc\":\"2.0\",\"id\":1}").getId()).isEqualTo(1);
39+
}
40+
41+
@Test
42+
void idAsNull() throws IOException {
43+
assertThat(message("{\"jsonrpc\":\"2.0\",\"id\":null}").getId()).isNull();
44+
}
45+
46+
@Test
47+
void idNotIncluded() throws IOException {
48+
assertThat(message("{\"jsonrpc\":\"2.0\"}").getId()).isNull();
49+
}
50+
51+
@Disabled
52+
@Test
53+
void idAsObjectFails() {
54+
assertThatThrownBy(() -> message("{\"jsonrpc\":\"2.0\",\"id\":[]}").getId())
55+
.hasMessageContaining("MUST");
56+
}
57+
58+
private JsonRpcMessage message(String x) throws IOException {
59+
return formatter.deserialize(new ByteArrayInputStream(
60+
x.getBytes(formatter.getEncoding())
61+
));
62+
}
63+
}

0 commit comments

Comments
 (0)