From 44985db45f94cc98c4afc0dbc64cafaaa17a4a06 Mon Sep 17 00:00:00 2001 From: Sean Gilligan Date: Sun, 4 Jan 2026 23:19:18 -0800 Subject: [PATCH] WIP: WebSocket JsonRpcTransport There are two ignored functional tests: one that talks to echod and the other that talks to a Minecraft server. --- .../jsonrpc/JsonRpcClientWebSocket.java | 146 ++++++++++++++++++ .../EchoWebSocketProofOfConcept.groovy | 27 ++++ .../consensusj/jsonrpc/MinecraftProof.groovy | 24 +++ 3 files changed, 197 insertions(+) create mode 100644 consensusj-jsonrpc/src/main/java/org/consensusj/jsonrpc/JsonRpcClientWebSocket.java create mode 100644 consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/EchoWebSocketProofOfConcept.groovy create mode 100644 consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/MinecraftProof.groovy diff --git a/consensusj-jsonrpc/src/main/java/org/consensusj/jsonrpc/JsonRpcClientWebSocket.java b/consensusj-jsonrpc/src/main/java/org/consensusj/jsonrpc/JsonRpcClientWebSocket.java new file mode 100644 index 00000000..285e31a4 --- /dev/null +++ b/consensusj-jsonrpc/src/main/java/org/consensusj/jsonrpc/JsonRpcClientWebSocket.java @@ -0,0 +1,146 @@ +package org.consensusj.jsonrpc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Type; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Function; + +// TODO: Should this be combined with JsonRpcClientJavaNet? (There is a lot of common code, maybe switch on URI type?) +// TODO: Support SSL (wss) +// TODO: Long-lived websocket connection, rather than connect and disconnect for each request/response +// TODO: Handle notifications (needs persistent connections) -- use j.u.c.Flow? +// TODO: Handle authentication other than Minecraft's bearer-token +/** + * Proof-of-concept WebSocket JSON-RPC transport + */ +public class JsonRpcClientWebSocket implements JsonRpcTransport { + private static final Logger log = LoggerFactory.getLogger(JsonRpcClientWebSocket.class); + + private final ObjectMapper mapper; + private final URI serverURI; + private final String bearerToken; + private final HttpClient client; + + public JsonRpcClientWebSocket(ObjectMapper mapper, URI server, String bearerToken) { + if (!server.getScheme().equals("ws")) { + throw new IllegalArgumentException("ws only"); + } + log.debug("Constructing JSON-RPC client for: {}", server); + this.mapper = mapper; + this.serverURI = server; + this.bearerToken = bearerToken; + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + } + + @Override + public URI getServerURI() { + return serverURI; + } + + @Override + public CompletableFuture> sendRequestForResponseAsync(JsonRpcRequest request, JavaType responseType) { + String requestString; + try { + requestString = encodeJsonRpcRequest(request); + } catch (JsonProcessingException e) { + // TODO: Return this as a failed future + throw new RuntimeException(e); + } + + CompletableFuture responseFuture = new CompletableFuture<>(); + + WebSocket.Listener listener = new WebSocket.Listener() { + private final StringBuilder messageBuilder = new StringBuilder(); + + @Override + public CompletableFuture onText(WebSocket webSocket, CharSequence data, boolean last) { + messageBuilder.append(data); + if (last) { + responseFuture.complete(messageBuilder.toString()); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + responseFuture.completeExceptionally(error); + } + + @Override + public CompletableFuture onClose(WebSocket webSocket, int statusCode, String reason) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally( + new RuntimeException("WebSocket closed: " + statusCode + " - " + reason) + ); + } + return CompletableFuture.completedFuture(null); + } + }; + + WebSocket webSocket = client.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .header("Authorization", "Bearer " + bearerToken) + .buildAsync(serverURI, listener) + .join(); + + CompletableFuture cf = webSocket.sendText(requestString, true); + // cf completes when the request is sent. TODO: check result + + return responseFuture + .thenApply(mappingFuncFor(responseType)); + } + + private String encodeJsonRpcRequest(JsonRpcRequest request) throws JsonProcessingException { + return mapper.writeValueAsString(request); + } + + // return a MappingFunction for a given type + private JsonRpcClientJavaNet.MappingFunction mappingFuncFor(T responseType) { + return s -> mapper.readValue(s, (JavaType) responseType); + } + + /** + * Map a response string to a Java object. Wraps checked {@link JsonProcessingException} + * in unchecked {@link CompletionException}. + * @param result type + */ + @FunctionalInterface + protected interface MappingFunction extends Function { + + /** + * Gets a result. Wraps checked {@link JsonProcessingException} in {@link CompletionException} + * @param s input + * @return a result + * @throws CompletionException (unchecked) if a JsonProcessingException exception occurs + */ + @Override + default R apply(String s) throws CompletionException { + try { + return applyThrows(s); + } catch (Exception e) { + throw new CompletionException(e); + } + } + + /** + * Gets a result and may throw a checked exception. + * @param s input + * @return a result + * @throws JsonProcessingException Checked Exception + */ + R applyThrows(String s) throws Exception; + } +} diff --git a/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/EchoWebSocketProofOfConcept.groovy b/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/EchoWebSocketProofOfConcept.groovy new file mode 100644 index 00000000..41748823 --- /dev/null +++ b/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/EchoWebSocketProofOfConcept.groovy @@ -0,0 +1,27 @@ +package org.consensusj.jsonrpc + +import com.fasterxml.jackson.databind.ObjectMapper +import spock.lang.Ignore +import spock.lang.Specification + +@Ignore("Integration test -- needs a running echod instance") +class EchoWebSocketProofOfConcept extends Specification { + + private URI echodServer = URI.create("ws://localhost:8080/ws"); + private String bearerToken = "unused"; + private final DefaultRpcClient.TransportFactory transportFactory = (ObjectMapper m) -> new JsonRpcClientWebSocket(m, echodServer, bearerToken) + + def "send and receive and echo as a JsonRpcResponse" () { + given: + String expectedEcho = "Hello WebSocket!" + + when: + var client = new DefaultRpcClient(transportFactory, JsonRpcMessage.Version.V2) + String result = client.send("echo", String.class, List.of(expectedEcho)) + + then: + result != null + result == expectedEcho + } + +} diff --git a/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/MinecraftProof.groovy b/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/MinecraftProof.groovy new file mode 100644 index 00000000..31d0e8e5 --- /dev/null +++ b/consensusj-jsonrpc/src/test/groovy/org/consensusj/jsonrpc/MinecraftProof.groovy @@ -0,0 +1,24 @@ +package org.consensusj.jsonrpc + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import spock.lang.Ignore +import spock.lang.Specification + +@Ignore("Integration test -- needs a local Minecraft server") +class MinecraftProof extends Specification { + + private URI testMineCraftServer = URI.create("ws://localhost:44000"); + private String bearerToken = "0123456789012345678901234567890123456789"; + private final DefaultRpcClient.TransportFactory transportFactory = (ObjectMapper m) -> new JsonRpcClientWebSocket(m, testMineCraftServer, bearerToken) + + def "get server status as JsonRpcResponse" () { + when: + var client = new DefaultRpcClient(transportFactory, JsonRpcMessage.Version.V2) + var node = client.send("minecraft:server/status", JsonNode.class) + + then: + node != null + } + +}