From 69a2b539509808276942f009f9e3d2140e4b7f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miro=20Sp=C3=B6nemann?= Date: Wed, 17 Apr 2019 15:11:15 +0200 Subject: [PATCH] #203: Implemented WebSocket adapter for JSON-RPC --- gradle/versions.gradle | 1 + .../org/eclipse/lsp4j/jsonrpc/Launcher.java | 105 +++--- ...endableConcurrentMessageProcessorTest.java | 1 + org.eclipse.lsp4j.websocket/.project | 29 ++ org.eclipse.lsp4j.websocket/build.gradle | 24 ++ .../lsp4j/websocket/WebSocketEndpoint.java | 51 +++ .../websocket/WebSocketLauncherBuilder.java | 70 ++++ .../websocket/WebSocketMessageConsumer.java | 71 ++++ .../websocket/WebSocketMessageHandler.java | 47 +++ .../websocket/test/MockConnectionTest.java | 170 +++++++++ .../websocket/test/MockEndpointConfig.java | 39 ++ .../lsp4j/websocket/test/MockSession.java | 333 ++++++++++++++++++ settings.gradle | 1 + 13 files changed, 890 insertions(+), 52 deletions(-) create mode 100644 org.eclipse.lsp4j.websocket/.project create mode 100644 org.eclipse.lsp4j.websocket/build.gradle create mode 100644 org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketEndpoint.java create mode 100644 org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java create mode 100644 org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageConsumer.java create mode 100644 org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageHandler.java create mode 100644 org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockConnectionTest.java create mode 100644 org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockEndpointConfig.java create mode 100644 org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockSession.java diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 911a1800..23a36a3a 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -17,5 +17,6 @@ ext.versions = [ 'guava': '[14.0,22)', 'gson': '2.8.2', 'gson_orbit': '2.8.2.v20180104-1110', + 'websocket': '1.0', 'junit': '4.12' ] diff --git a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java index f981bb99..0c2b27ae 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java +++ b/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/Launcher.java @@ -38,6 +38,8 @@ /** * This is the entry point for applications that use LSP4J. A Launcher does all the wiring that is necessary to connect * your endpoint via an input stream and an output stream. + * + * @param remote service interface type */ public interface Launcher { @@ -217,6 +219,8 @@ static Launcher createIoLauncher(Collection localServices, Colle /** * The launcher builder wires up all components for JSON-RPC communication. + * + * @param remote service interface type */ public static class Builder { @@ -291,7 +295,6 @@ public Builder configureGson(Consumer configureGson) { return this; } - @SuppressWarnings("unchecked") public Launcher create() { // Validate input if (input == null) @@ -303,27 +306,63 @@ public Launcher create() { if (remoteInterfaces == null) throw new IllegalStateException("Remote interface must be configured."); - // Create proxy and endpoints + // Create the JSON handler, remote endpoint and remote proxy MessageJsonHandler jsonHandler = createJsonHandler(); RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); - T remoteProxy; - if (localServices.size() == 1 && remoteInterfaces.size() == 1) { - remoteProxy = ServiceEndpoints.toServiceObject(remoteEndpoint, remoteInterfaces.iterator().next()); - } else { - remoteProxy = (T) ServiceEndpoints.toServiceObject(remoteEndpoint, (Collection>) (Object) remoteInterfaces, classLoader); - } + T remoteProxy = createProxy(remoteEndpoint); - // create the message processor + // Create the message processor StreamMessageProducer reader = new StreamMessageProducer(input, jsonHandler, remoteEndpoint); MessageConsumer messageConsumer = wrapMessageConsumer(remoteEndpoint); - ExecutorService execService = executorService != null ? executorService : Executors.newCachedThreadPool(); ConcurrentMessageProcessor msgProcessor = createMessageProcessor(reader, messageConsumer, remoteProxy); - - return createLauncher(reader, messageConsumer, execService, remoteProxy, remoteEndpoint, msgProcessor); + ExecutorService execService = executorService != null ? executorService : Executors.newCachedThreadPool(); + return createLauncher(execService, remoteProxy, remoteEndpoint, msgProcessor); } - protected Launcher createLauncher(StreamMessageProducer reader, MessageConsumer messageConsumer, - ExecutorService execService, T remoteProxy, RemoteEndpoint remoteEndpoint, ConcurrentMessageProcessor msgProcessor) { + /** + * Create the JSON handler for messages between the local and remote services. + */ + protected MessageJsonHandler createJsonHandler() { + Map supportedMethods = getSupportedMethods(); + if (configureGson != null) + return new MessageJsonHandler(supportedMethods, configureGson); + else + return new MessageJsonHandler(supportedMethods); + } + + /** + * Create the remote endpoint that communicates with the local services. + */ + protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { + MessageConsumer outgoingMessageStream = new StreamMessageConsumer(output, jsonHandler); + outgoingMessageStream = wrapMessageConsumer(outgoingMessageStream); + RemoteEndpoint remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, ServiceEndpoints.toEndpoint(localServices)); + jsonHandler.setMethodProvider(remoteEndpoint); + return remoteEndpoint; + } + + /** + * Create the proxy for calling methods on the remote service. + */ + @SuppressWarnings("unchecked") + protected T createProxy(RemoteEndpoint remoteEndpoint) { + if (localServices.size() == 1 && remoteInterfaces.size() == 1) { + return ServiceEndpoints.toServiceObject(remoteEndpoint, remoteInterfaces.iterator().next()); + } else { + return (T) ServiceEndpoints.toServiceObject(remoteEndpoint, (Collection>) (Object) remoteInterfaces, classLoader); + } + } + + /** + * Create the message processor that listens to the input stream. + */ + protected ConcurrentMessageProcessor createMessageProcessor(MessageProducer reader, + MessageConsumer messageConsumer, T remoteProxy) { + return new ConcurrentMessageProcessor(reader, messageConsumer); + } + + protected Launcher createLauncher(ExecutorService execService, T remoteProxy, RemoteEndpoint remoteEndpoint, + ConcurrentMessageProcessor msgProcessor) { return new StandardLauncher(execService, remoteProxy, remoteEndpoint, msgProcessor); } @@ -368,44 +407,6 @@ protected Map getSupportedMethods() { return supportedMethods; } - - /** - * Create the JSON handler for messages between the local and remote services. - */ - protected MessageJsonHandler createJsonHandler() { - Map supportedMethods = getSupportedMethods(); - if (configureGson != null) - return new MessageJsonHandler(supportedMethods, configureGson); - else - return new MessageJsonHandler(supportedMethods); - } - - /** - * Create a message processor given the provided parameters. - * - * Clients may override this method to create a message processor - * with an expanded feature set. - * - * @param reader - A message producer capable of receiving messages from clients - * @param messageConsumer - A message consumer capable of passing completed messages to the local service - * @param remoteProxy - The remote proxy for communicating with the connecting client - * @return A ConcurrentMessageProcessor capable of linking an incoming request to the local service's implementation - */ - protected ConcurrentMessageProcessor createMessageProcessor(MessageProducer reader, - MessageConsumer messageConsumer, T remoteProxy) { - return new ConcurrentMessageProcessor(reader, messageConsumer); - } - - /** - * Create the remote endpoint that communicates with the local services. - */ - protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { - MessageConsumer outgoingMessageStream = new StreamMessageConsumer(output, jsonHandler); - outgoingMessageStream = wrapMessageConsumer(outgoingMessageStream); - RemoteEndpoint remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, ServiceEndpoints.toEndpoint(localServices)); - jsonHandler.setMethodProvider(remoteEndpoint); - return remoteEndpoint; - } } diff --git a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/ExtendableConcurrentMessageProcessorTest.java b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/ExtendableConcurrentMessageProcessorTest.java index 910a0b42..fdfd6b15 100644 --- a/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/ExtendableConcurrentMessageProcessorTest.java +++ b/org.eclipse.lsp4j.jsonrpc/src/test/java/org/eclipse/lsp4j/jsonrpc/test/ExtendableConcurrentMessageProcessorTest.java @@ -93,6 +93,7 @@ static Launcher createLauncher(Builder builder, Object localService, C */ static Builder createBuilder(MessageContextStore store) { return new Builder() { + @Override protected ConcurrentMessageProcessor createMessageProcessor(MessageProducer reader, MessageConsumer messageConsumer, T remoteProxy) { return new CustomConcurrentMessageProcessor(reader, messageConsumer, remoteProxy, store); diff --git a/org.eclipse.lsp4j.websocket/.project b/org.eclipse.lsp4j.websocket/.project new file mode 100644 index 00000000..1a6a345b --- /dev/null +++ b/org.eclipse.lsp4j.websocket/.project @@ -0,0 +1,29 @@ + + + org.eclipse.lsp4j.websocket + Project org.eclipse.lsp4j.websocket created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.xtext.ui.shared.xtextBuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.xtext.ui.shared.xtextNature + org.eclipse.buildship.core.gradleprojectnature + + diff --git a/org.eclipse.lsp4j.websocket/build.gradle b/org.eclipse.lsp4j.websocket/build.gradle new file mode 100644 index 00000000..81ea6878 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/build.gradle @@ -0,0 +1,24 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ + +ext.title = 'LSP4J WebSocket' +description = 'WebSocket support for LSP4J' + +dependencies { + compile project(":org.eclipse.lsp4j.jsonrpc") + compile "javax.websocket:javax.websocket-api:${versions.websocket}" + testCompile "junit:junit:$versions.junit" +} + +jar.manifest { + instruction 'Import-Package', '*' +} diff --git a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketEndpoint.java b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketEndpoint.java new file mode 100644 index 00000000..efe675f1 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketEndpoint.java @@ -0,0 +1,51 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket; + +import java.util.Collection; + +import javax.websocket.Endpoint; +import javax.websocket.EndpointConfig; +import javax.websocket.Session; + +import org.eclipse.lsp4j.jsonrpc.Launcher; + +/** + * WebSocket endpoint implementation that connects to a JSON-RPC service. + * + * @param remote service interface type + */ +public abstract class WebSocketEndpoint extends Endpoint { + + @Override + public void onOpen(Session session, EndpointConfig config) { + WebSocketLauncherBuilder builder = new WebSocketLauncherBuilder(); + builder.setSession(session); + configure(builder); + Launcher launcher = builder.create(); + connect(builder.getLocalServices(), launcher.getRemoteProxy()); + } + + /** + * Configure the JSON-RPC launcher. Implementations should set at least the + * {@link Launcher.Builder#setLocalService(Object) local service} and the + * {@link Launcher.Builder#setRemoteInterface(Class) remote interface}. + */ + abstract protected void configure(Launcher.Builder builder); + + /** + * Override this in order to connect the local services to the remote service proxy. + */ + protected void connect(Collection localServices, T remoteProxy) { + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java new file mode 100644 index 00000000..a8714246 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketLauncherBuilder.java @@ -0,0 +1,70 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket; + +import java.util.Collection; + +import javax.websocket.Session; + +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; +import org.eclipse.lsp4j.jsonrpc.services.ServiceEndpoints; + +/** + * JSON-RPC launcher builder for use in {@link WebSocketEndpoint}. + * + * @param remote service interface type + */ +public class WebSocketLauncherBuilder extends Launcher.Builder { + + protected Session session; + + public Collection getLocalServices() { + return localServices; + } + + public WebSocketLauncherBuilder setSession(Session session) { + this.session = session; + return this; + } + + @Override + public Launcher create() { + if (localServices == null) + throw new IllegalStateException("Local service must be configured."); + if (remoteInterfaces == null) + throw new IllegalStateException("Remote interface must be configured."); + + MessageJsonHandler jsonHandler = createJsonHandler(); + RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); + addMessageHandlers(jsonHandler, remoteEndpoint); + T remoteProxy = createProxy(remoteEndpoint); + return createLauncher(null, remoteProxy, remoteEndpoint, null); + } + + @Override + protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { + MessageConsumer outgoingMessageStream = new WebSocketMessageConsumer(session, jsonHandler); + outgoingMessageStream = wrapMessageConsumer(outgoingMessageStream); + RemoteEndpoint remoteEndpoint = new RemoteEndpoint(outgoingMessageStream, ServiceEndpoints.toEndpoint(localServices)); + jsonHandler.setMethodProvider(remoteEndpoint); + return remoteEndpoint; + } + + protected void addMessageHandlers(MessageJsonHandler jsonHandler, RemoteEndpoint remoteEndpoint) { + MessageConsumer messageConsumer = wrapMessageConsumer(remoteEndpoint); + session.addMessageHandler(new WebSocketMessageHandler(messageConsumer, jsonHandler, remoteEndpoint)); + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageConsumer.java b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageConsumer.java new file mode 100644 index 00000000..b9065678 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageConsumer.java @@ -0,0 +1,71 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket; + +import java.io.IOException; +import java.util.logging.Logger; + +import javax.websocket.Session; + +import org.eclipse.lsp4j.jsonrpc.JsonRpcException; +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; +import org.eclipse.lsp4j.jsonrpc.messages.Message; + +/** + * Message consumer that sends messages via a WebSocket session. + */ +public class WebSocketMessageConsumer implements MessageConsumer { + + private static final Logger LOG = Logger.getLogger(WebSocketMessageConsumer.class.getName()); + + private final Session session; + private final MessageJsonHandler jsonHandler; + + public WebSocketMessageConsumer(Session session, MessageJsonHandler jsonHandler) { + this.session = session; + this.jsonHandler = jsonHandler; + } + + public Session getSession() { + return session; + } + + @Override + public void consume(Message message) { + String content = jsonHandler.serialize(message); + try { + sendMessage(content); + } catch (IOException exception) { + throw new JsonRpcException(exception); + } + } + + protected void sendMessage(String message) throws IOException { + if (session.isOpen()) { + int length = message.length(); + if (length <= session.getMaxTextMessageBufferSize()) { + session.getAsyncRemote().sendText(message); + } else { + int currentOffset = 0; + while (currentOffset < length) { + int currentEnd = Math.min(currentOffset + session.getMaxTextMessageBufferSize(), length); + session.getBasicRemote().sendText(message.substring(currentOffset, currentEnd), currentEnd == length); + currentOffset = currentEnd; + } + } + } else { + LOG.info("Ignoring message due to closed session: " + message); + } + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageHandler.java b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageHandler.java new file mode 100644 index 00000000..622eef21 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/main/java/org/eclipse/lsp4j/websocket/WebSocketMessageHandler.java @@ -0,0 +1,47 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket; + +import javax.websocket.MessageHandler; + +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.jsonrpc.MessageIssueException; +import org.eclipse.lsp4j.jsonrpc.MessageIssueHandler; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; +import org.eclipse.lsp4j.jsonrpc.messages.Message; + +/** + * WebSocket message handler that parses JSON messages and forwards them to a {@link MessageConsumer}. + */ +public class WebSocketMessageHandler implements MessageHandler.Whole { + + private final MessageConsumer callback; + private final MessageJsonHandler jsonHandler; + private final MessageIssueHandler issueHandler; + + public WebSocketMessageHandler(MessageConsumer callback, MessageJsonHandler jsonHandler, MessageIssueHandler issueHandler) { + this.callback = callback; + this.jsonHandler = jsonHandler; + this.issueHandler = issueHandler; + } + + public void onMessage(String content) { + try { + Message message = jsonHandler.parseMessage(content); + callback.consume(message); + } catch (MessageIssueException exception) { + // An issue was found while parsing or validating the message + issueHandler.handle(exception.getRpcMessage(), exception.getIssues()); + } + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockConnectionTest.java b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockConnectionTest.java new file mode 100644 index 00000000..37ecfd69 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockConnectionTest.java @@ -0,0 +1,170 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket.test; + +import java.util.Collection; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification; +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; +import org.eclipse.lsp4j.websocket.WebSocketEndpoint; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class MockConnectionTest { + + private static final long TIMEOUT = 2000; + + private Client client; + private Server server; + + @SuppressWarnings("resource") + @Before + public void setup() { + client = new Client(); + server = new Server(); + MockSession clientSession = new MockSession(); + MockSession serverSession = new MockSession(); + clientSession.connect(serverSession); + clientSession.open(new ClientSideEndpoint()); + serverSession.open(new ServerSideEndpoint()); + } + + @Test + public void testClientRequest() throws Exception { + CompletableFuture future = client.server.request("foo"); + String result = future.get(TIMEOUT, TimeUnit.MILLISECONDS); + Assert.assertEquals("foobar", result); + } + + @Test + public void testNotifications() throws Exception { + server.client.notify("12"); + await(() -> client.result.length() == 2); + client.server.notify("foo"); + await(() -> server.result.length() == 3); + server.client.notify("34"); + await(() -> client.result.length() == 4); + client.server.notify("bar"); + await(() -> server.result.length() == 6); + server.client.notify("56"); + await(() -> client.result.length() == 6); + + Assert.assertEquals("foobar", server.result); + Assert.assertEquals("123456", client.result); + } + + @Test + public void testChunkedNotification() throws Exception { + StringBuilder messageBuilder = new StringBuilder(); + Random random = new Random(1); + for (int i = 0; i < 3 * MockSession.MAX_CHUNK_SIZE; i++) { + messageBuilder.append((char) ('a' + random.nextInt('z' - 'a' + 1))); + } + String message = messageBuilder.toString(); + + server.client.notify(message); + await(() -> client.result.length() == message.length()); + + Assert.assertEquals(message, client.result); + } + + private void await(Supplier condition) throws InterruptedException { + long startTime = System.currentTimeMillis(); + while (!condition.get()) { + Thread.sleep(20); + if (System.currentTimeMillis() - startTime > TIMEOUT) { + Assert.fail("Timeout elapsed while waiting for condition.\n"); + } + } + } + + private static interface ClientInterface { + + @JsonNotification("client/notify") + void notify(String arg); + + } + + private static class Client implements ClientInterface { + ServerInterface server; + String result = ""; + + @Override + public void notify(String arg) { + this.result += arg; + } + } + + private static interface ServerInterface { + + @JsonRequest("server/request") + CompletableFuture request(String arg); + + @JsonNotification("server/notify") + void notify(String arg); + + } + + private static class Server implements ServerInterface { + ClientInterface client; + String result = ""; + + @Override + public CompletableFuture request(String arg) { + return CompletableFuture.supplyAsync(() -> arg + "bar"); + } + + @Override + public void notify(String arg) { + this.result += arg; + } + } + + private class ClientSideEndpoint extends WebSocketEndpoint { + + @Override + protected void configure(Launcher.Builder builder) { + builder + .setLocalService(client) + .setRemoteInterface(ServerInterface.class); + } + + @Override + protected void connect(Collection localServices, ServerInterface remoteProxy) { + localServices.forEach(s -> ((Client) s).server = remoteProxy); + } + + } + + private class ServerSideEndpoint extends WebSocketEndpoint { + + @Override + protected void configure(Launcher.Builder builder) { + builder + .setLocalService(server) + .setRemoteInterface(ClientInterface.class); + } + + @Override + protected void connect(Collection localServices, ClientInterface remoteProxy) { + localServices.forEach(s -> ((Server) s).client = remoteProxy); + } + + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockEndpointConfig.java b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockEndpointConfig.java new file mode 100644 index 00000000..72101cfb --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockEndpointConfig.java @@ -0,0 +1,39 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket.test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.websocket.Decoder; +import javax.websocket.Encoder; +import javax.websocket.EndpointConfig; + +public class MockEndpointConfig implements EndpointConfig { + + @Override + public List> getEncoders() { + return Collections.emptyList(); + } + + @Override + public List> getDecoders() { + return Collections.emptyList(); + } + + @Override + public Map getUserProperties() { + return Collections.emptyMap(); + } + +} diff --git a/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockSession.java b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockSession.java new file mode 100644 index 00000000..6d7039c7 --- /dev/null +++ b/org.eclipse.lsp4j.websocket/src/test/java/org/eclipse/lsp4j/websocket/test/MockSession.java @@ -0,0 +1,333 @@ +/****************************************************************************** + * Copyright (c) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, + * or the Eclipse Distribution License v. 1.0 which is available at + * http://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause + ******************************************************************************/ +package org.eclipse.lsp4j.websocket.test; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.security.Principal; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import javax.websocket.CloseReason; +import javax.websocket.EncodeException; +import javax.websocket.Endpoint; +import javax.websocket.Extension; +import javax.websocket.MessageHandler; +import javax.websocket.RemoteEndpoint; +import javax.websocket.SendHandler; +import javax.websocket.SendResult; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; + +public class MockSession implements Session { + + public static final int MAX_CHUNK_SIZE = 100; + + private final BasicRemote basicRemote = new BasicRemote(); + private final AsyncRemote asyncRemote = new AsyncRemote(); + private final Set messageHandlers = new HashSet<>(); + private Endpoint endpoint; + private MockSession connectedSession; + private boolean isClosed; + private StringBuilder partialMessage; + + public void connect(MockSession other) { + this.connectedSession = other; + other.connectedSession = this; + } + + @Override + public RemoteEndpoint.Async getAsyncRemote() { + return asyncRemote; + } + + @Override + public RemoteEndpoint.Basic getBasicRemote() { + return basicRemote; + } + + public void open(Endpoint endpoint) { + this.endpoint = endpoint; + endpoint.onOpen(this, new MockEndpointConfig()); + } + + @Override + public void close() throws IOException { + close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "OK")); + } + + @Override + public void close(CloseReason closeReason) throws IOException { + isClosed = true; + endpoint.onClose(this, closeReason); + } + + @Override + public void addMessageHandler(MessageHandler handler) throws IllegalStateException { + if (!messageHandlers.add(handler)) + throw new IllegalStateException(); + } + + @Override + public Set getMessageHandlers() { + return messageHandlers; + } + + @Override + public void removeMessageHandler(MessageHandler handler) { + messageHandlers.remove(handler); + } + + @SuppressWarnings("unchecked") + protected void dispatch(String message, boolean lastChunk) { + if (lastChunk) { + String wholeMessage = message; + if (partialMessage != null) { + partialMessage.append(message); + wholeMessage = partialMessage.toString(); + partialMessage = null; + } + for (MessageHandler h : connectedSession.messageHandlers) { + if (h instanceof MessageHandler.Whole) + ((MessageHandler.Whole) h).onMessage(wholeMessage); + else + ((MessageHandler.Partial) h).onMessage(message, true); + }; + } else { + if (partialMessage == null) { + partialMessage = new StringBuilder(); + } + for (MessageHandler h : connectedSession.messageHandlers) { + if (h instanceof MessageHandler.Partial) + ((MessageHandler.Partial) h).onMessage(message, false); + }; + partialMessage.append(message); + } + } + + @Override + public WebSocketContainer getContainer() { + return null; + } + + @Override + public String getProtocolVersion() { + return "13"; + } + + @Override + public String getNegotiatedSubprotocol() { + return null; + } + + @Override + public List getNegotiatedExtensions() { + return Collections.emptyList(); + } + + @Override + public boolean isSecure() { + return true; + } + + @Override + public boolean isOpen() { + return !isClosed; + } + + @Override + public long getMaxIdleTimeout() { + return 10000; + } + + @Override + public void setMaxIdleTimeout(long milliseconds) { + } + + @Override + public void setMaxBinaryMessageBufferSize(int length) { + } + + @Override + public int getMaxBinaryMessageBufferSize() { + return 100; + } + + @Override + public void setMaxTextMessageBufferSize(int length) { + } + + @Override + public int getMaxTextMessageBufferSize() { + return MAX_CHUNK_SIZE; + } + + @Override + public String getId() { + return "mock"; + } + + @Override + public URI getRequestURI() { + try { + return new URI("http://localhost:8080/mock"); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + @Override + public Map> getRequestParameterMap() { + return Collections.emptyMap(); + } + + @Override + public String getQueryString() { + return ""; + } + + @Override + public Map getPathParameters() { + return Collections.emptyMap(); + } + + @Override + public Map getUserProperties() { + return Collections.emptyMap(); + } + + @Override + public Principal getUserPrincipal() { + return null; + } + + @Override + public Set getOpenSessions() { + return Collections.singleton(this); + } + + private class BasicRemote extends AbstractRemoteEndpoint implements RemoteEndpoint.Basic { + + @Override + public void sendText(String text) throws IOException { + dispatch(text, true); + } + + @Override + public void sendBinary(ByteBuffer data) throws IOException { + } + + @Override + public void sendText(String partialMessage, boolean isLast) throws IOException { + dispatch(partialMessage, isLast); + } + + @Override + public void sendBinary(ByteBuffer partialByte, boolean isLast) throws IOException { + } + + @Override + public OutputStream getSendStream() throws IOException { + return null; + } + + @Override + public Writer getSendWriter() throws IOException { + return null; + } + + @Override + public void sendObject(Object data) throws IOException, EncodeException { + } + + } + + private class AsyncRemote extends AbstractRemoteEndpoint implements RemoteEndpoint.Async { + + @Override + public long getSendTimeout() { + return 1000; + } + + @Override + public void setSendTimeout(long timeoutmillis) { + } + + @Override + public void sendText(String text, SendHandler handler) { + sendText(text).thenRun(() -> { + handler.onResult(new SendResult()); + }); + } + + @Override + public CompletableFuture sendText(String text) { + return CompletableFuture.runAsync(() -> { + dispatch(text, true); + }); + } + + @Override + public Future sendBinary(ByteBuffer data) { + return null; + } + + @Override + public void sendBinary(ByteBuffer data, SendHandler handler) { + } + + @Override + public Future sendObject(Object data) { + return null; + } + + @Override + public void sendObject(Object data, SendHandler handler) { + } + + } + + private static abstract class AbstractRemoteEndpoint implements RemoteEndpoint { + + @Override + public void setBatchingAllowed(boolean allowed) throws IOException { + } + + @Override + public boolean getBatchingAllowed() { + return false; + } + + @Override + public void flushBatch() throws IOException { + } + + @Override + public void sendPing(ByteBuffer applicationData) throws IOException, IllegalArgumentException { + } + + @Override + public void sendPong(ByteBuffer applicationData) throws IOException, IllegalArgumentException { + } + + } + +} diff --git a/settings.gradle b/settings.gradle index 66f1c760..df9bea2d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,3 +16,4 @@ include 'org.eclipse.lsp4j.debug' include 'org.eclipse.lsp4j.generator' include 'org.eclipse.lsp4j.jsonrpc' include 'org.eclipse.lsp4j.jsonrpc.debug' +include 'org.eclipse.lsp4j.websocket'