Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions src/main/java/io/moderne/jsonrpc/JsonRpc.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,11 @@
import io.moderne.jsonrpc.formatter.JsonMessageFormatter;
import io.moderne.jsonrpc.formatter.MessageFormatter;
import io.moderne.jsonrpc.handler.MessageHandler;
import lombok.RequiredArgsConstructor;

import java.io.EOFException;
import java.util.Map;
import java.util.concurrent.*;

@RequiredArgsConstructor
public class JsonRpc {
private final ForkJoinPool forkJoin = new ForkJoinPool(
4, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null, true);
Expand All @@ -45,6 +43,11 @@ public JsonRpc(MessageHandler messageHandler) {
this(messageHandler, new JsonMessageFormatter());
}

public JsonRpc(MessageHandler messageHandler, MessageFormatter formatter) {
this.messageHandler = messageHandler;
this.formatter = formatter;
}

public <P> JsonRpc rpc(String name, JsonRpcMethod<P> method) {
methods.put(name, method);
return this;
Expand Down Expand Up @@ -105,18 +108,7 @@ protected void compute() {
messageHandler.send(JsonRpcError.methodNotFound(errorId, errorMethod), formatter)
).fork();
} else {
ForkJoinTask.adapt(() -> {
try {
Object response = method.convertAndHandle(request.getParams(), formatter);
if (response != null) {
messageHandler.send(new JsonRpcSuccess(request.getId(), response), formatter);
} else {
messageHandler.send(JsonRpcError.internalError(request.getId(), "Method returned null"), formatter);
}
} catch (Exception e) {
messageHandler.send(JsonRpcError.internalError(request.getId(), e), formatter);
}
}).fork();
ForkJoinTask.adapt(() -> dispatch(request, method)).fork();
}
}
} catch (EOFException e) {
Expand Down Expand Up @@ -157,6 +149,22 @@ protected void compute() {
return this;
}

private void dispatch(JsonRpcRequest request, JsonRpcMethod<?> method) {
JsonRpcMessage outbound;
try {
Object result = method.convertAndHandle(request.getParams(), formatter);
// Wrap the handler's return value so the on-wire representation
// goes through the same RawJson + Jackson serializer pipeline
// as inbound-converted values.
outbound = result != null
? new JsonRpcSuccess(request.getId(), RawJson.of(result))
: JsonRpcError.internalError(request.getId(), "Method returned null");
} catch (Exception e) {
outbound = JsonRpcError.internalError(request.getId(), e);
}
messageHandler.send(outbound, formatter);
}

public void shutdown() {
shutdown = true;
forkJoin.shutdownNow();
Expand Down
37 changes: 18 additions & 19 deletions src/main/java/io/moderne/jsonrpc/JsonRpcIdDeserializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,32 @@
package io.moderne.jsonrpc;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;

import java.io.IOException;

public class JsonRpcIdDeserializer extends JsonDeserializer<Object> {

@Override
public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
ObjectCodec codec = jsonParser.getCodec();
JsonNode jsonNode = codec.readTree(jsonParser);
if (jsonNode.isNumber()) {
// The assumption here is that the id is either a String or an Integer, and likely
// an Integer that is no larger than JavaScripts `Number.MAX_SAFE_INTEGER` since
// any JSON-RPC client interacting with a JavaScript peer wouldn't be able to send
// integer values larger than that without JavaScript converting that integer to a
// float, losing precision, and therefore not being able to associate requests/responses
// with the correct id.
return jsonNode.asInt();
} else if (jsonNode.isTextual()) {
return jsonNode.asText();
} else if (jsonNode.isNull()) {
return null;
} else {
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.");
public Object deserialize(JsonParser parser, DeserializationContext context) throws IOException {
// Direct token inspection — no JsonNode tree allocation. The id field
// is a scalar by spec ("String, Number, or NULL value if included"),
// so a single switch on the current token covers every legal shape.
switch (parser.currentToken()) {
case VALUE_NUMBER_INT:
// Per the comment that used to live here: assume int. Any
// JSON-RPC client interacting with a JavaScript peer cannot
// send integers larger than Number.MAX_SAFE_INTEGER without
// losing precision, so widening to long isn't worth the API
// change.
return parser.getIntValue();
case VALUE_STRING:
return parser.getText();
case VALUE_NULL:
return null;
default:
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.");
}
}
}
16 changes: 13 additions & 3 deletions src/main/java/io/moderne/jsonrpc/JsonRpcMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,30 @@
package io.moderne.jsonrpc;

import io.moderne.jsonrpc.formatter.MessageFormatter;
import org.jspecify.annotations.Nullable;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

@SuppressWarnings("unused")
public abstract class JsonRpcMethod<P> {

final Object convertAndHandle(Object params, MessageFormatter formatter) throws Exception {
Type paramType = ((ParameterizedType) getClass().getGenericSuperclass())
// Resolved once per instance at construction. The previous implementation
// walked getGenericSuperclass()/getActualTypeArguments() on every dispatch
// — same answer every call, so cache it.
private final Type paramType;

protected JsonRpcMethod() {
this.paramType = ((ParameterizedType) getClass().getGenericSuperclass())
.getActualTypeArguments()[0];
}

@SuppressWarnings("unchecked")
final Object convertAndHandle(@Nullable RawJson params, MessageFormatter formatter) throws Exception {
if (Void.class.equals(paramType)) {
return handle(null);
}
return handle(formatter.convertValue(params, paramType));
return handle(params == null ? null : (P) formatter.convertValue(params, paramType));
}

protected abstract Object handle(P params) throws Exception;
Expand Down
12 changes: 8 additions & 4 deletions src/main/java/io/moderne/jsonrpc/JsonRpcRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@ public class JsonRpcRequest extends JsonRpcMessage {
String method;

/**
* Either a Map of named parameters or a List of positional parameters.
* Either named parameters (a Map-shaped value), positional parameters
* (a list), or a typed POJO wrapped at request construction. Use
* {@link RawJson#as} (or call {@link io.moderne.jsonrpc.formatter.MessageFormatter#convertValue}
* directly) inside a {@link JsonRpcMethod} to materialize the typed value.
*/
@Nullable
Object params;
RawJson params;

public static JsonRpcRequest newRequest(String method, Object params) {
return new JsonRpcRequest(SnowflakeId.generateId(), method, params);
public static JsonRpcRequest newRequest(String method, @Nullable Object params) {
return new JsonRpcRequest(SnowflakeId.generateId(), method,
params == null ? null : RawJson.of(params));
}

public static JsonRpcRequest newRequest(String method) {
Expand Down
12 changes: 6 additions & 6 deletions src/main/java/io/moderne/jsonrpc/JsonRpcSuccess.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,30 @@ public class JsonRpcSuccess extends JsonRpcResponse {

@Getter
@Nullable
private final Object result;
private final RawJson result;

@JsonIgnore
@EqualsAndHashCode.Exclude
@ToString.Exclude
@Nullable
private final transient MessageFormatter formatter;

public JsonRpcSuccess(Object id, @Nullable Object result) {
public JsonRpcSuccess(Object id, @Nullable RawJson result) {
this(id, result, null);
}

private JsonRpcSuccess(Object id, @Nullable Object result, @Nullable MessageFormatter formatter) {
private JsonRpcSuccess(Object id, @Nullable RawJson result, @Nullable MessageFormatter formatter) {
this.id = id;
this.result = result;
this.formatter = formatter;
}

public static JsonRpcSuccess fromPayload(Object id, @Nullable Object result, @Nullable MessageFormatter formatter) {
public static JsonRpcSuccess fromPayload(Object id, @Nullable RawJson result, @Nullable MessageFormatter formatter) {
return new JsonRpcSuccess(id, result, formatter);
}

public <V> V getResult(Class<V> resultType) {
public <V> @Nullable V getResult(Class<V> resultType) {
assert formatter != null;
return formatter.convertValue(result, resultType);
return result == null ? null : formatter.convertValue(result, resultType);
}
}
108 changes: 108 additions & 0 deletions src/main/java/io/moderne/jsonrpc/RawJson.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright 2025 the original author or authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.moderne.jsonrpc;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import io.moderne.jsonrpc.formatter.MessageFormatter;
import org.jspecify.annotations.Nullable;

import java.io.IOException;
import java.lang.reflect.Type;

/**
* Library-owned wrapper for the {@code params}, {@code result}, and inbound
* {@code error} JSON values on JSON-RPC messages. Holds either:
* <ul>
* <li>a POJO (outbound — wrapped at request construction, serialized
* through whatever the {@link MessageFormatter} uses on the wire), or</li>
* <li>a parser-format-specific buffer (inbound — produced by the formatter
* during {@code deserialize}; converted lazily to a typed POJO when the
* consumer asks via {@link #as(MessageFormatter, Class)}), or</li>
* <li>{@code null}.</li>
* </ul>
* Keeps Jackson types out of the public ABI: consumers see only library
* classes, so adding/upgrading/swapping the wire format does not force them
* to recompile.
*/
@JsonSerialize(using = RawJson.RawJsonSerializer.class)
public final class RawJson {

private static final RawJson NULL = new RawJson(null);

private final @Nullable Object value;

private RawJson(@Nullable Object value) {
this.value = value;
}

/**
* Wrap an arbitrary value. {@code null} returns the canonical null
* instance (no allocation per call).
*/
public static RawJson of(@Nullable Object value) {
return value == null ? NULL : new RawJson(value);
}

public boolean isNull() {
return value == null;
}

/**
* Convert this value to {@code type} using {@code formatter}. Returns
* {@code null} when {@link #isNull()}; otherwise delegates to
* {@link MessageFormatter#convertValue(RawJson, Type)}.
*/
public <T> @Nullable T as(MessageFormatter formatter, Class<T> type) {
return formatter.convertValue(this, type);
}

public <T> @Nullable T as(MessageFormatter formatter, Type type) {
return formatter.convertValue(this, type);
}

/**
* Internal accessor for {@link MessageFormatter} implementations and the
* default Jackson serializer. Returns the wrapped value (POJO, parser
* buffer, or {@code null}). Library consumers should use
* {@link #as(MessageFormatter, Class)} instead — calling {@code unwrap()}
* directly couples them to the wire format.
*/
public @Nullable Object unwrap() {
return value;
}

public static final class RawJsonSerializer extends StdSerializer<RawJson> {
public RawJsonSerializer() {
super(RawJson.class);
}

@Override
public void serialize(RawJson value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
Object inner = value.unwrap();
if (inner == null) {
gen.writeNull();
return;
}
// Jackson's defaultSerializeValue handles both POJOs and the
// format's own buffered token type (e.g. TokenBuffer for JSON —
// it knows how to replay the captured tokens into the writer).
serializers.defaultSerializeValue(inner, gen);
}
}
}
Loading
Loading