From 457a41b6d5fdc0320f80bbeaa89b238a51778559 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Sat, 24 Mar 2018 20:51:49 +0000 Subject: [PATCH 01/15] Implements WebSocketService --- build.gradle | 1 + config/checkstyle/checkstyle.xml | 4 +- core/build.gradle | 6 +- .../protocol/websocket/WebSocketClient.java | 45 ++++ .../protocol/websocket/WebSocketRequest.java | 21 ++ .../protocol/websocket/WebSocketService.java | 189 ++++++++++++++++ ...va => JsonRpc2_0WebSocketClientJTest.java} | 2 +- .../websocket/WebSocketServiceTest.java | 208 ++++++++++++++++++ 8 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java rename core/src/test/java/org/web3j/protocol/core/{JsonRpc2_0Web3jTest.java => JsonRpc2_0WebSocketClientJTest.java} (93%) create mode 100644 core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java diff --git a/build.gradle b/build.gradle index d2672c6ed5..89f747f715 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { ext.okhttpVersion = '3.8.1' ext.rxjavaVersion = '1.2.4' ext.slf4jVersion = '1.7.25' + ext.javaWebSocketVersion = '1.3.8' // test dependencies ext.equalsverifierVersion = '2.1.7' diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 77186fd2b1..8220c6c128 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -53,9 +53,7 @@ - - - + diff --git a/core/build.gradle b/core/build.gradle index 3a655e8139..cf604898c6 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -9,9 +9,11 @@ dependencies { "com.github.jnr:jnr-unixsocket:$jnr_unixsocketVersion", "com.squareup.okhttp3:okhttp:$okhttpVersion", "com.squareup.okhttp3:logging-interceptor:$okhttpVersion", - "io.reactivex:rxjava:$rxjavaVersion" + "io.reactivex:rxjava:$rxjavaVersion", + "org.java-websocket:Java-WebSocket:$javaWebSocketVersion" testCompile project(path: ':crypto', configuration: 'testArtifacts'), - "nl.jqno.equalsverifier:equalsverifier:$equalsverifierVersion" + "nl.jqno.equalsverifier:equalsverifier:$equalsverifierVersion", + group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' } task createProperties(dependsOn: processResources) doLast { diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java new file mode 100644 index 0000000000..7571e46771 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -0,0 +1,45 @@ +package org.web3j.protocol.websocket; + +import java.net.URI; + +import org.java_websocket.handshake.ServerHandshake; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class WebSocketClient extends org.java_websocket.client.WebSocketClient { + + private static final Logger log = LoggerFactory.getLogger(WebSocketClient.class); + + final WebSocketService service; + + public WebSocketClient(URI serverUri, WebSocketService service) { + super(serverUri); + this.service = service; + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + log.info("Opened WebSocket connection to {}", uri); + } + + @Override + public void onMessage(String s) { + try { + log.debug("Received message {} from server {}", s, uri); + service.onReply(s); + log.debug("Processed message {} from server {}", s, uri); + } catch (Exception e) { + log.error("Failed to process message '{}' from server {}", s, uri); + } + } + + @Override + public void onClose(int i, String s, boolean b) { + log.info("Closed WebSocket connection to {}", uri); + } + + @Override + public void onError(Exception e) { + log.error(String.format("WebSocket connection to {} failed with error", uri), e); + } +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java new file mode 100644 index 0000000000..e93a1126f7 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java @@ -0,0 +1,21 @@ +package org.web3j.protocol.websocket; + +import java.util.concurrent.CompletableFuture; + +class WebSocketRequest { + private CompletableFuture completableFuture; + private Class responseType; + + public WebSocketRequest(CompletableFuture completableFuture, Class responseType) { + this.completableFuture = completableFuture; + this.responseType = responseType; + } + + public CompletableFuture getCompletableFuture() { + return completableFuture; + } + + public Class getResponseType() { + return responseType; + } +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java new file mode 100644 index 0000000000..24ad3d547a --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -0,0 +1,189 @@ +package org.web3j.protocol.websocket; + +import java.io.IOException; + +import java.net.ConnectException; +import java.net.URI; +import java.net.URISyntaxException; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.web3j.protocol.ObjectMapperFactory; +import org.web3j.protocol.Web3jService; +import org.web3j.protocol.core.Request; +import org.web3j.protocol.core.Response; + +public class WebSocketService implements Web3jService { + + private static final Logger log = LoggerFactory.getLogger(WebSocketService.class); + + static final long REQUEST_TIMEOUT = 60; + + private final WebSocketClient webSocketClient; + private final ScheduledExecutorService executor; + private final ObjectMapper objectMapper; + + private Map> requestForId = new HashMap<>(); + + public WebSocketService(String serverUrl, boolean includeRawResponses) { + this.webSocketClient = new WebSocketClient(parseURI(serverUrl), this); + this.executor = Executors.newScheduledThreadPool(1); + this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); + } + + WebSocketService(WebSocketClient webSocketClient, + ScheduledExecutorService executor, + boolean includeRawResponses) { + this.webSocketClient = webSocketClient; + this.executor = executor; + this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); + } + + public void connect() throws ConnectException { + try { + boolean connected = webSocketClient.connectBlocking(); + if (!connected) { + throw new ConnectException("Failed to connect to WebSocket"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while connecting via WebSocket protocol"); + } + } + + @Override + public T send(Request request, Class responseType) throws IOException { + try { + return sendAsync(request, responseType).get(); + } catch (InterruptedException e) { + Thread.interrupted(); + throw new IOException("Interrupted WebSocket request", e); + } catch (ExecutionException e) { + if (e.getCause() instanceof IOException) { + throw (IOException) e.getCause(); + } + + throw new RuntimeException("Unexpected exception", e); + } + } + + @Override + public CompletableFuture sendAsync( + Request request, + Class responseType) { + CompletableFuture result = new CompletableFuture<>(); + long requestId = request.getId(); + requestForId.put(requestId, new WebSocketRequest<>(result, responseType)); + try { + String payload = objectMapper.writeValueAsString(request); + webSocketClient.send(payload); + setTimeout(requestId); + } catch (IOException e) { + closeRequest(requestId, e); + } + + return result; + } + + private void setTimeout(long requestId) { + executor.schedule( + () -> closeRequest( + requestId, + new IOException( + String.format("Request with id %d timed out", requestId))), + REQUEST_TIMEOUT, + TimeUnit.SECONDS); + } + + private void closeRequest(long requestId, Exception e) { + CompletableFuture result = requestForId.get(requestId).getCompletableFuture(); + requestForId.remove(requestId); + result.completeExceptionally(e); + } + + boolean isWaitingForReply(long requestId) { + return requestForId.containsKey(requestId); + } + + void onReply(String replyStr) throws IOException { + JsonNode replyJson = parseToTree(replyStr); + + WebSocketRequest request = getAndRemoveRequest(replyJson); + Class responseType = request.getResponseType(); + CompletableFuture future = request.getCompletableFuture(); + + try { + Object reply = objectMapper.convertValue(replyJson, responseType); + future.complete(reply); + } catch (IllegalArgumentException e) { + future.completeExceptionally( + new IOException( + String.format( + "Failed to parse '%s' as type %s", + replyStr, + responseType), + e)); + } + } + + private JsonNode parseToTree(String replyStr) throws IOException { + try { + return objectMapper.readTree(replyStr); + } catch (IOException e) { + throw new IOException("Failed to parse incoming WebSocket message", e); + } + } + + private WebSocketRequest getAndRemoveRequest(JsonNode replyJson) throws IOException { + long id = getReplyId(replyJson); + if (!requestForId.containsKey(id)) { + throw new IOException(String.format( + "Received reply for unexpected request id: %d", + id)); + } + WebSocketRequest request = requestForId.get(id); + requestForId.remove(id); + return request; + } + + private long getReplyId(JsonNode replyJson) throws IOException { + JsonNode idField = replyJson.get("id"); + if (idField == null) { + throw new IOException("'id' field is missing in the reply"); + } + + if (!idField.isIntegralNumber()) { + throw new IOException(String.format( + "'id' expected to be long, but it is: '%s'", + idField.asText())); + } + + return idField.longValue(); + } + + private static URI parseURI(String serverUrl) { + try { + return new URI(serverUrl); + } catch (URISyntaxException e) { + throw new RuntimeException(String.format("Failed to parse URL: '%s'", serverUrl), e); + } + } + + public void close() { + webSocketClient.close(); + executor.shutdown(); + } +} + diff --git a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java similarity index 93% rename from core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java rename to core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java index 4cd3799087..08da91ed9c 100644 --- a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java +++ b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java @@ -10,7 +10,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -public class JsonRpc2_0Web3jTest { +public class JsonRpc2_0WebSocketClientJTest { @Test public void testStopExecutorOnShutdown() { diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java new file mode 100644 index 0000000000..372608fa87 --- /dev/null +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -0,0 +1,208 @@ +package org.web3j.protocol.websocket; + +import java.io.IOException; +import java.net.ConnectException; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.web3j.protocol.core.JsonRpc2_0Web3j; +import org.web3j.protocol.core.Request; +import org.web3j.protocol.core.methods.response.Web3ClientVersion; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WebSocketServiceTest { + + private WebSocketClient webSocketClient = mock(WebSocketClient.class); + private ScheduledExecutorService executorService = mock(ScheduledExecutorService.class); + + private WebSocketService service = new WebSocketService(webSocketClient, executorService, true); + + private Request request = new Request<>( + "web3_clientVersion", + Collections.emptyList(), + service, + Web3ClientVersion.class); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Before + public void before() throws InterruptedException { + when(webSocketClient.connectBlocking()).thenReturn(true); + request.setId(1); + } + + @Test + public void testThrowExceptionIfServerUrlIsInvalid() { + thrown.expect(RuntimeException.class); + thrown.expectMessage("Failed to parse URL: 'invalid\\url'"); + new WebSocketService("invalid\\url", true); + } + + @Test + public void testConnectViaWebSocketClient() throws Exception { + service.connect(); + + verify(webSocketClient).connectBlocking(); + } + + @Test + public void testInterruptCurrentThreadIfConnectionIsInterrupted() throws Exception { + when(webSocketClient.connectBlocking()).thenThrow(new InterruptedException()); + service.connect(); + + assertTrue("Interrupted flag was not set properly", + Thread.currentThread().isInterrupted()); + } + + @Test + public void testThrowExceptionIfConnectionFailed() throws Exception { + thrown.expect(ConnectException.class); + thrown.expectMessage("Failed to connect to WebSocket"); + when(webSocketClient.connectBlocking()).thenReturn(false); + service.connect(); + } + + @Test + public void testNotWaitingForReplyWithUnknownId() { + assertFalse(service.isWaitingForReply(123)); + } + + @Test + public void testWaitingForReplyToSentRequest() throws Exception { + service.sendAsync(request, Web3ClientVersion.class); + + assertTrue(service.isWaitingForReply(request.getId())); + } + + @Test + public void testNoLongerWaitingForResponseAfterReply() throws Exception { + service.sendAsync(request, Web3ClientVersion.class); + service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + + assertFalse(service.isWaitingForReply(1)); + } + + @Test + public void testSendWebSocketRequest() throws Exception { + service.sendAsync(request, Web3ClientVersion.class); + + verify(webSocketClient).send( + "{\"jsonrpc\":\"2.0\",\"method\":\"web3_clientVersion\",\"params\":[],\"id\":1}"); + } + + @Test + public void testIgnoreInvalidReplies() throws Exception { + thrown.expect(IOException.class); + thrown.expectMessage("Failed to parse incoming WebSocket message"); + service.sendAsync(request, Web3ClientVersion.class); + service.onReply("{"); + } + + @Test + public void testThrowExceptionIfIdHasInvalidType() throws Exception { + thrown.expect(IOException.class); + thrown.expectMessage("'id' expected to be long, but it is: 'true'"); + service.sendAsync(request, Web3ClientVersion.class); + service.onReply("{\"id\":true}"); + } + + @Test + public void testThrowExceptionIfIdIsMissing() throws Exception { + thrown.expect(IOException.class); + thrown.expectMessage("'id' field is missing in the reply"); + service.sendAsync(request, Web3ClientVersion.class); + service.onReply("{}"); + } + + @Test + public void testThrowExceptionIfUnexpectedIdIsReceived() throws Exception { + thrown.expect(IOException.class); + thrown.expectMessage("Received reply for unexpected request id: 12345"); + service.sendAsync(request, Web3ClientVersion.class); + service.onReply("{\"jsonrpc\":\"2.0\",\"id\":12345,\"result\":\"geth-version\"}"); + } + + @Test + public void testReceiveReply() throws Exception { + CompletableFuture reply = service.sendAsync( + request, + Web3ClientVersion.class); + service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + + assertTrue(reply.isDone()); + assertEquals("geth-version", reply.get().getWeb3ClientVersion()); + } + + @Test(expected = ExecutionException.class) + public void testCancelRequestAfterTimeout() throws Exception { + when(executorService.schedule( + any(Runnable.class), + eq(WebSocketService.REQUEST_TIMEOUT), + eq(TimeUnit.SECONDS))) + .then(invocation -> { + Runnable runnable = invocation.getArgumentAt(0, Runnable.class); + runnable.run(); + return null; + }); + + CompletableFuture reply = service.sendAsync( + request, + Web3ClientVersion.class); + + assertTrue(reply.isDone()); + reply.get(); + } + + @Test + public void testSyncRequest() throws Exception { + CountDownLatch requestSent = new CountDownLatch(1); + + doAnswer(invocation -> { + requestSent.countDown(); + return null; + }).when(webSocketClient).send(anyString()); + + Executors.newSingleThreadExecutor().execute(() -> { + try { + requestSent.await(); + service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + } catch (Exception e) { + e.printStackTrace(); + } + }); + + Web3ClientVersion reply = service.send(request, Web3ClientVersion.class); + + assertEquals(reply.getWeb3ClientVersion(), "geth-version"); + } + + @Test + public void testCloseWebSocketOnClose() throws Exception { + service.close(); + + verify(webSocketClient).close(); + verify(executorService).shutdown(); + } + +} \ No newline at end of file From 61e04266307f256e3dd58c2f269635730b6947df Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Mon, 26 Mar 2018 22:19:12 +0100 Subject: [PATCH 02/15] Implement WebSocket subscriptions --- .../main/java/org/web3j/protocol/Service.java | 17 ++ .../java/org/web3j/protocol/Web3jService.java | 9 + .../core/methods/response/EthSubscribe.java | 9 + .../core/methods/response/EthUnsubscribe.java | 7 + .../protocol/websocket/WebSocketService.java | 196 ++++++++++++++-- .../websocket/WebSocketSubscription.java | 21 ++ .../events/NewHeadsNotification.java | 8 + .../events/NewHeadsNotificationParam.java | 74 ++++++ .../websocket/events/Notification.java | 23 ++ .../websocket/events/NotificationParams.java | 17 ++ .../websocket/WebSocketServiceTest.java | 211 +++++++++++++++++- 11 files changed, 565 insertions(+), 27 deletions(-) create mode 100644 core/src/main/java/org/web3j/protocol/core/methods/response/EthSubscribe.java create mode 100644 core/src/main/java/org/web3j/protocol/core/methods/response/EthUnsubscribe.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/Notification.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java diff --git a/core/src/main/java/org/web3j/protocol/Service.java b/core/src/main/java/org/web3j/protocol/Service.java index 27c56dd804..684ce51e8b 100644 --- a/core/src/main/java/org/web3j/protocol/Service.java +++ b/core/src/main/java/org/web3j/protocol/Service.java @@ -6,8 +6,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import rx.Observable; + import org.web3j.protocol.core.Request; import org.web3j.protocol.core.Response; +import org.web3j.protocol.websocket.events.Notification; import org.web3j.utils.Async; /** @@ -42,4 +45,18 @@ public CompletableFuture sendAsync( Request jsonRpc20Request, Class responseType) { return Async.run(() -> send(jsonRpc20Request, responseType)); } + + + public > Observable subscribe( + Request request, + Class responseType) { + throw new UnsupportedOperationException( + String.format( + "Service %s does not support subscriptions", + this.getClass().getSimpleName())); + } + + public boolean supportsSubscription() { + return false; + } } diff --git a/core/src/main/java/org/web3j/protocol/Web3jService.java b/core/src/main/java/org/web3j/protocol/Web3jService.java index d970355e21..a8c2b4c0c3 100644 --- a/core/src/main/java/org/web3j/protocol/Web3jService.java +++ b/core/src/main/java/org/web3j/protocol/Web3jService.java @@ -3,8 +3,11 @@ import java.io.IOException; import java.util.concurrent.CompletableFuture; +import rx.Observable; + import org.web3j.protocol.core.Request; import org.web3j.protocol.core.Response; +import org.web3j.protocol.websocket.events.Notification; /** * Services API. @@ -15,4 +18,10 @@ T send( CompletableFuture sendAsync( Request request, Class responseType); + + > Observable subscribe( + Request request, + Class responseType); + + boolean supportsSubscription(); } diff --git a/core/src/main/java/org/web3j/protocol/core/methods/response/EthSubscribe.java b/core/src/main/java/org/web3j/protocol/core/methods/response/EthSubscribe.java new file mode 100644 index 0000000000..b24f4e5368 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/core/methods/response/EthSubscribe.java @@ -0,0 +1,9 @@ +package org.web3j.protocol.core.methods.response; + +import org.web3j.protocol.core.Response; + +public class EthSubscribe extends Response { + public String getSubscriptionId() { + return getResult(); + } +} diff --git a/core/src/main/java/org/web3j/protocol/core/methods/response/EthUnsubscribe.java b/core/src/main/java/org/web3j/protocol/core/methods/response/EthUnsubscribe.java new file mode 100644 index 0000000000..4a97470fab --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/core/methods/response/EthUnsubscribe.java @@ -0,0 +1,7 @@ +package org.web3j.protocol.core.methods.response; + +import org.web3j.protocol.core.Response; + +public class EthUnsubscribe extends Response { + +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 24ad3d547a..0d77ec43d1 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -6,6 +6,7 @@ import java.net.URI; import java.net.URISyntaxException; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -14,16 +15,23 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import rx.Observable; +import rx.subjects.PublishSubject; + import org.web3j.protocol.ObjectMapperFactory; import org.web3j.protocol.Web3jService; import org.web3j.protocol.core.Request; import org.web3j.protocol.core.Response; +import org.web3j.protocol.core.methods.response.EthSubscribe; +import org.web3j.protocol.core.methods.response.EthUnsubscribe; +import org.web3j.protocol.websocket.events.Notification; public class WebSocketService implements Web3jService { @@ -36,6 +44,8 @@ public class WebSocketService implements Web3jService { private final ObjectMapper objectMapper; private Map> requestForId = new HashMap<>(); + private Map> pendingSubscription = new HashMap<>(); + private Map> subscriptionForId = new HashMap<>(); public WebSocketService(String serverUrl, boolean includeRawResponses) { this.webSocketClient = new WebSocketClient(parseURI(serverUrl), this); @@ -75,7 +85,7 @@ public T send(Request request, Class responseType) throw throw (IOException) e.getCause(); } - throw new RuntimeException("Unexpected exception", e); + throw new RuntimeException("Unexpected exception", e.getCause()); } } @@ -87,9 +97,7 @@ public CompletableFuture sendAsync( long requestId = request.getId(); requestForId.put(requestId, new WebSocketRequest<>(result, responseType)); try { - String payload = objectMapper.writeValueAsString(request); - webSocketClient.send(payload); - setTimeout(requestId); + sendRequest(request, requestId); } catch (IOException e) { closeRequest(requestId, e); } @@ -97,6 +105,13 @@ public CompletableFuture sendAsync( return result; } + private void sendRequest(Request request, long requestId) throws JsonProcessingException { + String payload = objectMapper.writeValueAsString(request); + log.debug("Sending request: {}", payload); + webSocketClient.send(payload); + setTimeout(requestId); + } + private void setTimeout(long requestId) { executor.schedule( () -> closeRequest( @@ -107,37 +122,91 @@ private void setTimeout(long requestId) { TimeUnit.SECONDS); } - private void closeRequest(long requestId, Exception e) { + void closeRequest(long requestId, Exception e) { CompletableFuture result = requestForId.get(requestId).getCompletableFuture(); requestForId.remove(requestId); result.completeExceptionally(e); } - boolean isWaitingForReply(long requestId) { - return requestForId.containsKey(requestId); - } - void onReply(String replyStr) throws IOException { JsonNode replyJson = parseToTree(replyStr); - WebSocketRequest request = getAndRemoveRequest(replyJson); - Class responseType = request.getResponseType(); - CompletableFuture future = request.getCompletableFuture(); + if (isReply(replyJson)) { + processRequestReply(replyStr, replyJson); + } else if (isSubscriptionEvent(replyJson)) { + processSubscriptionEvent(replyStr, replyJson); + } else { + throw new IOException("Unknown message type"); + } + } + private void processRequestReply(String replyStr, JsonNode replyJson) throws IOException { + long replyId = getReplyId(replyJson); + WebSocketRequest request = getAndRemoveRequest(replyId); try { - Object reply = objectMapper.convertValue(replyJson, responseType); - future.complete(reply); + Object reply = objectMapper.convertValue(replyJson, request.getResponseType()); + + if (reply instanceof EthSubscribe) { + subscribeForEvents(replyId, (EthSubscribe) reply); + } + + sendReplyToListener(request, reply); } catch (IllegalArgumentException e) { - future.completeExceptionally( - new IOException( - String.format( - "Failed to parse '%s' as type %s", - replyStr, - responseType), - e)); + sendExceptionToListener(replyStr, request, e); } } + private void subscribeForEvents(long replyId, EthSubscribe reply) { + WebSocketSubscription subscription = pendingSubscription.remove(replyId); + subscriptionForId.put(reply.getSubscriptionId(), subscription); + } + + private void sendReplyToListener(WebSocketRequest request, Object reply) { + request.getCompletableFuture().complete(reply); + } + + private void sendExceptionToListener( + String replyStr, + WebSocketRequest request, + IllegalArgumentException e) { + request.getCompletableFuture().completeExceptionally( + new IOException( + String.format( + "Failed to parse '%s' as type %s", + replyStr, + request.getResponseType()), + e)); + } + + private void processSubscriptionEvent(String replyStr, JsonNode replyJson) { + log.info("Processing event: {}", replyStr); + String subscriptionId = extractSubscriptionId(replyJson); + WebSocketSubscription subscription = subscriptionForId.get(subscriptionId); + + if (subscription == null) { + unsubscribeFromEventsStream(subscriptionId); + } else { + sendEventToSubscriber(replyJson, subscription); + } + } + + private String extractSubscriptionId(JsonNode replyJson) { + return replyJson.get("params").get("subscription").asText(); + } + + private void sendEventToSubscriber(JsonNode replyJson, WebSocketSubscription subscription) { + Object event = objectMapper.convertValue(replyJson, subscription.getResponseType()); + subscription.getSubject().onNext(event); + } + + private boolean isReply(JsonNode replyJson) { + return replyJson.has("id"); + } + + private boolean isSubscriptionEvent(JsonNode replyJson) { + return replyJson.has("method"); + } + private JsonNode parseToTree(String replyStr) throws IOException { try { return objectMapper.readTree(replyStr); @@ -146,8 +215,7 @@ private JsonNode parseToTree(String replyStr) throws IOException { } } - private WebSocketRequest getAndRemoveRequest(JsonNode replyJson) throws IOException { - long id = getReplyId(replyJson); + private WebSocketRequest getAndRemoveRequest(long id) throws IOException { if (!requestForId.containsKey(id)) { throw new IOException(String.format( "Received reply for unexpected request id: %d", @@ -181,9 +249,91 @@ private static URI parseURI(String serverUrl) { } } + @Override + public > Observable subscribe( + Request request, + Class responseType) { + PublishSubject subject = PublishSubject.create(); + + pendingSubscription.put( + request.getId(), + new WebSocketSubscription<>(subject, responseType)); + sendSubscribeRequest(request, subject); + + return subject + .doOnUnsubscribe(() -> closeSubscription(subject)); + + } + + private > void closeSubscription(PublishSubject subject) { + subject.onCompleted(); + String subscriptionId = getSubscriptionId(subject); + subscriptionForId.remove(subscriptionId); + if (subscriptionId != null) { + unsubscribeFromEventsStream(subscriptionId); + } + } + + private > void sendSubscribeRequest( + Request request, + PublishSubject subject) { + sendAsync(request, EthSubscribe.class) + .thenAccept(ethSubscribe -> { + log.info( + "Subscribed to RPC events with id {}", + ethSubscribe.getSubscriptionId()); + }) + .exceptionally(throwable -> { + log.error( + "Failed to subscribe to RPC events with request id {}", + request.getId()); + subject.onError(throwable.getCause()); + return null; + }); + } + + private > String getSubscriptionId(PublishSubject subject) { + return subscriptionForId.entrySet().stream() + .filter(entry -> entry.getValue().getSubject() == subject) + .map(entry -> entry.getKey()) + .findFirst() + .orElse(null); + } + + private void unsubscribeFromEventsStream(String subscriptionId) { + sendAsync(unsubscribeRequest(subscriptionId), EthUnsubscribe.class) + .thenAccept(ethUnsubscribe -> { + log.debug( + "Successfully unsubscribed from subscription with id {}", + subscriptionId); + }) + .exceptionally(throwable -> { + log.error("Failed to unsubscribe from subscription with id {}", subscriptionId); + return null; + }); + } + + private Request unsubscribeRequest(String subscriptionId) { + return new Request<>( + "eth_unsubscribe", + Arrays.asList(subscriptionId), + this, + EthUnsubscribe.class); + } + + @Override + public boolean supportsSubscription() { + return true; + } + public void close() { webSocketClient.close(); executor.shutdown(); } + + // Method for unit-tests + boolean isWaitingForReply(long requestId) { + return requestForId.containsKey(requestId); + } } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java new file mode 100644 index 0000000000..b8bd38413d --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java @@ -0,0 +1,21 @@ +package org.web3j.protocol.websocket; + +import rx.subjects.PublishSubject; + +public class WebSocketSubscription { + private PublishSubject subject; + private Class responseType; + + public WebSocketSubscription(PublishSubject subject, Class responseType) { + this.subject = subject; + this.responseType = responseType; + } + + public PublishSubject getSubject() { + return subject; + } + + public Class getResponseType() { + return responseType; + } +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java new file mode 100644 index 0000000000..aaa643c3ce --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java @@ -0,0 +1,8 @@ +package org.web3j.protocol.websocket.events; + +public class NewHeadsNotification + extends Notification { +} + + + diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java new file mode 100644 index 0000000000..8415c88ee8 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java @@ -0,0 +1,74 @@ +package org.web3j.protocol.websocket.events; + +public class NewHeadsNotificationParam { + private String difficulty; + private String extraData; + private String gasLimit; + private String gasUsed; + private String logsBloom; + private String miner; + private String nonce; + private String number; + private String parentHash; + private String receiptRoot; + private String sha3Uncles; + private String stateRoot; + private String timestamp; + private String transactionRoot; + + public String getDifficulty() { + return difficulty; + } + + public String getExtraData() { + return extraData; + } + + public String getGasLimit() { + return gasLimit; + } + + public String getGasUsed() { + return gasUsed; + } + + public String getLogsBloom() { + return logsBloom; + } + + public String getMiner() { + return miner; + } + + public String getNonce() { + return nonce; + } + + public String getNumber() { + return number; + } + + public String getParentHash() { + return parentHash; + } + + public String getReceiptRoot() { + return receiptRoot; + } + + public String getSha3Uncles() { + return sha3Uncles; + } + + public String getStateRoot() { + return stateRoot; + } + + public String getTimestamp() { + return timestamp; + } + + public String getTransactionRoot() { + return transactionRoot; + } +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java b/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java new file mode 100644 index 0000000000..b88c806e4c --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java @@ -0,0 +1,23 @@ +package org.web3j.protocol.websocket.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Notification { + private String jsonrpc; + private String method; + private NotificationParams params; + + public String getJsonrpc() { + return jsonrpc; + } + + public String getMethod() { + return method; + } + + public NotificationParams getParams() { + return params; + } +} + diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java b/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java new file mode 100644 index 0000000000..f6d24cd926 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java @@ -0,0 +1,17 @@ +package org.web3j.protocol.websocket.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class NotificationParams { + private T result; + private String subsciption; + + public T getResult() { + return result; + } + + public String getSubsciption() { + return subsciption; + } +} diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 372608fa87..09df49fe3d 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.ConnectException; +import java.util.Arrays; import java.util.Collections; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -9,25 +10,37 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import rx.Observable; +import rx.Observer; +import rx.Subscriber; +import rx.Subscription; + import org.web3j.protocol.core.JsonRpc2_0Web3j; import org.web3j.protocol.core.Request; +import org.web3j.protocol.core.Response; +import org.web3j.protocol.core.methods.response.EthSubscribe; import org.web3j.protocol.core.methods.response.Web3ClientVersion; +import org.web3j.protocol.websocket.events.NewHeadsNotification; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; + import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.startsWith; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public class WebSocketServiceTest { @@ -45,6 +58,7 @@ public class WebSocketServiceTest { @Rule public ExpectedException thrown = ExpectedException.none(); + private Request subscribeRequest; @Before public void before() throws InterruptedException { @@ -98,7 +112,7 @@ public void testWaitingForReplyToSentRequest() throws Exception { @Test public void testNoLongerWaitingForResponseAfterReply() throws Exception { service.sendAsync(request, Web3ClientVersion.class); - service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + sendGethVersionReply(); assertFalse(service.isWaitingForReply(1)); } @@ -130,7 +144,7 @@ public void testThrowExceptionIfIdHasInvalidType() throws Exception { @Test public void testThrowExceptionIfIdIsMissing() throws Exception { thrown.expect(IOException.class); - thrown.expectMessage("'id' field is missing in the reply"); + thrown.expectMessage("Unknown message type"); service.sendAsync(request, Web3ClientVersion.class); service.onReply("{}"); } @@ -148,12 +162,27 @@ public void testReceiveReply() throws Exception { CompletableFuture reply = service.sendAsync( request, Web3ClientVersion.class); - service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + sendGethVersionReply(); assertTrue(reply.isDone()); assertEquals("geth-version", reply.get().getWeb3ClientVersion()); } + @Test + public void testReceiveError() throws Exception { + CompletableFuture reply = service.sendAsync( + request, + Web3ClientVersion.class); + sendErrorReply(); + + assertTrue(reply.isDone()); + Web3ClientVersion version = reply.get(); + assertTrue(version.hasError()); + assertEquals( + new Response.Error(-1, "Error message"), + version.getError()); + } + @Test(expected = ExecutionException.class) public void testCancelRequestAfterTimeout() throws Exception { when(executorService.schedule( @@ -186,7 +215,7 @@ public void testSyncRequest() throws Exception { Executors.newSingleThreadExecutor().execute(() -> { try { requestSent.await(); - service.onReply("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"geth-version\"}"); + sendGethVersionReply(); } catch (Exception e) { e.printStackTrace(); } @@ -205,4 +234,178 @@ public void testCloseWebSocketOnClose() throws Exception { verify(executorService).shutdown(); } + @Test + public void testSendSubscriptionReply() throws Exception { + subscribeToEvents(); + + verifyStartedSubscriptionHadnshake(); + } + + @Test + public void testPropagateSubscriptionEvent() throws Exception { + CountDownLatch eventReceived = new CountDownLatch(1); + CountDownLatch completedCalled = new CountDownLatch(1); + AtomicReference actualNotificationRef = new AtomicReference<>(); + + final Subscription subscription = subscribeToEvents() + .subscribe(new Subscriber() { + @Override + public void onCompleted() { + completedCalled.countDown(); + } + + @Override + public void onError(Throwable e) { + + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + actualNotificationRef.set(newHeadsNotification); + eventReceived.countDown(); + } + }); + + sendSubscriptionConfirmation(); + sendWebSocketEvent(); + + assertTrue(eventReceived.await(2, TimeUnit.SECONDS)); + subscription.unsubscribe(); + + assertTrue(completedCalled.await(2, TimeUnit.SECONDS)); + assertEquals( + "0xd9263f42a87", + actualNotificationRef.get().getParams().getResult().getDifficulty()); + } + + @Test + public void testSendUnsubscribeRequest() throws Exception { + Observable obserable = subscribeToEvents(); + sendSubscriptionConfirmation(); + sendWebSocketEvent(); + + obserable.subscribe().unsubscribe(); + + verifyUnsubscribed(); + } + + @Test + public void testUnsubscribeIfReceivedUnexpectedSubscriptionId() throws Exception { + sendWebSocketEvent(); + + verifyUnsubscribed(); + } + + @Test + public void testDoSendUnsubscribeRequestIfUnsubscribedBeforeConfirmation() throws Exception { + Observable obserable = subscribeToEvents(); + obserable.subscribe().unsubscribe(); + + verifyStartedSubscriptionHadnshake(); + verifyNoMoreInteractions(webSocketClient); + } + + @Test + public void testStopWaitingForSubscriptionReplyAfterTimeout() throws Exception { + Observable obserable = subscribeToEvents(); + CountDownLatch errorReceived = new CountDownLatch(1); + AtomicReference actualThrowable = new AtomicReference<>(); + + obserable.subscribe(new Observer() { + @Override + public void onCompleted() { + } + + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + + } + }); + + Exception e = new IOException("timeout"); + service.closeRequest(1, e); + + assertTrue(errorReceived.await(2, TimeUnit.SECONDS)); + assertEquals( + actualThrowable.get(), + e); + } + + private Observable subscribeToEvents() { + subscribeRequest = new Request<>( + "eth_subscribe", + Arrays.asList("newHeads", Collections.emptyMap()), + service, + EthSubscribe.class); + subscribeRequest.setId(1); + + return service.subscribe( + subscribeRequest, + NewHeadsNotification.class + ); + } + + private void sendErrorReply() throws IOException { + service.onReply( + "{" + + " \"jsonrpc\":\"2.0\"," + + " \"id\":1," + + " \"error\":{" + + " \"code\":-1," + + " \"message\":\"Error message\"," + + " \"data\":null" + + " }" + + "}"); + } + + private void sendGethVersionReply() throws IOException { + service.onReply( + "{" + + " \"jsonrpc\":\"2.0\"," + + " \"id\":1," + + " \"result\":\"geth-version\"" + + "}"); + } + + private void verifyStartedSubscriptionHadnshake() { + verify(webSocketClient).send( + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":[\"newHeads\",{}],\"id\":1}"); + } + + private void verifyUnsubscribed() { + verify(webSocketClient).send(startsWith( + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_unsubscribe\"," + + "\"params\":[\"0xcd0c3e8af590364c09d0fa6a1210faf5\"]")); + } + + private void sendSubscriptionConfirmation() throws IOException { + service.onReply( + "{" + + "\"jsonrpc\":\"2.0\"," + + "\"id\":1," + + "\"result\":\"0xcd0c3e8af590364c09d0fa6a1210faf5\"" + + "}"); + } + + private void sendWebSocketEvent() throws IOException { + service.onReply( + "{" + + " \"jsonrpc\":\"2.0\"," + + " \"method\":\"eth_subscription\"," + + " \"params\":{" + + " \"subscription\":\"0xcd0c3e8af590364c09d0fa6a1210faf5\"," + + " \"result\":{" + + " \"difficulty\":\"0xd9263f42a87\"," + + " \"uncles\":[]" + + " }" + + " }" + + "}"); + } } \ No newline at end of file From 420c29aea79f27367a24ac4f5697cb85519095bf Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Mon, 26 Mar 2018 22:45:34 +0100 Subject: [PATCH 03/15] Adds Web3jService::close() method --- core/src/main/java/org/web3j/protocol/Web3jService.java | 2 ++ .../java/org/web3j/protocol/core/JsonRpc2_0Web3j.java | 6 ++++++ .../main/java/org/web3j/protocol/http/HttpService.java | 5 +++++ .../src/main/java/org/web3j/protocol/ipc/IpcService.java | 2 +- .../org/web3j/protocol/websocket/WebSocketService.java | 1 + .../protocol/core/JsonRpc2_0WebSocketClientJTest.java | 9 +++++---- 6 files changed, 20 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/Web3jService.java b/core/src/main/java/org/web3j/protocol/Web3jService.java index a8c2b4c0c3..221e7839da 100644 --- a/core/src/main/java/org/web3j/protocol/Web3jService.java +++ b/core/src/main/java/org/web3j/protocol/Web3jService.java @@ -24,4 +24,6 @@ > Observable subscribe( Class responseType); boolean supportsSubscription(); + + void close() throws IOException; } diff --git a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java index 23c0487c8f..1bc4980f81 100644 --- a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java +++ b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java @@ -1,5 +1,6 @@ package org.web3j.protocol.core; +import java.io.IOException; import java.math.BigInteger; import java.util.Arrays; import java.util.Collections; @@ -779,5 +780,10 @@ public Observable catchUpToLatestAndSubscribeToNewBlocksObservable( @Override public void shutdown() { scheduledExecutorService.shutdown(); + try { + web3jService.close(); + } catch (IOException e) { + throw new RuntimeException("Failed to close web3j service", e); + } } } diff --git a/core/src/main/java/org/web3j/protocol/http/HttpService.java b/core/src/main/java/org/web3j/protocol/http/HttpService.java index a321fe5e25..32fb878773 100644 --- a/core/src/main/java/org/web3j/protocol/http/HttpService.java +++ b/core/src/main/java/org/web3j/protocol/http/HttpService.java @@ -159,4 +159,9 @@ public void addHeaders(Map headersToAdd) { public HashMap getHeaders() { return headers; } + + @Override + public void close() throws IOException { + + } } diff --git a/core/src/main/java/org/web3j/protocol/ipc/IpcService.java b/core/src/main/java/org/web3j/protocol/ipc/IpcService.java index de74306ecc..eeefc16176 100644 --- a/core/src/main/java/org/web3j/protocol/ipc/IpcService.java +++ b/core/src/main/java/org/web3j/protocol/ipc/IpcService.java @@ -64,7 +64,7 @@ protected InputStream performIO(String payload) throws IOException { return new ByteArrayInputStream(result.getBytes("UTF-8")); } - @Deprecated + @Override public void close() throws IOException { if (ioFacade != null) { ioFacade.close(); diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 0d77ec43d1..2903b87483 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -326,6 +326,7 @@ public boolean supportsSubscription() { return true; } + @Override public void close() { webSocketClient.close(); executor.shutdown(); diff --git a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java index 08da91ed9c..5da2c3903c 100644 --- a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java +++ b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java @@ -8,20 +8,21 @@ import org.web3j.protocol.Web3jService; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; public class JsonRpc2_0WebSocketClientJTest { @Test - public void testStopExecutorOnShutdown() { + public void testStopExecutorOnShutdown() throws Exception { ScheduledExecutorService scheduledExecutorService = mock(ScheduledExecutorService.class); + Web3jService service = mock(Web3jService.class); - Web3j web3j = Web3j.build( - mock(Web3jService.class), 10, scheduledExecutorService - ); + Web3j web3j = Web3j.build(service, 10, scheduledExecutorService); web3j.shutdown(); verify(scheduledExecutorService).shutdown(); + verify(service).close(); } } \ No newline at end of file From c67c6b9e8df748820bb35137c5446cd033358323 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Tue, 27 Mar 2018 08:43:17 +0100 Subject: [PATCH 04/15] Implements Web3jRx::newHeadsNotifications method --- .../web3j/protocol/core/JsonRpc2_0Web3j.java | 14 ++++++++ .../java/org/web3j/protocol/rx/Web3jRx.java | 9 ++++++ .../protocol/websocket/WebSocketClient.java | 2 +- .../protocol/websocket/WebSocketService.java | 7 ++++ .../protocol/core/WebSocketEventTest.java | 32 +++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java diff --git a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java index 1bc4980f81..1e494c3be1 100644 --- a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java +++ b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java @@ -45,6 +45,7 @@ import org.web3j.protocol.core.methods.response.EthSign; import org.web3j.protocol.core.methods.response.EthSubmitHashrate; import org.web3j.protocol.core.methods.response.EthSubmitWork; +import org.web3j.protocol.core.methods.response.EthSubscribe; import org.web3j.protocol.core.methods.response.EthSyncing; import org.web3j.protocol.core.methods.response.EthTransaction; import org.web3j.protocol.core.methods.response.EthUninstallFilter; @@ -63,6 +64,7 @@ import org.web3j.protocol.core.methods.response.Web3ClientVersion; import org.web3j.protocol.core.methods.response.Web3Sha3; import org.web3j.protocol.rx.JsonRpc2_0Rx; +import org.web3j.protocol.websocket.events.NewHeadsNotification; import org.web3j.utils.Async; import org.web3j.utils.Numeric; @@ -687,6 +689,18 @@ public Request shhGetMessages(BigInteger filterId) { ShhMessages.class); } + @Override + public Observable newHeadsNotifications() { + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("newHeads", Collections.emptyMap()), + web3jService, + EthSubscribe.class), + NewHeadsNotification.class + ); + } + @Override public Observable ethBlockHashObservable() { return web3jRx.ethBlockHashObservable(blockTime); diff --git a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java index a9186a9cbb..e58b0c92c1 100644 --- a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java +++ b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java @@ -7,6 +7,7 @@ import org.web3j.protocol.core.methods.response.EthBlock; import org.web3j.protocol.core.methods.response.Log; import org.web3j.protocol.core.methods.response.Transaction; +import org.web3j.protocol.websocket.events.NewHeadsNotification; /** * The Observables JSON-RPC client event API. @@ -167,4 +168,12 @@ Observable catchUpToLatestAndSubscribeToNewBlocksObservable( */ Observable catchUpToLatestAndSubscribeToNewTransactionsObservable( DefaultBlockParameter startBlock); + + /** + * Creates an observable that emits a notification when a new header is appended to a chain, + * including chain reorganizations. + * + * @return Observable that emits a notification for every new header + */ + Observable newHeadsNotifications(); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java index 7571e46771..f5cdd0af03 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -6,7 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -class WebSocketClient extends org.java_websocket.client.WebSocketClient { +public class WebSocketClient extends org.java_websocket.client.WebSocketClient { private static final Logger log = LoggerFactory.getLogger(WebSocketClient.class); diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 2903b87483..d84a9e5b16 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -53,6 +53,13 @@ public WebSocketService(String serverUrl, boolean includeRawResponses) { this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); } + public WebSocketService(WebSocketClient webSocketClient, + boolean includeRawResponses) { + this.webSocketClient = webSocketClient; + this.executor = Executors.newScheduledThreadPool(1); + this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); + } + WebSocketService(WebSocketClient webSocketClient, ScheduledExecutorService executor, boolean includeRawResponses) { diff --git a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java new file mode 100644 index 0000000000..d59d114a94 --- /dev/null +++ b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java @@ -0,0 +1,32 @@ +package org.web3j.protocol.core; + +import org.junit.Test; + +import org.web3j.protocol.Web3j; +import org.web3j.protocol.websocket.WebSocketClient; +import org.web3j.protocol.websocket.WebSocketService; + +import static org.mockito.Matchers.matches; +import static org.mockito.Matchers.startsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class WebSocketEventTest { + + private WebSocketClient webSocketClient = mock(WebSocketClient.class); + + private WebSocketService webSocketService = new WebSocketService( + webSocketClient, true + ); + + private Web3j web3j = Web3j.build(webSocketService); + + @Test + public void testNewHeadsNotifications() { + web3j.newHeadsNotifications(); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":\\[\"newHeads\",\\{}],\"id\":[0-9]{1,}}")); + } +} From f2953183e7ec6544f3d85228731cd1172c69dcca Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Tue, 27 Mar 2018 20:26:51 +0100 Subject: [PATCH 05/15] Refactor WebSocketService --- .../protocol/websocket/WebSocketClient.java | 11 ++-- .../protocol/websocket/WebSocketListener.java | 7 +++ .../protocol/websocket/WebSocketService.java | 50 +++++++------------ .../protocol/core/WebSocketEventTest.java | 1 - .../websocket/WebSocketServiceTest.java | 25 ++++------ 5 files changed, 42 insertions(+), 52 deletions(-) create mode 100644 core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java index f5cdd0af03..37b732168b 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -10,11 +10,10 @@ public class WebSocketClient extends org.java_websocket.client.WebSocketClient { private static final Logger log = LoggerFactory.getLogger(WebSocketClient.class); - final WebSocketService service; + private WebSocketListener listener; - public WebSocketClient(URI serverUri, WebSocketService service) { + public WebSocketClient(URI serverUri) { super(serverUri); - this.service = service; } @Override @@ -26,7 +25,7 @@ public void onOpen(ServerHandshake serverHandshake) { public void onMessage(String s) { try { log.debug("Received message {} from server {}", s, uri); - service.onReply(s); + listener.onMessage(s); log.debug("Processed message {} from server {}", s, uri); } catch (Exception e) { log.error("Failed to process message '{}' from server {}", s, uri); @@ -42,4 +41,8 @@ public void onClose(int i, String s, boolean b) { public void onError(Exception e) { log.error(String.format("WebSocket connection to {} failed with error", uri), e); } + + public void setListener(WebSocketListener listener) { + this.listener = listener; + } } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java new file mode 100644 index 0000000000..09615276f9 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java @@ -0,0 +1,7 @@ +package org.web3j.protocol.websocket; + +import java.io.IOException; + +public interface WebSocketListener { + void onMessage(String message) throws IOException; +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index d84a9e5b16..e92296c554 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -44,20 +44,15 @@ public class WebSocketService implements Web3jService { private final ObjectMapper objectMapper; private Map> requestForId = new HashMap<>(); - private Map> pendingSubscription = new HashMap<>(); private Map> subscriptionForId = new HashMap<>(); public WebSocketService(String serverUrl, boolean includeRawResponses) { - this.webSocketClient = new WebSocketClient(parseURI(serverUrl), this); - this.executor = Executors.newScheduledThreadPool(1); - this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); + this(new WebSocketClient(parseURI(serverUrl)), includeRawResponses); } public WebSocketService(WebSocketClient webSocketClient, boolean includeRawResponses) { - this.webSocketClient = webSocketClient; - this.executor = Executors.newScheduledThreadPool(1); - this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); + this(webSocketClient, Executors.newScheduledThreadPool(1), includeRawResponses); } WebSocketService(WebSocketClient webSocketClient, @@ -74,6 +69,7 @@ public void connect() throws ConnectException { if (!connected) { throw new ConnectException("Failed to connect to WebSocket"); } + webSocketClient.setListener(this::onMessage); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("Interrupted while connecting via WebSocket protocol"); @@ -100,6 +96,7 @@ public T send(Request request, Class responseType) throw public CompletableFuture sendAsync( Request request, Class responseType) { + CompletableFuture result = new CompletableFuture<>(); long requestId = request.getId(); requestForId.put(requestId, new WebSocketRequest<>(result, responseType)); @@ -116,10 +113,10 @@ private void sendRequest(Request request, long requestId) throws JsonProcessingE String payload = objectMapper.writeValueAsString(request); log.debug("Sending request: {}", payload); webSocketClient.send(payload); - setTimeout(requestId); + setRequestTimeout(requestId); } - private void setTimeout(long requestId) { + private void setRequestTimeout(long requestId) { executor.schedule( () -> closeRequest( requestId, @@ -135,13 +132,13 @@ void closeRequest(long requestId, Exception e) { result.completeExceptionally(e); } - void onReply(String replyStr) throws IOException { - JsonNode replyJson = parseToTree(replyStr); + void onMessage(String messageStr) throws IOException { + JsonNode replyJson = parseToTree(messageStr); if (isReply(replyJson)) { - processRequestReply(replyStr, replyJson); + processRequestReply(messageStr, replyJson); } else if (isSubscriptionEvent(replyJson)) { - processSubscriptionEvent(replyStr, replyJson); + processSubscriptionEvent(messageStr, replyJson); } else { throw new IOException("Unknown message type"); } @@ -153,21 +150,12 @@ private void processRequestReply(String replyStr, JsonNode replyJson) throws IOE try { Object reply = objectMapper.convertValue(replyJson, request.getResponseType()); - if (reply instanceof EthSubscribe) { - subscribeForEvents(replyId, (EthSubscribe) reply); - } - sendReplyToListener(request, reply); } catch (IllegalArgumentException e) { sendExceptionToListener(replyStr, request, e); } } - private void subscribeForEvents(long replyId, EthSubscribe reply) { - WebSocketSubscription subscription = pendingSubscription.remove(replyId); - subscriptionForId.put(reply.getSubscriptionId(), subscription); - } - private void sendReplyToListener(WebSocketRequest request, Object reply) { request.getCompletableFuture().complete(reply); } @@ -190,10 +178,10 @@ private void processSubscriptionEvent(String replyStr, JsonNode replyJson) { String subscriptionId = extractSubscriptionId(replyJson); WebSocketSubscription subscription = subscriptionForId.get(subscriptionId); - if (subscription == null) { - unsubscribeFromEventsStream(subscriptionId); - } else { + if (subscription != null) { sendEventToSubscriber(replyJson, subscription); + } else { + log.warn("No subscriber for WebSocket event with subscription id {}", subscriptionId); } } @@ -262,10 +250,7 @@ public > Observable subscribe( Class responseType) { PublishSubject subject = PublishSubject.create(); - pendingSubscription.put( - request.getId(), - new WebSocketSubscription<>(subject, responseType)); - sendSubscribeRequest(request, subject); + sendSubscribeRequest(request, subject, responseType); return subject .doOnUnsubscribe(() -> closeSubscription(subject)); @@ -283,12 +268,15 @@ private > void closeSubscription(PublishSubject sub private > void sendSubscribeRequest( Request request, - PublishSubject subject) { + PublishSubject subject, Class responseType) { sendAsync(request, EthSubscribe.class) .thenAccept(ethSubscribe -> { log.info( "Subscribed to RPC events with id {}", ethSubscribe.getSubscriptionId()); + subscriptionForId.put( + ethSubscribe.getSubscriptionId(), + new WebSocketSubscription<>(subject, responseType)); }) .exceptionally(throwable -> { log.error( @@ -339,7 +327,7 @@ public void close() { executor.shutdown(); } - // Method for unit-tests + // Method visible for unit-tests boolean isWaitingForReply(long requestId) { return requestForId.containsKey(requestId); } diff --git a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java index d59d114a94..3718020c13 100644 --- a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java +++ b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java @@ -7,7 +7,6 @@ import org.web3j.protocol.websocket.WebSocketService; import static org.mockito.Matchers.matches; -import static org.mockito.Matchers.startsWith; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 09df49fe3d..605fffb1d3 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -130,7 +130,7 @@ public void testIgnoreInvalidReplies() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Failed to parse incoming WebSocket message"); service.sendAsync(request, Web3ClientVersion.class); - service.onReply("{"); + service.onMessage("{"); } @Test @@ -138,7 +138,7 @@ public void testThrowExceptionIfIdHasInvalidType() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("'id' expected to be long, but it is: 'true'"); service.sendAsync(request, Web3ClientVersion.class); - service.onReply("{\"id\":true}"); + service.onMessage("{\"id\":true}"); } @Test @@ -146,7 +146,7 @@ public void testThrowExceptionIfIdIsMissing() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Unknown message type"); service.sendAsync(request, Web3ClientVersion.class); - service.onReply("{}"); + service.onMessage("{}"); } @Test @@ -154,7 +154,7 @@ public void testThrowExceptionIfUnexpectedIdIsReceived() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Received reply for unexpected request id: 12345"); service.sendAsync(request, Web3ClientVersion.class); - service.onReply("{\"jsonrpc\":\"2.0\",\"id\":12345,\"result\":\"geth-version\"}"); + service.onMessage("{\"jsonrpc\":\"2.0\",\"id\":12345,\"result\":\"geth-version\"}"); } @Test @@ -289,13 +289,6 @@ public void testSendUnsubscribeRequest() throws Exception { verifyUnsubscribed(); } - @Test - public void testUnsubscribeIfReceivedUnexpectedSubscriptionId() throws Exception { - sendWebSocketEvent(); - - verifyUnsubscribed(); - } - @Test public void testDoSendUnsubscribeRequestIfUnsubscribedBeforeConfirmation() throws Exception { Observable obserable = subscribeToEvents(); @@ -337,7 +330,7 @@ public void onNext(NewHeadsNotification newHeadsNotification) { e); } - private Observable subscribeToEvents() { + private Observable subscribeToEvents() throws IOException { subscribeRequest = new Request<>( "eth_subscribe", Arrays.asList("newHeads", Collections.emptyMap()), @@ -352,7 +345,7 @@ private Observable subscribeToEvents() { } private void sendErrorReply() throws IOException { - service.onReply( + service.onMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"id\":1," @@ -365,7 +358,7 @@ private void sendErrorReply() throws IOException { } private void sendGethVersionReply() throws IOException { - service.onReply( + service.onMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"id\":1," @@ -386,7 +379,7 @@ private void verifyUnsubscribed() { } private void sendSubscriptionConfirmation() throws IOException { - service.onReply( + service.onMessage( "{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," @@ -395,7 +388,7 @@ private void sendSubscriptionConfirmation() throws IOException { } private void sendWebSocketEvent() throws IOException { - service.onReply( + service.onMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"method\":\"eth_subscription\"," From e10b145ff8be4b73e63532e5f1f6d0686c8885f6 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Tue, 27 Mar 2018 22:39:43 +0100 Subject: [PATCH 06/15] Add support for all WebSocket subscriptions --- .../web3j/protocol/core/JsonRpc2_0Web3j.java | 59 ++++++++++++++++++- .../java/org/web3j/protocol/rx/Web3jRx.java | 31 ++++++++++ .../web3j/protocol/websocket/events/Log.java | 46 +++++++++++++++ .../websocket/events/LogNotification.java | 4 ++ ...adsNotificationParam.java => NewHead.java} | 2 +- .../events/NewHeadsNotification.java | 2 +- .../PendingTransactionNotification.java | 4 ++ .../websocket/events/SyncingNotfication.java | 6 ++ .../protocol/core/WebSocketEventTest.java | 44 +++++++++++++- .../websocket/WebSocketServiceTest.java | 1 + 10 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/Log.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/LogNotification.java rename core/src/main/java/org/web3j/protocol/websocket/events/{NewHeadsNotificationParam.java => NewHead.java} (97%) create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/PendingTransactionNotification.java create mode 100644 core/src/main/java/org/web3j/protocol/websocket/events/SyncingNotfication.java diff --git a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java index 1e494c3be1..a4f1c5606d 100644 --- a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java +++ b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java @@ -4,6 +4,9 @@ import java.math.BigInteger; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.ScheduledExecutorService; import rx.Observable; @@ -64,7 +67,10 @@ import org.web3j.protocol.core.methods.response.Web3ClientVersion; import org.web3j.protocol.core.methods.response.Web3Sha3; import org.web3j.protocol.rx.JsonRpc2_0Rx; +import org.web3j.protocol.websocket.events.LogNotification; import org.web3j.protocol.websocket.events.NewHeadsNotification; +import org.web3j.protocol.websocket.events.PendingTransactionNotification; +import org.web3j.protocol.websocket.events.SyncingNotfication; import org.web3j.utils.Async; import org.web3j.utils.Numeric; @@ -694,13 +700,64 @@ public Observable newHeadsNotifications() { return web3jService.subscribe( new Request<>( "eth_subscribe", - Arrays.asList("newHeads", Collections.emptyMap()), + Collections.singletonList("newHeads"), web3jService, EthSubscribe.class), NewHeadsNotification.class ); } + @Override + public Observable logsNotifications( + List addresses, List topics) { + + Map params = createLogsParams(addresses, topics); + + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("logs", params), + web3jService, + EthSubscribe.class), + LogNotification.class + ); + } + + private Map createLogsParams(List addresses, List topics) { + Map params = new HashMap<>(); + if (!addresses.isEmpty()) { + params.put("address", addresses); + } + if (!topics.isEmpty()) { + params.put("topics", topics); + } + return params; + } + + @Override + public Observable newPendingTransactionsNotifications() { + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("newPendingTransactions"), + web3jService, + EthSubscribe.class), + PendingTransactionNotification.class + ); + } + + @Override + public Observable syncingStatusNotifications() { + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("syncing"), + web3jService, + EthSubscribe.class), + SyncingNotfication.class + ); + } + @Override public Observable ethBlockHashObservable() { return web3jRx.ethBlockHashObservable(blockTime); diff --git a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java index e58b0c92c1..40b16749c4 100644 --- a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java +++ b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java @@ -1,5 +1,7 @@ package org.web3j.protocol.rx; +import java.util.List; + import rx.Observable; import org.web3j.protocol.core.DefaultBlockParameter; @@ -7,7 +9,10 @@ import org.web3j.protocol.core.methods.response.EthBlock; import org.web3j.protocol.core.methods.response.Log; import org.web3j.protocol.core.methods.response.Transaction; +import org.web3j.protocol.websocket.events.LogNotification; import org.web3j.protocol.websocket.events.NewHeadsNotification; +import org.web3j.protocol.websocket.events.PendingTransactionNotification; +import org.web3j.protocol.websocket.events.SyncingNotfication; /** * The Observables JSON-RPC client event API. @@ -176,4 +181,30 @@ Observable catchUpToLatestAndSubscribeToNewTransactionsObservable( * @return Observable that emits a notification for every new header */ Observable newHeadsNotifications(); + + /** + * Creates an observable that emits notifications for logs included in new imported blocks. + * + * @param addresses only return logs from this list of address. Return logs from all addresses + * if the list is empty + * @param topics only return logs that match specified topics. Returns logs for all topics if + * the list is empty + * @return Observable that emits logs included in new blocks + */ + Observable logsNotifications(List addresses, List topics); + + /** + * Creates an observable that emits a notification when a new transaction is added + * to the pending state and is signed with a key that is available in the node. + * + * @return Observable that emits a notification when a new transaction is added + * to the pending state + */ + Observable newPendingTransactionsNotifications(); + + /** + * Creates an observable that emits a notification when a node starts or stops syncing. + * @return Observalbe that emits changes to syncing status + */ + Observable syncingStatusNotifications(); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/Log.java b/core/src/main/java/org/web3j/protocol/websocket/events/Log.java new file mode 100644 index 0000000000..f331219c24 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/Log.java @@ -0,0 +1,46 @@ +package org.web3j.protocol.websocket.events; + +import java.util.List; + +public class Log { + private String address; + private String blockHash; + private String blockNumber; + private String data; + private String logIndex; + private List topics; + private String transactionHash; + private String transactionIndex; + + public String getAddress() { + return address; + } + + public String getBlockHash() { + return blockHash; + } + + public String getBlockNumber() { + return blockNumber; + } + + public String getData() { + return data; + } + + public String getLogIndex() { + return logIndex; + } + + public List getTopics() { + return topics; + } + + public String getTransactionHash() { + return transactionHash; + } + + public String getTransactionIndex() { + return transactionIndex; + } +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/LogNotification.java b/core/src/main/java/org/web3j/protocol/websocket/events/LogNotification.java new file mode 100644 index 0000000000..ca8ee22423 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/LogNotification.java @@ -0,0 +1,4 @@ +package org.web3j.protocol.websocket.events; + +public class LogNotification extends Notification { +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java b/core/src/main/java/org/web3j/protocol/websocket/events/NewHead.java similarity index 97% rename from core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java rename to core/src/main/java/org/web3j/protocol/websocket/events/NewHead.java index 8415c88ee8..e44bb9a535 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotificationParam.java +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NewHead.java @@ -1,6 +1,6 @@ package org.web3j.protocol.websocket.events; -public class NewHeadsNotificationParam { +public class NewHead { private String difficulty; private String extraData; private String gasLimit; diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java index aaa643c3ce..65c1e21299 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NewHeadsNotification.java @@ -1,7 +1,7 @@ package org.web3j.protocol.websocket.events; public class NewHeadsNotification - extends Notification { + extends Notification { } diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/PendingTransactionNotification.java b/core/src/main/java/org/web3j/protocol/websocket/events/PendingTransactionNotification.java new file mode 100644 index 0000000000..09454f7022 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/PendingTransactionNotification.java @@ -0,0 +1,4 @@ +package org.web3j.protocol.websocket.events; + +public class PendingTransactionNotification extends Notification { +} diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/SyncingNotfication.java b/core/src/main/java/org/web3j/protocol/websocket/events/SyncingNotfication.java new file mode 100644 index 0000000000..ca786d3ed0 --- /dev/null +++ b/core/src/main/java/org/web3j/protocol/websocket/events/SyncingNotfication.java @@ -0,0 +1,6 @@ +package org.web3j.protocol.websocket.events; + +import org.web3j.protocol.core.methods.response.EthSyncing; + +public class SyncingNotfication extends Notification { +} diff --git a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java index 3718020c13..56c589a2d9 100644 --- a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java +++ b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java @@ -1,5 +1,8 @@ package org.web3j.protocol.core; +import java.util.ArrayList; +import java.util.Collections; + import org.junit.Test; import org.web3j.protocol.Web3j; @@ -26,6 +29,45 @@ public void testNewHeadsNotifications() { verify(webSocketClient).send(matches( "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," - + "\"params\":\\[\"newHeads\",\\{}],\"id\":[0-9]{1,}}")); + + "\"params\":\\[\"newHeads\"],\"id\":[0-9]{1,}}")); + } + + @Test + public void testLogsNotificationsWithoutArguments() { + web3j.logsNotifications(new ArrayList<>(), new ArrayList<>()); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":\\[\"logs\",\\{}],\"id\":[0-9]{1,}}")); + } + + @Test + public void testLogsNotificationsWithArguments() { + web3j.logsNotifications( + Collections.singletonList("0x1"), + Collections.singletonList("0x2")); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":\\[\"logs\",\\{\"address\":\\[\"0x1\"]," + + "\"topics\":\\[\"0x2\"]}],\"id\":[0-9]{1,}}")); + } + + @Test + public void testPendingTransactionsNotifications() { + web3j.newPendingTransactionsNotifications(); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\",\"params\":" + + "\\[\"newPendingTransactions\"],\"id\":[0-9]{1,}}")); + } + + @Test + public void testSyncingStatusNotifications() { + web3j.syncingStatusNotifications(); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":\\[\"syncing\"],\"id\":[0-9]{1,}}")); } } diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 605fffb1d3..79fcdc6e62 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.ConnectException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.concurrent.CompletableFuture; From abe4279e128be97cdb4977fffc6203d0d4853e26 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Tue, 27 Mar 2018 23:00:41 +0100 Subject: [PATCH 07/15] Adds JavaDoc for WebSocket related classes --- .../protocol/websocket/WebSocketClient.java | 14 ++++++++++++-- .../protocol/websocket/WebSocketListener.java | 9 +++++++++ .../protocol/websocket/WebSocketRequest.java | 5 +++++ .../protocol/websocket/WebSocketService.java | 17 +++++++++++++++++ .../protocol/websocket/events/Notification.java | 5 +++++ .../websocket/events/NotificationParams.java | 5 +++++ 6 files changed, 53 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java index 37b732168b..9dde4f765d 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -6,6 +6,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Web socket client implementation that connects to a specify URI. Allows to provide a listener + * that will be called when a new message is received by the client. + */ public class WebSocketClient extends org.java_websocket.client.WebSocketClient { private static final Logger log = LoggerFactory.getLogger(WebSocketClient.class); @@ -33,8 +37,9 @@ public void onMessage(String s) { } @Override - public void onClose(int i, String s, boolean b) { - log.info("Closed WebSocket connection to {}", uri); + public void onClose(int code, String reason, boolean remote) { + log.info("Closed WebSocket connection to {}, because of reason: '{}'." + + "Conection closed remotely: {}", uri, reason, remote); } @Override @@ -42,6 +47,11 @@ public void onError(Exception e) { log.error(String.format("WebSocket connection to {} failed with error", uri), e); } + /** + * Set a listener that will be called when a new message is received by the client. + * + * @param listener WebSocket listener + */ public void setListener(WebSocketListener listener) { this.listener = listener; } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java index 09615276f9..5410d18cda 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java @@ -2,6 +2,15 @@ import java.io.IOException; +/** + * A listener used to notify about about new WebSocket messages. + */ public interface WebSocketListener { + + /** + * Called when a new WebSocket message is delivered. + * @param message new WebSocket message + * @throws IOException thrown if an observer failed to process the message + */ void onMessage(String message) throws IOException; } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java index e93a1126f7..f86fef060d 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java @@ -2,6 +2,11 @@ import java.util.concurrent.CompletableFuture; +/** + * Objects necessary to process a reply for a request sent via WebSocket protocol. + * + * @param type of a data item that should be returned by the sent request + */ class WebSocketRequest { private CompletableFuture completableFuture; private Class responseType; diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index e92296c554..7a124d6a03 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -33,17 +33,34 @@ import org.web3j.protocol.core.methods.response.EthUnsubscribe; import org.web3j.protocol.websocket.events.Notification; +/** + * Web socket service that allows to interact with JSON-RPC via WebSocket protocol. + * + *

Allows to interact with JSON-RPC either by sending individual requests or by + * subscribing to a stream of notifications. To subscribe to a notification it first + * sends a special JSON-RPC request that returns a unique subscription id. A subscription + * id is used to identify events for a single notifications stream. + * + *

To unsubscribe from a stream of notifications it should send another JSON-RPC + * request. + */ public class WebSocketService implements Web3jService { private static final Logger log = LoggerFactory.getLogger(WebSocketService.class); + // Timeout for JSON-RPC requests static final long REQUEST_TIMEOUT = 60; + // WebSocket client private final WebSocketClient webSocketClient; + // Executor to schedule request timeouts private final ScheduledExecutorService executor; + // Object mapper to map incoming JSON objects private final ObjectMapper objectMapper; + // Map of a sent request id to objects necessary to process this id private Map> requestForId = new HashMap<>(); + // Map of a subscription id to objects necessary to process incoming events private Map> subscriptionForId = new HashMap<>(); public WebSocketService(String serverUrl, boolean includeRawResponses) { diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java b/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java index b88c806e4c..2ddacc8837 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java +++ b/core/src/main/java/org/web3j/protocol/websocket/events/Notification.java @@ -2,6 +2,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +/** + * Base class for WebSocket notifications. + * + * @param type of data return by a particular subscription + */ @JsonIgnoreProperties(ignoreUnknown = true) public class Notification { private String jsonrpc; diff --git a/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java b/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java index f6d24cd926..cd335a6e72 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java +++ b/core/src/main/java/org/web3j/protocol/websocket/events/NotificationParams.java @@ -2,6 +2,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +/** + * Generic class for a notification param. Contains a subscription id and a data item. + * + * @param type of data return by a particular subscription + */ @JsonIgnoreProperties(ignoreUnknown = true) public class NotificationParams { private T result; From 19f780ed2f19a92a86e85f422ce2e568ee7ca1a6 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Wed, 28 Mar 2018 08:15:45 +0100 Subject: [PATCH 08/15] Allows a client of WebSocketService to specify unsubscribe method --- .../main/java/org/web3j/protocol/Service.java | 7 +-- .../java/org/web3j/protocol/Web3jService.java | 45 ++++++++++++++++++- .../web3j/protocol/core/JsonRpc2_0Web3j.java | 4 ++ .../protocol/websocket/WebSocketService.java | 22 +++++---- .../websocket/WebSocketServiceTest.java | 1 + 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/Service.java b/core/src/main/java/org/web3j/protocol/Service.java index 684ce51e8b..6aacb0d605 100644 --- a/core/src/main/java/org/web3j/protocol/Service.java +++ b/core/src/main/java/org/web3j/protocol/Service.java @@ -46,17 +46,14 @@ public CompletableFuture sendAsync( return Async.run(() -> send(jsonRpc20Request, responseType)); } - + @Override public > Observable subscribe( Request request, + String unsubscribeMethod, Class responseType) { throw new UnsupportedOperationException( String.format( "Service %s does not support subscriptions", this.getClass().getSimpleName())); } - - public boolean supportsSubscription() { - return false; - } } diff --git a/core/src/main/java/org/web3j/protocol/Web3jService.java b/core/src/main/java/org/web3j/protocol/Web3jService.java index 221e7839da..69aabbf803 100644 --- a/core/src/main/java/org/web3j/protocol/Web3jService.java +++ b/core/src/main/java/org/web3j/protocol/Web3jService.java @@ -13,17 +13,58 @@ * Services API. */ public interface Web3jService { + + /** + * Perform a synchronous JSON-RPC request. + * + * @param request request to perform + * @param responseType class of a data item returned by the request + * @param type of a data item returned by the request + * @return deserialized JSON-RPC response + * @throws IOException thrown if failed to perform a request + */ T send( Request request, Class responseType) throws IOException; + /** + * Performs an asynchronous JSON-RPC request. + * + * @param request request to perform + * @param responseType class of a data item returned by the request + * @param type of a data item returned by the request + * @return CompletableFuture that will be completed when a result is returned or if a + * request has failed + */ CompletableFuture sendAsync( Request request, Class responseType); + /** + * Subscribe to a stream of notifications. A stream of notifications is opened by + * by performing a specified JSON-RPC request and is closed by calling + * the unsubscribe method. Different WebSocket implementations use different pair of + * subscribe/unsubscribe methods. + * + *

This method creates an Observable that can be used to subscribe to new notifications. + * When a client unsubscribes from this Observable the service unsubscribes from + * the underlying stream of events. + * + * @param request JSON-RPC request that will be send to subscribe to a stream of + * events + * @param unsubscribeMethod method that will be called to unsubscribe from a + * stream of notifications + * @param responseType class of incoming events objects in a stream + * @param type of incoming event objects + * @return Observable that emits incoming events + */ > Observable subscribe( Request request, + String unsubscribeMethod, Class responseType); - boolean supportsSubscription(); - + /** + * Closes resources used by the service. + * + * @throws IOException thrown if a service failed to close all resources + */ void close() throws IOException; } diff --git a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java index a4f1c5606d..434e8d5765 100644 --- a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java +++ b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java @@ -703,6 +703,7 @@ public Observable newHeadsNotifications() { Collections.singletonList("newHeads"), web3jService, EthSubscribe.class), + "eth_unsubscribe", NewHeadsNotification.class ); } @@ -719,6 +720,7 @@ public Observable logsNotifications( Arrays.asList("logs", params), web3jService, EthSubscribe.class), + "eth_unsubscribe", LogNotification.class ); } @@ -742,6 +744,7 @@ public Observable newPendingTransactionsNotifica Arrays.asList("newPendingTransactions"), web3jService, EthSubscribe.class), + "eth_unsubscribe", PendingTransactionNotification.class ); } @@ -754,6 +757,7 @@ public Observable syncingStatusNotifications() { Arrays.asList("syncing"), web3jService, EthSubscribe.class), + "eth_unsubscribe", SyncingNotfication.class ); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 7a124d6a03..2c02d873b6 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -264,22 +264,24 @@ private static URI parseURI(String serverUrl) { @Override public > Observable subscribe( Request request, + String unsubscribeMethod, Class responseType) { PublishSubject subject = PublishSubject.create(); sendSubscribeRequest(request, subject, responseType); return subject - .doOnUnsubscribe(() -> closeSubscription(subject)); + .doOnUnsubscribe(() -> closeSubscription(subject, unsubscribeMethod)); } - private > void closeSubscription(PublishSubject subject) { + private > void closeSubscription( + PublishSubject subject, String unsubscribeMethod) { subject.onCompleted(); String subscriptionId = getSubscriptionId(subject); subscriptionForId.remove(subscriptionId); if (subscriptionId != null) { - unsubscribeFromEventsStream(subscriptionId); + unsubscribeFromEventsStream(subscriptionId, unsubscribeMethod); } } @@ -312,8 +314,8 @@ private > String getSubscriptionId(PublishSubject s .orElse(null); } - private void unsubscribeFromEventsStream(String subscriptionId) { - sendAsync(unsubscribeRequest(subscriptionId), EthUnsubscribe.class) + private void unsubscribeFromEventsStream(String subscriptionId, String unsubscribeMethod) { + sendAsync(unsubscribeRequest(subscriptionId, unsubscribeMethod), EthUnsubscribe.class) .thenAccept(ethUnsubscribe -> { log.debug( "Successfully unsubscribed from subscription with id {}", @@ -325,19 +327,15 @@ private void unsubscribeFromEventsStream(String subscriptionId) { }); } - private Request unsubscribeRequest(String subscriptionId) { + private Request unsubscribeRequest( + String subscriptionId, String unsubscribeMethod) { return new Request<>( - "eth_unsubscribe", + unsubscribeMethod, Arrays.asList(subscriptionId), this, EthUnsubscribe.class); } - @Override - public boolean supportsSubscription() { - return true; - } - @Override public void close() { webSocketClient.close(); diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 79fcdc6e62..6255004343 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -341,6 +341,7 @@ private Observable subscribeToEvents() throws IOException return service.subscribe( subscribeRequest, + "eth_unsubscribe", NewHeadsNotification.class ); } From 84a70cea22652d5bc02b74f528828b95cf572951 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Wed, 28 Mar 2018 18:59:16 +0100 Subject: [PATCH 09/15] Make WebSocketService thread-safe --- .../protocol/websocket/WebSocketService.java | 80 ++++++----- .../websocket/WebSocketSubscription.java | 8 +- .../protocol/core/WebSocketEventTest.java | 51 +++++++ .../websocket/WebSocketServiceTest.java | 127 ++++++++++-------- 4 files changed, 170 insertions(+), 96 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 2c02d873b6..ca6ea4405f 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -7,9 +7,9 @@ import java.net.URISyntaxException; import java.util.Arrays; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -23,7 +23,7 @@ import org.slf4j.LoggerFactory; import rx.Observable; -import rx.subjects.PublishSubject; +import rx.subjects.BehaviorSubject; import org.web3j.protocol.ObjectMapperFactory; import org.web3j.protocol.Web3jService; @@ -59,9 +59,9 @@ public class WebSocketService implements Web3jService { private final ObjectMapper objectMapper; // Map of a sent request id to objects necessary to process this id - private Map> requestForId = new HashMap<>(); + private Map> requestForId = new ConcurrentHashMap<>(); // Map of a subscription id to objects necessary to process incoming events - private Map> subscriptionForId = new HashMap<>(); + private Map> subscriptionForId = new ConcurrentHashMap<>(); public WebSocketService(String serverUrl, boolean includeRawResponses) { this(new WebSocketClient(parseURI(serverUrl)), includeRawResponses); @@ -80,6 +80,11 @@ public WebSocketService(WebSocketClient webSocketClient, this.objectMapper = ObjectMapperFactory.getObjectMapper(includeRawResponses); } + /** + * Connect to a WebSocket server. + * + * @throws ConnectException thrown if failed to connect to the server via WebSocket protocol + */ public void connect() throws ConnectException { try { boolean connected = webSocketClient.connectBlocking(); @@ -229,9 +234,9 @@ private JsonNode parseToTree(String replyStr) throws IOException { private WebSocketRequest getAndRemoveRequest(long id) throws IOException { if (!requestForId.containsKey(id)) { - throw new IOException(String.format( - "Received reply for unexpected request id: %d", - id)); + throw new IOException( + String.format("Received reply for unexpected request id: %d", + id)); } WebSocketRequest request = requestForId.get(id); requestForId.remove(id); @@ -245,9 +250,9 @@ private long getReplyId(JsonNode replyJson) throws IOException { } if (!idField.isIntegralNumber()) { - throw new IOException(String.format( - "'id' expected to be long, but it is: '%s'", - idField.asText())); + throw new IOException( + String.format("'id' expected to be long, but it is: '%s'", + idField.asText())); } return idField.longValue(); @@ -266,9 +271,16 @@ public > Observable subscribe( Request request, String unsubscribeMethod, Class responseType) { - PublishSubject subject = PublishSubject.create(); + // We can't use usual Observer since we can call "onError" + // before first client is subscribed and we need to + // preserve it + BehaviorSubject subject = BehaviorSubject.create(); - sendSubscribeRequest(request, subject, responseType); + // We need to subscribe synchronously, since if we return + // an Observable to a client before we got a reply + // a client can unsubscribe before we know a subscription + // id and this can cause a race condition + subscribeToEventsStream(request, subject, responseType); return subject .doOnUnsubscribe(() -> closeSubscription(subject, unsubscribeMethod)); @@ -276,37 +288,36 @@ public > Observable subscribe( } private > void closeSubscription( - PublishSubject subject, String unsubscribeMethod) { + BehaviorSubject subject, String unsubscribeMethod) { subject.onCompleted(); String subscriptionId = getSubscriptionId(subject); - subscriptionForId.remove(subscriptionId); if (subscriptionId != null) { + subscriptionForId.remove(subscriptionId); unsubscribeFromEventsStream(subscriptionId, unsubscribeMethod); + } else { + log.warn("Trying to unsubscribe from a non-existing subscription. Race condition?"); } } - private > void sendSubscribeRequest( + private > void subscribeToEventsStream( Request request, - PublishSubject subject, Class responseType) { - sendAsync(request, EthSubscribe.class) - .thenAccept(ethSubscribe -> { - log.info( - "Subscribed to RPC events with id {}", - ethSubscribe.getSubscriptionId()); - subscriptionForId.put( - ethSubscribe.getSubscriptionId(), - new WebSocketSubscription<>(subject, responseType)); - }) - .exceptionally(throwable -> { - log.error( - "Failed to subscribe to RPC events with request id {}", - request.getId()); - subject.onError(throwable.getCause()); - return null; - }); + BehaviorSubject subject, Class responseType) { + + try { + EthSubscribe ethSubscribe = send(request, EthSubscribe.class); + log.info("Subscribed to RPC events with id {}", + ethSubscribe.getSubscriptionId()); + subscriptionForId.put( + ethSubscribe.getSubscriptionId(), + new WebSocketSubscription<>(subject, responseType)); + } catch (IOException e) { + log.error("Failed to subscribe to RPC events with request id {}", + request.getId()); + subject.onError(e); + } } - private > String getSubscriptionId(PublishSubject subject) { + private > String getSubscriptionId(BehaviorSubject subject) { return subscriptionForId.entrySet().stream() .filter(entry -> entry.getValue().getSubject() == subject) .map(entry -> entry.getKey()) @@ -317,8 +328,7 @@ private > String getSubscriptionId(PublishSubject s private void unsubscribeFromEventsStream(String subscriptionId, String unsubscribeMethod) { sendAsync(unsubscribeRequest(subscriptionId, unsubscribeMethod), EthUnsubscribe.class) .thenAccept(ethUnsubscribe -> { - log.debug( - "Successfully unsubscribed from subscription with id {}", + log.debug("Successfully unsubscribed from subscription with id {}", subscriptionId); }) .exceptionally(throwable -> { diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java index b8bd38413d..1494bbf521 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java @@ -1,17 +1,17 @@ package org.web3j.protocol.websocket; -import rx.subjects.PublishSubject; +import rx.subjects.BehaviorSubject; public class WebSocketSubscription { - private PublishSubject subject; + private BehaviorSubject subject; private Class responseType; - public WebSocketSubscription(PublishSubject subject, Class responseType) { + public WebSocketSubscription(BehaviorSubject subject, Class responseType) { this.subject = subject; this.responseType = responseType; } - public PublishSubject getSubject() { + public BehaviorSubject getSubject() { return subject; } diff --git a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java index 56c589a2d9..7507c84feb 100644 --- a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java +++ b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java @@ -1,17 +1,28 @@ package org.web3j.protocol.core; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.Before; import org.junit.Test; +import org.web3j.protocol.ObjectMapperFactory; import org.web3j.protocol.Web3j; import org.web3j.protocol.websocket.WebSocketClient; +import org.web3j.protocol.websocket.WebSocketListener; import org.web3j.protocol.websocket.WebSocketService; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.matches; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class WebSocketEventTest { @@ -23,6 +34,30 @@ public class WebSocketEventTest { private Web3j web3j = Web3j.build(webSocketService); + private final ObjectMapper objectMapper = ObjectMapperFactory.getObjectMapper(); + + private WebSocketListener listener; + + @Before + public void before() throws Exception { + when(webSocketClient.connectBlocking()).thenReturn(true); + + doAnswer(invocation -> { + listener = invocation.getArgumentAt(0, WebSocketListener.class); + return null; + }).when(webSocketClient).setListener(any()); + + doAnswer(invocation -> { + String message = invocation.getArgumentAt(0, String.class); + int requestId = getRequestId(message); + + sendSubscriptionConfirmation(requestId); + return null; + }).when(webSocketClient).send(anyString()); + + webSocketService.connect(); + } + @Test public void testNewHeadsNotifications() { web3j.newHeadsNotifications(); @@ -70,4 +105,20 @@ public void testSyncingStatusNotifications() { "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + "\"params\":\\[\"syncing\"],\"id\":[0-9]{1,}}")); } + + private int getRequestId(String message) throws IOException { + JsonNode messageJson = objectMapper.readTree(message); + return messageJson.get("id").asInt(); + } + + private void sendSubscriptionConfirmation(int requestId) throws IOException { + listener.onMessage( + String.format( + "{" + + "\"jsonrpc\":\"2.0\"," + + "\"id\":%d," + + "\"result\":\"0xcd0c3e8af590364c09d0fa6a1210faf5\"" + + "}", + requestId)); + } } diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 6255004343..473a92c0b6 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.net.ConnectException; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.concurrent.CompletableFuture; @@ -23,7 +22,6 @@ import rx.Subscriber; import rx.Subscription; -import org.web3j.protocol.core.JsonRpc2_0Web3j; import org.web3j.protocol.core.Request; import org.web3j.protocol.core.Response; import org.web3j.protocol.core.methods.response.EthSubscribe; @@ -41,11 +39,12 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public class WebSocketServiceTest { + private static final int REQUEST_ID = 1; + private WebSocketClient webSocketClient = mock(WebSocketClient.class); private ScheduledExecutorService executorService = mock(ScheduledExecutorService.class); @@ -213,9 +212,9 @@ public void testSyncRequest() throws Exception { return null; }).when(webSocketClient).send(anyString()); - Executors.newSingleThreadExecutor().execute(() -> { + runAsync(() -> { try { - requestSent.await(); + requestSent.await(2, TimeUnit.SECONDS); sendGethVersionReply(); } catch (Exception e) { e.printStackTrace(); @@ -248,32 +247,37 @@ public void testPropagateSubscriptionEvent() throws Exception { CountDownLatch completedCalled = new CountDownLatch(1); AtomicReference actualNotificationRef = new AtomicReference<>(); - final Subscription subscription = subscribeToEvents() - .subscribe(new Subscriber() { - @Override - public void onCompleted() { - completedCalled.countDown(); - } - - @Override - public void onError(Throwable e) { - - } - - @Override - public void onNext(NewHeadsNotification newHeadsNotification) { - actualNotificationRef.set(newHeadsNotification); - eventReceived.countDown(); - } - }); + runAsync(() -> { + final Subscription subscription = subscribeToEvents() + .subscribe(new Subscriber() { + @Override + public void onCompleted() { + completedCalled.countDown(); + } + + @Override + public void onError(Throwable e) { + + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + actualNotificationRef.set(newHeadsNotification); + eventReceived.countDown(); + } + }); + try { + eventReceived.await(2, TimeUnit.SECONDS); + subscription.unsubscribe(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }); sendSubscriptionConfirmation(); sendWebSocketEvent(); - assertTrue(eventReceived.await(2, TimeUnit.SECONDS)); - subscription.unsubscribe(); - - assertTrue(completedCalled.await(2, TimeUnit.SECONDS)); + assertTrue(completedCalled.await(6, TimeUnit.SECONDS)); assertEquals( "0xd9263f42a87", actualNotificationRef.get().getParams().getResult().getDifficulty()); @@ -281,57 +285,58 @@ public void onNext(NewHeadsNotification newHeadsNotification) { @Test public void testSendUnsubscribeRequest() throws Exception { - Observable obserable = subscribeToEvents(); + CountDownLatch unsubscribed = new CountDownLatch(1); + + runAsync(() -> { + Observable observable = subscribeToEvents(); + observable.subscribe().unsubscribe(); + unsubscribed.countDown(); + + }); sendSubscriptionConfirmation(); sendWebSocketEvent(); - obserable.subscribe().unsubscribe(); - + assertTrue(unsubscribed.await(2, TimeUnit.SECONDS)); verifyUnsubscribed(); } - @Test - public void testDoSendUnsubscribeRequestIfUnsubscribedBeforeConfirmation() throws Exception { - Observable obserable = subscribeToEvents(); - obserable.subscribe().unsubscribe(); - - verifyStartedSubscriptionHadnshake(); - verifyNoMoreInteractions(webSocketClient); - } - @Test public void testStopWaitingForSubscriptionReplyAfterTimeout() throws Exception { - Observable obserable = subscribeToEvents(); CountDownLatch errorReceived = new CountDownLatch(1); AtomicReference actualThrowable = new AtomicReference<>(); - obserable.subscribe(new Observer() { - @Override - public void onCompleted() { - } + runAsync(() -> { + subscribeToEvents().subscribe(new Observer() { + @Override + public void onCompleted() { + } - @Override - public void onError(Throwable e) { - actualThrowable.set(e); - errorReceived.countDown(); - } + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } - @Override - public void onNext(NewHeadsNotification newHeadsNotification) { + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { - } + } + }); }); + waitForRequestSent(); Exception e = new IOException("timeout"); service.closeRequest(1, e); assertTrue(errorReceived.await(2, TimeUnit.SECONDS)); - assertEquals( - actualThrowable.get(), - e); + assertEquals(e, actualThrowable.get()); + } + + private void runAsync(Runnable runnable) { + Executors.newSingleThreadExecutor().execute(runnable); } - private Observable subscribeToEvents() throws IOException { + private Observable subscribeToEvents() { subscribeRequest = new Request<>( "eth_subscribe", Arrays.asList("newHeads", Collections.emptyMap()), @@ -380,7 +385,9 @@ private void verifyUnsubscribed() { + "\"params\":[\"0xcd0c3e8af590364c09d0fa6a1210faf5\"]")); } - private void sendSubscriptionConfirmation() throws IOException { + private void sendSubscriptionConfirmation() throws Exception { + waitForRequestSent(); + service.onMessage( "{" + "\"jsonrpc\":\"2.0\"," @@ -389,6 +396,12 @@ private void sendSubscriptionConfirmation() throws IOException { + "}"); } + private void waitForRequestSent() throws InterruptedException { + while (!service.isWaitingForReply(REQUEST_ID)) { + Thread.sleep(50); + } + } + private void sendWebSocketEvent() throws IOException { service.onMessage( "{" From 959f62b68d752862a45d9f0e7cd9e2a1fd4b2dca Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Wed, 28 Mar 2018 22:21:18 +0100 Subject: [PATCH 10/15] Close subscription Observable if subscription request failed --- .../protocol/websocket/WebSocketService.java | 42 +++++++++++++++---- .../websocket/WebSocketServiceTest.java | 39 +++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index ca6ea4405f..2cb31971b4 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -58,7 +58,7 @@ public class WebSocketService implements Web3jService { // Object mapper to map incoming JSON objects private final ObjectMapper objectMapper; - // Map of a sent request id to objects necessary to process this id + // Map of a sent request id to objects necessary to process this request private Map> requestForId = new ConcurrentHashMap<>(); // Map of a subscription id to objects necessary to process incoming events private Map> subscriptionForId = new ConcurrentHashMap<>(); @@ -304,12 +304,7 @@ private > void subscribeToEventsStream( BehaviorSubject subject, Class responseType) { try { - EthSubscribe ethSubscribe = send(request, EthSubscribe.class); - log.info("Subscribed to RPC events with id {}", - ethSubscribe.getSubscriptionId()); - subscriptionForId.put( - ethSubscribe.getSubscriptionId(), - new WebSocketSubscription<>(subject, responseType)); + processSubscriptionResponse(request, subject, responseType); } catch (IOException e) { log.error("Failed to subscribe to RPC events with request id {}", request.getId()); @@ -317,14 +312,45 @@ private > void subscribeToEventsStream( } } + private > void processSubscriptionResponse( + Request request, BehaviorSubject subject, Class responseType) throws IOException { + EthSubscribe subscriptionReply = send(request, EthSubscribe.class); + if (subscriptionReply.hasError()) { + reportSubscriptionError(subject, subscriptionReply); + } else { + establishSubscription(subject, responseType, subscriptionReply); + } + } + + private > void establishSubscription( + BehaviorSubject subject, Class responseType, EthSubscribe subscriptionReply) { + log.info("Subscribed to RPC events with id {}", + subscriptionReply.getSubscriptionId()); + subscriptionForId.put( + subscriptionReply.getSubscriptionId(), + new WebSocketSubscription<>(subject, responseType)); + } + private > String getSubscriptionId(BehaviorSubject subject) { return subscriptionForId.entrySet().stream() .filter(entry -> entry.getValue().getSubject() == subject) - .map(entry -> entry.getKey()) + .map(Map.Entry::getKey) .findFirst() .orElse(null); } + private > void reportSubscriptionError( + BehaviorSubject subject, EthSubscribe subscriptionReply) { + Response.Error error = subscriptionReply.getError(); + log.error("Subscription request returned error: {}", error.getMessage()); + subject.onError( + new IOException(String.format( + "Subscription request failed with error: %s", + error.getMessage() + )) + ); + } + private void unsubscribeFromEventsStream(String subscriptionId, String unsubscribeMethod) { sendAsync(unsubscribeRequest(subscriptionId, unsubscribeMethod), EthUnsubscribe.class) .thenAccept(ethUnsubscribe -> { diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 473a92c0b6..8548a33998 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -332,6 +332,45 @@ public void onNext(NewHeadsNotification newHeadsNotification) { assertEquals(e, actualThrowable.get()); } + @Test + public void testIfCloseObserverIfSubscriptionRequestFailed() throws Exception { + CountDownLatch errorReceived = new CountDownLatch(1); + AtomicReference actualThrowable = new AtomicReference<>(); + + runAsync(() -> { + subscribeToEvents().subscribe(new Observer() { + @Override + public void onCompleted() { + } + + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + + } + }); + }); + + waitForRequestSent(); + sendErrorReply(); + + assertTrue(errorReceived.await(2, TimeUnit.SECONDS)); + + Throwable throwable = actualThrowable.get(); + assertEquals( + IOException.class, + throwable.getClass() + ); + assertEquals( + "Subscription request failed with error: Error message", + throwable.getMessage()); + } + private void runAsync(Runnable runnable) { Executors.newSingleThreadExecutor().execute(runnable); } From 3719abffb73846496fd6faae35c02d0bcf0934e3 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Wed, 28 Mar 2018 23:14:32 +0100 Subject: [PATCH 11/15] Add more unit tests --- .../core/JsonRpc2_0WebSocketClientJTest.java | 22 ++++++++++++++----- .../web3j/protocol/http/HttpServiceTest.java | 21 ++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java index 5da2c3903c..7050e2bf0b 100644 --- a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java +++ b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java @@ -1,5 +1,6 @@ package org.web3j.protocol.core; +import java.io.IOException; import java.util.concurrent.ScheduledExecutorService; import org.junit.Test; @@ -7,22 +8,33 @@ import org.web3j.protocol.Web3j; import org.web3j.protocol.Web3jService; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class JsonRpc2_0WebSocketClientJTest { - @Test - public void testStopExecutorOnShutdown() throws Exception { - ScheduledExecutorService scheduledExecutorService = mock(ScheduledExecutorService.class); - Web3jService service = mock(Web3jService.class); + private ScheduledExecutorService scheduledExecutorService + = mock(ScheduledExecutorService.class); + private Web3jService service = mock(Web3jService.class); - Web3j web3j = Web3j.build(service, 10, scheduledExecutorService); + private Web3j web3j = Web3j.build(service, 10, scheduledExecutorService); + @Test + public void testStopExecutorOnShutdown() throws Exception { web3j.shutdown(); verify(scheduledExecutorService).shutdown(); verify(service).close(); } + + @Test(expected = RuntimeException.class) + public void testThrowsRuntimeExceptionIfFailedToCloseService() throws Exception { + doThrow(new IOException("Failed to close")) + .when(service).close(); + + web3j.shutdown(); + } } \ No newline at end of file diff --git a/core/src/test/java/org/web3j/protocol/http/HttpServiceTest.java b/core/src/test/java/org/web3j/protocol/http/HttpServiceTest.java index 345b07f275..ac7d2ab7c0 100644 --- a/core/src/test/java/org/web3j/protocol/http/HttpServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/http/HttpServiceTest.java @@ -1,9 +1,15 @@ package org.web3j.protocol.http; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import org.junit.Test; +import org.web3j.protocol.core.Request; +import org.web3j.protocol.core.methods.response.EthSubscribe; +import org.web3j.protocol.websocket.events.NewHeadsNotification; + import static org.junit.Assert.assertTrue; public class HttpServiceTest { @@ -35,5 +41,20 @@ public void testAddHeaders() { assertTrue(httpService.getHeaders().get(headerName1).equals(headerValue1)); assertTrue(httpService.getHeaders().get(headerName2).equals(headerValue2)); } + + @Test(expected = UnsupportedOperationException.class) + public void subscriptionNotSupported() { + Request subscribeRequest = new Request<>( + "eth_subscribe", + Arrays.asList("newHeads", Collections.emptyMap()), + httpService, + EthSubscribe.class); + + httpService.subscribe( + subscribeRequest, + "eth_unsubscribe", + NewHeadsNotification.class + ); + } } From adb6299d132ea40fb45901b85ddc805fcaaa9d95 Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Wed, 28 Mar 2018 23:26:28 +0100 Subject: [PATCH 12/15] Fix unit test race condition --- .../org/web3j/protocol/websocket/WebSocketServiceTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 8548a33998..841d2a70f6 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -274,7 +274,12 @@ public void onNext(NewHeadsNotification newHeadsNotification) { } }); + sendSubscriptionConfirmation(); + // A subscriber can miss an event if it comes at the same time as + // a subscription confirmation. Waiting for a bit to ensure + // delivery + Thread.sleep(100); sendWebSocketEvent(); assertTrue(completedCalled.await(6, TimeUnit.SECONDS)); From f1648592cc1a5ed8af462366b60134905241168c Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Thu, 29 Mar 2018 08:57:27 +0100 Subject: [PATCH 13/15] Add more methods to WebSocketListener --- .../protocol/websocket/WebSocketClient.java | 2 + .../protocol/websocket/WebSocketListener.java | 5 +++ .../protocol/websocket/WebSocketService.java | 39 +++++++++++++--- .../web3j/protocol/ipc/IpcServiceTest.java | 7 +++ .../websocket/WebSocketClientTest.java | 45 +++++++++++++++++++ .../websocket/WebSocketServiceTest.java | 17 +++---- 6 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 core/src/test/java/org/web3j/protocol/websocket/WebSocketClientTest.java diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java index 9dde4f765d..cdbf15fd25 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -40,11 +40,13 @@ public void onMessage(String s) { public void onClose(int code, String reason, boolean remote) { log.info("Closed WebSocket connection to {}, because of reason: '{}'." + "Conection closed remotely: {}", uri, reason, remote); + listener.onClose(); } @Override public void onError(Exception e) { log.error(String.format("WebSocket connection to {} failed with error", uri), e); + listener.onError(e); } /** diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java index 5410d18cda..13bd48a980 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketListener.java @@ -9,8 +9,13 @@ public interface WebSocketListener { /** * Called when a new WebSocket message is delivered. + * * @param message new WebSocket message * @throws IOException thrown if an observer failed to process the message */ void onMessage(String message) throws IOException; + + void onError(Exception e); + + void onClose(); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 2cb31971b4..2c029ac67c 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -87,17 +87,41 @@ public WebSocketService(WebSocketClient webSocketClient, */ public void connect() throws ConnectException { try { - boolean connected = webSocketClient.connectBlocking(); - if (!connected) { - throw new ConnectException("Failed to connect to WebSocket"); - } - webSocketClient.setListener(this::onMessage); + connectToWebSocket(); + setWebSocketListener(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("Interrupted while connecting via WebSocket protocol"); } } + private void connectToWebSocket() throws InterruptedException, ConnectException { + boolean connected = webSocketClient.connectBlocking(); + if (!connected) { + throw new ConnectException("Failed to connect to WebSocket"); + } + } + + private void setWebSocketListener() { + webSocketClient.setListener(new WebSocketListener() { + @Override + public void onMessage(String message) throws IOException { + onWebSocketMessage(message); + } + + @Override + public void onError(Exception e) { + + } + + @Override + public void onClose() { + onWebSocketClose(); + } + }); + } + + @Override public T send(Request request, Class responseType) throws IOException { try { @@ -154,7 +178,7 @@ void closeRequest(long requestId, Exception e) { result.completeExceptionally(e); } - void onMessage(String messageStr) throws IOException { + void onWebSocketMessage(String messageStr) throws IOException { JsonNode replyJson = parseToTree(messageStr); if (isReply(replyJson)) { @@ -378,6 +402,9 @@ public void close() { executor.shutdown(); } + void onWebSocketClose() { + } + // Method visible for unit-tests boolean isWaitingForReply(long requestId) { return requestForId.containsKey(requestId); diff --git a/core/src/test/java/org/web3j/protocol/ipc/IpcServiceTest.java b/core/src/test/java/org/web3j/protocol/ipc/IpcServiceTest.java index 0579fc3c37..3def39fae7 100644 --- a/core/src/test/java/org/web3j/protocol/ipc/IpcServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/ipc/IpcServiceTest.java @@ -38,4 +38,11 @@ public void testSend() throws IOException { verify(ioFacade).write("{\"jsonrpc\":\"2.0\",\"method\":null,\"params\":null,\"id\":0}"); } + + @Test + public void testClose() throws IOException { + ipcService.close(); + + verify(ioFacade).close(); + } } diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketClientTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketClientTest.java new file mode 100644 index 0000000000..b17be8a9cb --- /dev/null +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketClientTest.java @@ -0,0 +1,45 @@ +package org.web3j.protocol.websocket; + +import java.io.IOException; +import java.net.URI; + +import org.junit.Before; +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class WebSocketClientTest { + + private WebSocketListener listener = mock(WebSocketListener.class); + + private WebSocketClient client; + + @Before + public void before() throws Exception { + client = new WebSocketClient(new URI("ws://localhost/")); + client.setListener(listener); + } + + @Test + public void testNotifyListenerOnMessage() throws Exception { + client.onMessage("message"); + + verify(listener).onMessage("message"); + } + + @Test + public void testNotifyListenerOnError() throws Exception { + IOException e = new IOException("123"); + client.onError(e); + + verify(listener).onError(e); + } + + @Test + public void testNotifyListenerOnClose() throws Exception { + client.onClose(1, "reason", true); + + verify(listener).onClose(); + } +} \ No newline at end of file diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 841d2a70f6..a8d4c0602e 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -130,7 +130,7 @@ public void testIgnoreInvalidReplies() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Failed to parse incoming WebSocket message"); service.sendAsync(request, Web3ClientVersion.class); - service.onMessage("{"); + service.onWebSocketMessage("{"); } @Test @@ -138,7 +138,7 @@ public void testThrowExceptionIfIdHasInvalidType() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("'id' expected to be long, but it is: 'true'"); service.sendAsync(request, Web3ClientVersion.class); - service.onMessage("{\"id\":true}"); + service.onWebSocketMessage("{\"id\":true}"); } @Test @@ -146,7 +146,7 @@ public void testThrowExceptionIfIdIsMissing() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Unknown message type"); service.sendAsync(request, Web3ClientVersion.class); - service.onMessage("{}"); + service.onWebSocketMessage("{}"); } @Test @@ -154,7 +154,8 @@ public void testThrowExceptionIfUnexpectedIdIsReceived() throws Exception { thrown.expect(IOException.class); thrown.expectMessage("Received reply for unexpected request id: 12345"); service.sendAsync(request, Web3ClientVersion.class); - service.onMessage("{\"jsonrpc\":\"2.0\",\"id\":12345,\"result\":\"geth-version\"}"); + service.onWebSocketMessage( + "{\"jsonrpc\":\"2.0\",\"id\":12345,\"result\":\"geth-version\"}"); } @Test @@ -396,7 +397,7 @@ private Observable subscribeToEvents() { } private void sendErrorReply() throws IOException { - service.onMessage( + service.onWebSocketMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"id\":1," @@ -409,7 +410,7 @@ private void sendErrorReply() throws IOException { } private void sendGethVersionReply() throws IOException { - service.onMessage( + service.onWebSocketMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"id\":1," @@ -432,7 +433,7 @@ private void verifyUnsubscribed() { private void sendSubscriptionConfirmation() throws Exception { waitForRequestSent(); - service.onMessage( + service.onWebSocketMessage( "{" + "\"jsonrpc\":\"2.0\"," + "\"id\":1," @@ -447,7 +448,7 @@ private void waitForRequestSent() throws InterruptedException { } private void sendWebSocketEvent() throws IOException { - service.onMessage( + service.onWebSocketMessage( "{" + " \"jsonrpc\":\"2.0\"," + " \"method\":\"eth_subscription\"," From 05f5bc834d2e7b081eef1b7e6ec821570f257b7a Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Fri, 30 Mar 2018 09:14:10 +0100 Subject: [PATCH 14/15] Close requests and subscriptions when a WebSocket connection is closed --- .../protocol/websocket/WebSocketService.java | 138 +++++++++++------- .../websocket/WebSocketServiceTest.java | 119 +++++++++------ 2 files changed, 166 insertions(+), 91 deletions(-) diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index 2c029ac67c..f0399fbdc5 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -6,7 +6,7 @@ import java.net.URI; import java.net.URISyntaxException; -import java.util.Arrays; +import java.util.Collections; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -60,6 +60,10 @@ public class WebSocketService implements Web3jService { // Map of a sent request id to objects necessary to process this request private Map> requestForId = new ConcurrentHashMap<>(); + // Map of a sent subscription request id to objects necessary to process + // subscription events + private Map> subscriptionRequestForId + = new ConcurrentHashMap<>(); // Map of a subscription id to objects necessary to process incoming events private Map> subscriptionForId = new ConcurrentHashMap<>(); @@ -195,6 +199,11 @@ private void processRequestReply(String replyStr, JsonNode replyJson) throws IOE WebSocketRequest request = getAndRemoveRequest(replyId); try { Object reply = objectMapper.convertValue(replyJson, request.getResponseType()); + // Instead of sending a reply to a caller asynchronously we need to process it here + // to avoid race conditions we need to modify state of this class. + if (reply instanceof EthSubscribe) { + processSubscriptionResponse(replyId, (EthSubscribe) reply); + } sendReplyToListener(request, reply); } catch (IllegalArgumentException e) { @@ -202,6 +211,55 @@ private void processRequestReply(String replyStr, JsonNode replyJson) throws IOE } } + private void processSubscriptionResponse(long replyId, EthSubscribe reply) throws IOException { + WebSocketSubscription subscription = subscriptionRequestForId.get(replyId); + processSubscriptionResponse( + reply, + subscription.getSubject(), + subscription.getResponseType() + ); + } + + private > void processSubscriptionResponse( + EthSubscribe subscriptionReply, + BehaviorSubject subject, + Class responseType) throws IOException { + if (!subscriptionReply.hasError()) { + establishSubscription(subject, responseType, subscriptionReply); + } else { + reportSubscriptionError(subject, subscriptionReply); + } + } + + private > void establishSubscription( + BehaviorSubject subject, Class responseType, EthSubscribe subscriptionReply) { + log.info("Subscribed to RPC events with id {}", + subscriptionReply.getSubscriptionId()); + subscriptionForId.put( + subscriptionReply.getSubscriptionId(), + new WebSocketSubscription<>(subject, responseType)); + } + + private > String getSubscriptionId(BehaviorSubject subject) { + return subscriptionForId.entrySet().stream() + .filter(entry -> entry.getValue().getSubject() == subject) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + + private > void reportSubscriptionError( + BehaviorSubject subject, EthSubscribe subscriptionReply) { + Response.Error error = subscriptionReply.getError(); + log.error("Subscription request returned error: {}", error.getMessage()); + subject.onError( + new IOException(String.format( + "Subscription request failed with error: %s", + error.getMessage() + )) + ); + } + private void sendReplyToListener(WebSocketRequest request, Object reply) { request.getCompletableFuture().complete(reply); } @@ -311,24 +369,15 @@ public > Observable subscribe( } - private > void closeSubscription( - BehaviorSubject subject, String unsubscribeMethod) { - subject.onCompleted(); - String subscriptionId = getSubscriptionId(subject); - if (subscriptionId != null) { - subscriptionForId.remove(subscriptionId); - unsubscribeFromEventsStream(subscriptionId, unsubscribeMethod); - } else { - log.warn("Trying to unsubscribe from a non-existing subscription. Race condition?"); - } - } - private > void subscribeToEventsStream( Request request, BehaviorSubject subject, Class responseType) { + subscriptionRequestForId.put( + request.getId(), + new WebSocketSubscription<>(subject, responseType)); try { - processSubscriptionResponse(request, subject, responseType); + send(request, EthSubscribe.class); } catch (IOException e) { log.error("Failed to subscribe to RPC events with request id {}", request.getId()); @@ -336,45 +385,18 @@ private > void subscribeToEventsStream( } } - private > void processSubscriptionResponse( - Request request, BehaviorSubject subject, Class responseType) throws IOException { - EthSubscribe subscriptionReply = send(request, EthSubscribe.class); - if (subscriptionReply.hasError()) { - reportSubscriptionError(subject, subscriptionReply); + private > void closeSubscription( + BehaviorSubject subject, String unsubscribeMethod) { + subject.onCompleted(); + String subscriptionId = getSubscriptionId(subject); + if (subscriptionId != null) { + subscriptionForId.remove(subscriptionId); + unsubscribeFromEventsStream(subscriptionId, unsubscribeMethod); } else { - establishSubscription(subject, responseType, subscriptionReply); + log.warn("Trying to unsubscribe from a non-existing subscription. Race condition?"); } } - private > void establishSubscription( - BehaviorSubject subject, Class responseType, EthSubscribe subscriptionReply) { - log.info("Subscribed to RPC events with id {}", - subscriptionReply.getSubscriptionId()); - subscriptionForId.put( - subscriptionReply.getSubscriptionId(), - new WebSocketSubscription<>(subject, responseType)); - } - - private > String getSubscriptionId(BehaviorSubject subject) { - return subscriptionForId.entrySet().stream() - .filter(entry -> entry.getValue().getSubject() == subject) - .map(Map.Entry::getKey) - .findFirst() - .orElse(null); - } - - private > void reportSubscriptionError( - BehaviorSubject subject, EthSubscribe subscriptionReply) { - Response.Error error = subscriptionReply.getError(); - log.error("Subscription request returned error: {}", error.getMessage()); - subject.onError( - new IOException(String.format( - "Subscription request failed with error: %s", - error.getMessage() - )) - ); - } - private void unsubscribeFromEventsStream(String subscriptionId, String unsubscribeMethod) { sendAsync(unsubscribeRequest(subscriptionId, unsubscribeMethod), EthUnsubscribe.class) .thenAccept(ethUnsubscribe -> { @@ -391,7 +413,7 @@ private Request unsubscribeRequest( String subscriptionId, String unsubscribeMethod) { return new Request<>( unsubscribeMethod, - Arrays.asList(subscriptionId), + Collections.singletonList(subscriptionId), this, EthUnsubscribe.class); } @@ -403,6 +425,22 @@ public void close() { } void onWebSocketClose() { + closeOutstandingRequests(); + closeOutstandingSubscriptions(); + } + + private void closeOutstandingRequests() { + requestForId.values().forEach(request -> { + request.getCompletableFuture() + .completeExceptionally(new IOException("Connection was closed")); + }); + } + + private void closeOutstandingSubscriptions() { + subscriptionForId.values().forEach(subscription -> { + subscription.getSubject() + .onError(new IOException("Connection was closed")); + }); } // Method visible for unit-tests diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index a8d4c0602e..5b387b2606 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -184,6 +184,18 @@ public void testReceiveError() throws Exception { version.getError()); } + @Test + public void testCloseRequestWhenConnectionIsClosed() throws Exception { + thrown.expect(ExecutionException.class); + CompletableFuture reply = service.sendAsync( + request, + Web3ClientVersion.class); + service.onWebSocketClose(); + + assertTrue(reply.isDone()); + reply.get(); + } + @Test(expected = ExecutionException.class) public void testCancelRequestAfterTimeout() throws Exception { when(executorService.schedule( @@ -208,11 +220,13 @@ public void testCancelRequestAfterTimeout() throws Exception { public void testSyncRequest() throws Exception { CountDownLatch requestSent = new CountDownLatch(1); + // Wait for a request to be sent doAnswer(invocation -> { requestSent.countDown(); return null; }).when(webSocketClient).send(anyString()); + // Send reply asynchronously runAsync(() -> { try { requestSent.await(2, TimeUnit.SECONDS); @@ -277,10 +291,6 @@ public void onNext(NewHeadsNotification newHeadsNotification) { sendSubscriptionConfirmation(); - // A subscriber can miss an event if it comes at the same time as - // a subscription confirmation. Waiting for a bit to ensure - // delivery - Thread.sleep(100); sendWebSocketEvent(); assertTrue(completedCalled.await(6, TimeUnit.SECONDS)); @@ -311,24 +321,22 @@ public void testStopWaitingForSubscriptionReplyAfterTimeout() throws Exception { CountDownLatch errorReceived = new CountDownLatch(1); AtomicReference actualThrowable = new AtomicReference<>(); - runAsync(() -> { - subscribeToEvents().subscribe(new Observer() { - @Override - public void onCompleted() { - } - - @Override - public void onError(Throwable e) { - actualThrowable.set(e); - errorReceived.countDown(); - } - - @Override - public void onNext(NewHeadsNotification newHeadsNotification) { - - } - }); - }); + runAsync(() -> subscribeToEvents().subscribe(new Observer() { + @Override + public void onCompleted() { + } + + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + + } + })); waitForRequestSent(); Exception e = new IOException("timeout"); @@ -338,29 +346,58 @@ public void onNext(NewHeadsNotification newHeadsNotification) { assertEquals(e, actualThrowable.get()); } + @Test + public void testOnErrorCalledIfConnectionClosed() throws Exception { + CountDownLatch errorReceived = new CountDownLatch(1); + AtomicReference actualThrowable = new AtomicReference<>(); + + runAsync(() -> subscribeToEvents().subscribe(new Observer() { + @Override + public void onCompleted() { + } + + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + + } + })); + + waitForRequestSent(); + sendSubscriptionConfirmation(); + service.onWebSocketClose(); + + assertTrue(errorReceived.await(2, TimeUnit.SECONDS)); + assertEquals(IOException.class, actualThrowable.get().getClass()); + assertEquals("Connection was closed", actualThrowable.get().getMessage()); + } + @Test public void testIfCloseObserverIfSubscriptionRequestFailed() throws Exception { CountDownLatch errorReceived = new CountDownLatch(1); AtomicReference actualThrowable = new AtomicReference<>(); - runAsync(() -> { - subscribeToEvents().subscribe(new Observer() { - @Override - public void onCompleted() { - } - - @Override - public void onError(Throwable e) { - actualThrowable.set(e); - errorReceived.countDown(); - } - - @Override - public void onNext(NewHeadsNotification newHeadsNotification) { - - } - }); - }); + runAsync(() -> subscribeToEvents().subscribe(new Observer() { + @Override + public void onCompleted() { + } + + @Override + public void onError(Throwable e) { + actualThrowable.set(e); + errorReceived.countDown(); + } + + @Override + public void onNext(NewHeadsNotification newHeadsNotification) { + + } + })); waitForRequestSent(); sendErrorReply(); @@ -461,4 +498,4 @@ private void sendWebSocketEvent() throws IOException { + " }" + "}"); } -} \ No newline at end of file +} From 4458d1078d5b316c6c6eb70be9861b7efd5c392b Mon Sep 17 00:00:00 2001 From: Ivan Mushketyk Date: Tue, 8 May 2018 08:08:49 +0100 Subject: [PATCH 15/15] Minor websocket fixes --- core/build.gradle | 2 +- .../web3j/protocol/core/JsonRpc2_0Web3j.java | 28 ------ .../org/web3j/protocol/ipc/IpcService.java | 23 +++-- .../java/org/web3j/protocol/rx/Web3jRx.java | 15 --- .../protocol/websocket/WebSocketClient.java | 1 - .../protocol/websocket/WebSocketRequest.java | 10 +- .../protocol/websocket/WebSocketService.java | 10 +- .../websocket/WebSocketSubscription.java | 11 +++ ...entJTest.java => JsonRpc2_0Web3jTest.java} | 4 +- .../protocol/core/WebSocketEventTest.java | 18 ---- .../websocket/WebSocketServiceTest.java | 2 +- .../java/org/web3j/protocol/geth/Geth.java | 21 ++++- .../web3j/protocol/geth/JsonRpc2_0Geth.java | 29 ++++++ .../protocol/geth/JsonRpc2_0GethTest.java | 92 +++++++++++++++++++ 14 files changed, 180 insertions(+), 86 deletions(-) rename core/src/test/java/org/web3j/protocol/core/{JsonRpc2_0WebSocketClientJTest.java => JsonRpc2_0Web3jTest.java} (89%) create mode 100644 geth/src/test/java/org/web3j/protocol/geth/JsonRpc2_0GethTest.java diff --git a/core/build.gradle b/core/build.gradle index cf604898c6..2e0ca92fff 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -13,7 +13,7 @@ dependencies { "org.java-websocket:Java-WebSocket:$javaWebSocketVersion" testCompile project(path: ':crypto', configuration: 'testArtifacts'), "nl.jqno.equalsverifier:equalsverifier:$equalsverifierVersion", - group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' + "ch.qos.logback:logback-classic:$logbackVersion" } task createProperties(dependsOn: processResources) doLast { diff --git a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java index 434e8d5765..0093e9a61a 100644 --- a/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java +++ b/core/src/main/java/org/web3j/protocol/core/JsonRpc2_0Web3j.java @@ -69,8 +69,6 @@ import org.web3j.protocol.rx.JsonRpc2_0Rx; import org.web3j.protocol.websocket.events.LogNotification; import org.web3j.protocol.websocket.events.NewHeadsNotification; -import org.web3j.protocol.websocket.events.PendingTransactionNotification; -import org.web3j.protocol.websocket.events.SyncingNotfication; import org.web3j.utils.Async; import org.web3j.utils.Numeric; @@ -736,32 +734,6 @@ private Map createLogsParams(List addresses, List newPendingTransactionsNotifications() { - return web3jService.subscribe( - new Request<>( - "eth_subscribe", - Arrays.asList("newPendingTransactions"), - web3jService, - EthSubscribe.class), - "eth_unsubscribe", - PendingTransactionNotification.class - ); - } - - @Override - public Observable syncingStatusNotifications() { - return web3jService.subscribe( - new Request<>( - "eth_subscribe", - Arrays.asList("syncing"), - web3jService, - EthSubscribe.class), - "eth_unsubscribe", - SyncingNotfication.class - ); - } - @Override public Observable ethBlockHashObservable() { return web3jRx.ethBlockHashObservable(blockTime); diff --git a/core/src/main/java/org/web3j/protocol/ipc/IpcService.java b/core/src/main/java/org/web3j/protocol/ipc/IpcService.java index eeefc16176..264e886ace 100644 --- a/core/src/main/java/org/web3j/protocol/ipc/IpcService.java +++ b/core/src/main/java/org/web3j/protocol/ipc/IpcService.java @@ -43,12 +43,7 @@ protected IOFacade getIO() { @Override protected InputStream performIO(String payload) throws IOException { - IOFacade io; - if (ioFacade != null) { - io = ioFacade; - } else { - io = getIO(); - } + IOFacade io = getIoFacade(); io.write(payload); log.debug(">> " + payload); @@ -64,10 +59,22 @@ protected InputStream performIO(String payload) throws IOException { return new ByteArrayInputStream(result.getBytes("UTF-8")); } + private IOFacade getIoFacade() { + IOFacade io; + if (ioFacade != null) { + io = ioFacade; + } else { + io = getIO(); + } + return io; + } + @Override public void close() throws IOException { - if (ioFacade != null) { - ioFacade.close(); + IOFacade io = getIoFacade(); + + if (io != null) { + io.close(); } } } diff --git a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java index 40b16749c4..a6e1b78a8d 100644 --- a/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java +++ b/core/src/main/java/org/web3j/protocol/rx/Web3jRx.java @@ -192,19 +192,4 @@ Observable catchUpToLatestAndSubscribeToNewTransactionsObservable( * @return Observable that emits logs included in new blocks */ Observable logsNotifications(List addresses, List topics); - - /** - * Creates an observable that emits a notification when a new transaction is added - * to the pending state and is signed with a key that is available in the node. - * - * @return Observable that emits a notification when a new transaction is added - * to the pending state - */ - Observable newPendingTransactionsNotifications(); - - /** - * Creates an observable that emits a notification when a node starts or stops syncing. - * @return Observalbe that emits changes to syncing status - */ - Observable syncingStatusNotifications(); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java index cdbf15fd25..c73f809923 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketClient.java @@ -30,7 +30,6 @@ public void onMessage(String s) { try { log.debug("Received message {} from server {}", s, uri); listener.onMessage(s); - log.debug("Processed message {} from server {}", s, uri); } catch (Exception e) { log.error("Failed to process message '{}' from server {}", s, uri); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java index f86fef060d..05b978da5a 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketRequest.java @@ -8,16 +8,16 @@ * @param type of a data item that should be returned by the sent request */ class WebSocketRequest { - private CompletableFuture completableFuture; + private CompletableFuture onReply; private Class responseType; - public WebSocketRequest(CompletableFuture completableFuture, Class responseType) { - this.completableFuture = completableFuture; + public WebSocketRequest(CompletableFuture onReply, Class responseType) { + this.onReply = onReply; this.responseType = responseType; } - public CompletableFuture getCompletableFuture() { - return completableFuture; + public CompletableFuture getOnReply() { + return onReply; } public Class getResponseType() { diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java index f0399fbdc5..ad5cc74657 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketService.java @@ -115,7 +115,7 @@ public void onMessage(String message) throws IOException { @Override public void onError(Exception e) { - + log.error("Received error from a WebSocket connection", e); } @Override @@ -177,7 +177,7 @@ private void setRequestTimeout(long requestId) { } void closeRequest(long requestId, Exception e) { - CompletableFuture result = requestForId.get(requestId).getCompletableFuture(); + CompletableFuture result = requestForId.get(requestId).getOnReply(); requestForId.remove(requestId); result.completeExceptionally(e); } @@ -261,14 +261,14 @@ private > void reportSubscriptionError( } private void sendReplyToListener(WebSocketRequest request, Object reply) { - request.getCompletableFuture().complete(reply); + request.getOnReply().complete(reply); } private void sendExceptionToListener( String replyStr, WebSocketRequest request, IllegalArgumentException e) { - request.getCompletableFuture().completeExceptionally( + request.getOnReply().completeExceptionally( new IOException( String.format( "Failed to parse '%s' as type %s", @@ -431,7 +431,7 @@ void onWebSocketClose() { private void closeOutstandingRequests() { requestForId.values().forEach(request -> { - request.getCompletableFuture() + request.getOnReply() .completeExceptionally(new IOException("Connection was closed")); }); } diff --git a/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java index 1494bbf521..0063832c64 100644 --- a/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java +++ b/core/src/main/java/org/web3j/protocol/websocket/WebSocketSubscription.java @@ -2,10 +2,21 @@ import rx.subjects.BehaviorSubject; +/** + * Objects necessary to process a new item received via a WebSocket subscription. + * + * @param type of a data item that should be returned by a WebSocket subscription. + */ public class WebSocketSubscription { private BehaviorSubject subject; private Class responseType; + /** + * Creates WebSocketSubscription. + * + * @param subject used to send new data items to listeners + * @param responseType type of a data item returned by a WebSocket subscription + */ public WebSocketSubscription(BehaviorSubject subject, Class responseType) { this.subject = subject; this.responseType = responseType; diff --git a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java similarity index 89% rename from core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java rename to core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java index 7050e2bf0b..605a46d99e 100644 --- a/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0WebSocketClientJTest.java +++ b/core/src/test/java/org/web3j/protocol/core/JsonRpc2_0Web3jTest.java @@ -10,11 +10,9 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -public class JsonRpc2_0WebSocketClientJTest { +public class JsonRpc2_0Web3jTest { private ScheduledExecutorService scheduledExecutorService = mock(ScheduledExecutorService.class); diff --git a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java index 7507c84feb..1c5f97a558 100644 --- a/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java +++ b/core/src/test/java/org/web3j/protocol/core/WebSocketEventTest.java @@ -88,24 +88,6 @@ public void testLogsNotificationsWithArguments() { + "\"topics\":\\[\"0x2\"]}],\"id\":[0-9]{1,}}")); } - @Test - public void testPendingTransactionsNotifications() { - web3j.newPendingTransactionsNotifications(); - - verify(webSocketClient).send(matches( - "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\",\"params\":" - + "\\[\"newPendingTransactions\"],\"id\":[0-9]{1,}}")); - } - - @Test - public void testSyncingStatusNotifications() { - web3j.syncingStatusNotifications(); - - verify(webSocketClient).send(matches( - "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," - + "\"params\":\\[\"syncing\"],\"id\":[0-9]{1,}}")); - } - private int getRequestId(String message) throws IOException { JsonNode messageJson = objectMapper.readTree(message); return messageJson.get("id").asInt(); diff --git a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java index 5b387b2606..7215d6c4f8 100644 --- a/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java +++ b/core/src/test/java/org/web3j/protocol/websocket/WebSocketServiceTest.java @@ -232,7 +232,7 @@ public void testSyncRequest() throws Exception { requestSent.await(2, TimeUnit.SECONDS); sendGethVersionReply(); } catch (Exception e) { - e.printStackTrace(); + throw new RuntimeException(e); } }); diff --git a/geth/src/main/java/org/web3j/protocol/geth/Geth.java b/geth/src/main/java/org/web3j/protocol/geth/Geth.java index e66400b3fc..3884371a06 100644 --- a/geth/src/main/java/org/web3j/protocol/geth/Geth.java +++ b/geth/src/main/java/org/web3j/protocol/geth/Geth.java @@ -1,5 +1,7 @@ package org.web3j.protocol.geth; +import rx.Observable; + import org.web3j.protocol.Web3jService; import org.web3j.protocol.admin.Admin; import org.web3j.protocol.admin.methods.response.BooleanResponse; @@ -8,6 +10,8 @@ import org.web3j.protocol.core.methods.response.MinerStartResponse; import org.web3j.protocol.geth.response.PersonalEcRecover; import org.web3j.protocol.geth.response.PersonalImportRawKey; +import org.web3j.protocol.websocket.events.PendingTransactionNotification; +import org.web3j.protocol.websocket.events.SyncingNotfication; /** * JSON-RPC Request object building factory for Geth. @@ -18,7 +22,7 @@ static Geth build(Web3jService web3jService) { } Request personalImportRawKey(String keydata, String password); - + Request personalLockAccount(String accountId); Request personalSign(String message, String accountId, String password); @@ -29,4 +33,19 @@ static Geth build(Web3jService web3jService) { Request minerStop(); + /** + * Creates an observable that emits a notification when a new transaction is added + * to the pending state and is signed with a key that is available in the node. + * + * @return Observable that emits a notification when a new transaction is added + * to the pending state + */ + Observable newPendingTransactionsNotifications(); + + /** + * Creates an observable that emits a notification when a node starts or stops syncing. + * @return Observalbe that emits changes to syncing status + */ + Observable syncingStatusNotifications(); + } diff --git a/geth/src/main/java/org/web3j/protocol/geth/JsonRpc2_0Geth.java b/geth/src/main/java/org/web3j/protocol/geth/JsonRpc2_0Geth.java index 66bd970154..1a53902ce2 100644 --- a/geth/src/main/java/org/web3j/protocol/geth/JsonRpc2_0Geth.java +++ b/geth/src/main/java/org/web3j/protocol/geth/JsonRpc2_0Geth.java @@ -3,14 +3,19 @@ import java.util.Arrays; import java.util.Collections; +import rx.Observable; + import org.web3j.protocol.Web3jService; import org.web3j.protocol.admin.JsonRpc2_0Admin; import org.web3j.protocol.admin.methods.response.BooleanResponse; import org.web3j.protocol.admin.methods.response.PersonalSign; import org.web3j.protocol.core.Request; +import org.web3j.protocol.core.methods.response.EthSubscribe; import org.web3j.protocol.core.methods.response.MinerStartResponse; import org.web3j.protocol.geth.response.PersonalEcRecover; import org.web3j.protocol.geth.response.PersonalImportRawKey; +import org.web3j.protocol.websocket.events.PendingTransactionNotification; +import org.web3j.protocol.websocket.events.SyncingNotfication; /** * JSON-RPC 2.0 factory implementation for Geth. @@ -78,4 +83,28 @@ public Request minerStop() { BooleanResponse.class); } + public Observable newPendingTransactionsNotifications() { + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("newPendingTransactions"), + web3jService, + EthSubscribe.class), + "eth_unsubscribe", + PendingTransactionNotification.class + ); + } + + @Override + public Observable syncingStatusNotifications() { + return web3jService.subscribe( + new Request<>( + "eth_subscribe", + Arrays.asList("syncing"), + web3jService, + EthSubscribe.class), + "eth_unsubscribe", + SyncingNotfication.class + ); + } } diff --git a/geth/src/test/java/org/web3j/protocol/geth/JsonRpc2_0GethTest.java b/geth/src/test/java/org/web3j/protocol/geth/JsonRpc2_0GethTest.java new file mode 100644 index 0000000000..3dfd7b87ac --- /dev/null +++ b/geth/src/test/java/org/web3j/protocol/geth/JsonRpc2_0GethTest.java @@ -0,0 +1,92 @@ +package org.web3j.protocol.geth; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.Before; +import org.junit.Test; + +import org.web3j.protocol.ObjectMapperFactory; +import org.web3j.protocol.websocket.WebSocketClient; +import org.web3j.protocol.websocket.WebSocketListener; +import org.web3j.protocol.websocket.WebSocketService; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.matches; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JsonRpc2_0GethTest { + + private WebSocketClient webSocketClient = mock(WebSocketClient.class); + + private WebSocketService webSocketService = new WebSocketService( + webSocketClient, true + ); + + private Geth geth = Geth.build(webSocketService); + + private final ObjectMapper objectMapper = ObjectMapperFactory.getObjectMapper(); + + private WebSocketListener listener; + + @Before + public void before() throws Exception { + when(webSocketClient.connectBlocking()).thenReturn(true); + + doAnswer(invocation -> { + listener = invocation.getArgumentAt(0, WebSocketListener.class); + return null; + }).when(webSocketClient).setListener(any()); + + doAnswer(invocation -> { + String message = invocation.getArgumentAt(0, String.class); + int requestId = getRequestId(message); + + sendSubscriptionConfirmation(requestId); + return null; + }).when(webSocketClient).send(anyString()); + + webSocketService.connect(); + } + + + @Test + public void testPendingTransactionsNotifications() { + geth.newPendingTransactionsNotifications(); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\",\"params\":" + + "\\[\"newPendingTransactions\"],\"id\":[0-9]{1,}}")); + } + + @Test + public void testSyncingStatusNotifications() { + geth.syncingStatusNotifications(); + + verify(webSocketClient).send(matches( + "\\{\"jsonrpc\":\"2.0\",\"method\":\"eth_subscribe\"," + + "\"params\":\\[\"syncing\"],\"id\":[0-9]{1,}}")); + } + + private int getRequestId(String message) throws IOException { + JsonNode messageJson = objectMapper.readTree(message); + return messageJson.get("id").asInt(); + } + + private void sendSubscriptionConfirmation(int requestId) throws IOException { + listener.onMessage( + String.format( + "{" + + "\"jsonrpc\":\"2.0\"," + + "\"id\":%d," + + "\"result\":\"0xcd0c3e8af590364c09d0fa6a1210faf5\"" + + "}", + requestId)); + } +} \ No newline at end of file