From 153b054f717b1278319dc75effaf2238766ac6f1 Mon Sep 17 00:00:00 2001 From: Sashir Estela Date: Thu, 23 Jan 2025 18:57:39 +0000 Subject: [PATCH] Refactoring WebSocket entry point --- README.md | 101 ++++++++---- .../example/WebSocketExample.java | 29 ++-- .../cleverclient/CleverClient.java | 77 ++++++++- .../cleverclient/util/CommonUtil.java | 18 +++ .../websocket/WebSocketAdapter.java | 4 +- .../cleverclient/WebSocketTest.java | 150 ++++++++++++++++++ .../cleverclient/util/CommonUtilTest.java | 23 +++ 7 files changed, 351 insertions(+), 51 deletions(-) create mode 100644 src/test/java/io/github/sashirestela/cleverclient/WebSocketTest.java diff --git a/README.md b/README.md index 43bc1be..2d123ac 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,12 @@ A Java library for making http client and websocket requests easily. - [How to Use](#-how-to-use) - [Installation](#-installation) - [Features](#-features) - - [CleverClient Builder](#cleverclient-builder) - - [Http Client Options](#http-client-options) - - [WebSocket Options](#websocket-options) + - [CleverClient Creation](#cleverclient-creation) - [Interface Annotations](#interface-annotations) - [Supported Response Types](#supported-response-types) - [Interface Default Methods](#interface-default-methods) - [Exception Handling](#exception-handling) + - [WebSocket](#websocket) - [Examples](#-examples) - [Contributing](#-contributing) - [License](#-license) @@ -118,7 +117,7 @@ Take in account that you need to use **Java 11 or greater**. ## 📕 Features -### CleverClient Builder +### CleverClient Creation We have the following attributes to create a CleverClient object: @@ -138,6 +137,17 @@ We have the following attributes to create a CleverClient object: ```end(s)OfStream``` is required when you have endpoints sending back streams of data (Server Sent Events - SSE). +The attribute ```clientAdapter``` determines which Http client implementation to use. CleverClient supports two implementations out of the box: +- Java's HttpClient (default) via ```JavaHttpClientAdapter``` +- Square's OkHttp via ```OkHttpClientAdapter``` + +| clientAdapter's value | Description | +|-------------------------------------------------|-------------------------------------| +| new JavaHttpClientAdapter() | Uses a default Java's HttpClient | +| new JavaHttpClientAdapter(customJavaHttpClient) | Uses a custom Java's HttpClient | +| new OkHttpClientAdapter() | Uses a default OkHttpClient | +| new OkHttpClientAdapter(customOkHttpClient) | Uses a custom OkHttpClient | + Example: ```java @@ -185,32 +195,6 @@ var cleverClient = CleverClient.builder() .build(); ``` -### Http Client Options - -The Builder attribute ```clientAdapter``` determines which Http client implementation to use. CleverClient supports two implementations out of the box: -- Java's HttpClient (default) via ```JavaHttpClientAdapter``` -- Square's OkHttp via ```OkHttpClientAdapter``` - -| clientAdapter's value | Description | -|-------------------------------------------------|-------------------------------------| -| new JavaHttpClientAdapter() | Uses a default Java's HttpClient | -| new JavaHttpClientAdapter(customJavaHttpClient) | Uses a custom Java's HttpClient | -| new OkHttpClientAdapter() | Uses a default OkHttpClient | -| new OkHttpClientAdapter(customOkHttpClient) | Uses a custom OkHttpClient | - -### WebSocket Options - -The Builder attribute ```webSocketAdapter``` lets you specify which WebSocket implementation to use. Similar to ```clientAdapter```, you can choose between: -- Java's HttpClient (default) via ```JavaHttpWebSocketAdapter``` -- Square's OkHttp via ```OkHttpWebSocketAdapter``` - -| webSocketAdapter's value | Description | -|----------------------------------------------------|-------------------------------------| -| new JavaHttpWebSocketAdapter() | Uses a default Java's HttpClient | -| new JavaHttpWebSocketAdapter(customJavaHttpClient) | Uses a custom Java's HttpClient | -| new OkHttpWebSocketAdapter() | Uses a default OkHttpClient | -| new OkHttpWebSocketAdapter(customOkHttpClient) | Uses a custom OkHttpClient | - ### Interface Annotations | Annotation | Target | Attributes | Required Attrs | Mult | @@ -366,6 +350,63 @@ try { This mechanism allows you to handle both HTTP errors and other runtime exceptions in a clean, consistent way while preserving the original error information from the API response. +### WebSocket + +We have the following attributes to create a CleverClient.WebSocket object: + +| Attribute | Description | Required | +| -------------------|--------------------------------------------------------------|-----------| +| baseUrl | WebSocket's url | mandatory | +| queryParams | Map of query params (name/value) | optional | +| queryParam | Single query param as a name and a value | optional | +| headers | Map of headers (name/value) | optional | +| header | Single header as a name and a value | optional | +| webSocketAdapter | WebSocket implementation (Java HttpClient or OkHttp based) | optional | + +The attribute ```webSocketAdapter``` lets you specify which WebSocket implementation to use. You can choose between: +- Java's HttpClient (default) via ```JavaHttpWebSocketAdapter``` +- Square's OkHttp via ```OkHttpWebSocketAdapter``` + +| webSocketAdapter's value | Description | +|----------------------------------------------------|-------------------------------------| +| new JavaHttpWebSocketAdapter() | Uses a default Java's HttpClient | +| new JavaHttpWebSocketAdapter(customJavaHttpClient) | Uses a custom Java's HttpClient | +| new OkHttpWebSocketAdapter() | Uses a default OkHttpClient | +| new OkHttpWebSocketAdapter(customOkHttpClient) | Uses a custom OkHttpClient | + +Example: + +```java +final var BASE_URL = "ws://websocket.example.com"; +final var HEADER_NAME = "Authorization"; +final var HEADER_VALUE = "Bearer qwertyasdfghzxcvb"; + +var httpClient = HttpClient.newBuilder() + .version(Version.HTTP_1_1) + .followRedirects(Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .executor(Executors.newFixedThreadPool(3)) + .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80))) + .build(); + +var webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .queryParam("model", "qwerty_model") + .header(HEADER_NAME, HEADER_VALUE) + .webSocketAdapter(new JavaHttpWebSocketAdapter(httpClient)) + .build(); + +webSocket.onOpen(() -> System.out.println("Connected")); +webSocket.onMessage(message -> System.out.println("Received: " + message)); +webSocket.onClose((code, message) -> System.out.println("Closed")); + +webSocket.connect().join(); +webSocket.send("Hello World!").join(); +webSocket.send("Welcome to the Jungle!").join(); +webSocket.close(); +``` + + ## ✳ Examples Some examples have been created in the folder [example](https://github.com/sashirestela/cleverclient/tree/main/src/example/java/io/github/sashirestela/cleverclient/example) and you can follow the next steps to execute them: diff --git a/src/example/java/io/github/sashirestela/cleverclient/example/WebSocketExample.java b/src/example/java/io/github/sashirestela/cleverclient/example/WebSocketExample.java index 344c3f5..ebffef4 100644 --- a/src/example/java/io/github/sashirestela/cleverclient/example/WebSocketExample.java +++ b/src/example/java/io/github/sashirestela/cleverclient/example/WebSocketExample.java @@ -1,10 +1,9 @@ package io.github.sashirestela.cleverclient.example; +import io.github.sashirestela.cleverclient.CleverClient; import io.github.sashirestela.cleverclient.websocket.JavaHttpWebSocketAdapter; import io.github.sashirestela.cleverclient.websocket.WebSocketAdapter; -import java.util.Map; - public class WebSocketExample { protected WebSocketAdapter webSocketAdapter; @@ -14,17 +13,21 @@ public WebSocketExample() { } public void run() { - final String BASE_URL = "wss://s13970.blr1.piesocket.com/v3/1?api_key=" + System.getenv("PIESOCKET_API_KEY") - + "¬ify_self=1"; - - webSocketAdapter.onOpen(() -> System.out.println("Connected")); - webSocketAdapter.onMessage(message -> System.out.println("Received: " + message)); - webSocketAdapter.onClose((code, message) -> System.out.println("Closed")); - - webSocketAdapter.connect(BASE_URL, Map.of()).join(); - webSocketAdapter.send("Hello World!").join(); - webSocketAdapter.send("Welcome to the Jungle!").join(); - webSocketAdapter.close(); + var webSocket = CleverClient.WebSocket.builder() + .baseUrl("wss://s13970.blr1.piesocket.com/v3/1") + .queryParam("api_key", System.getenv("PIESOCKET_API_KEY")) + .queryParam("notify_self", "1") + .webSockewAdapter(webSocketAdapter) + .build(); + + webSocket.onOpen(() -> System.out.println("Connected")); + webSocket.onMessage(message -> System.out.println("Received: " + message)); + webSocket.onClose((code, message) -> System.out.println("Closed")); + + webSocket.connect().join(); + webSocket.send("Hello World!").join(); + webSocket.send("Welcome to the Jungle!").join(); + webSocket.close(); } public static void main(String[] args) { diff --git a/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java b/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java index 1f6fc70..2d489d7 100644 --- a/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java +++ b/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java @@ -8,6 +8,8 @@ import io.github.sashirestela.cleverclient.http.HttpResponseData; import io.github.sashirestela.cleverclient.support.Configurator; import io.github.sashirestela.cleverclient.util.CommonUtil; +import io.github.sashirestela.cleverclient.websocket.Action; +import io.github.sashirestela.cleverclient.websocket.JavaHttpWebSocketAdapter; import io.github.sashirestela.cleverclient.websocket.WebSocketAdapter; import lombok.Builder; import lombok.Getter; @@ -20,6 +22,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.UnaryOperator; @@ -38,7 +42,6 @@ public class CleverClient { private final UnaryOperator requestInterceptor; private final UnaryOperator responseInterceptor; private final HttpClientAdapter clientAdapter; - private final WebSocketAdapter webSockewAdapter; private final HttpProcessor httpProcessor; /** @@ -51,8 +54,6 @@ public class CleverClient { * @param responseInterceptor Function to modify the response once it has been received. * @param clientAdapter Component to call http services. If none is passed the * JavaHttpClientAdapter will be used. Optional. - * @param webSocketAdapter Component to do web socket interactions. If none is passed the - * JavaHttpWebSocketAdapter will be used. Optional. * @param endsOfStream Texts used to mark the final of streams when handling server sent * events (SSE). Optional. * @param objectMapper Provides Json conversions either to and from objects. Optional. @@ -61,8 +62,8 @@ public class CleverClient { @SuppressWarnings("java:S107") public CleverClient(@NonNull String baseUrl, @Singular Map headers, Consumer bodyInspector, UnaryOperator requestInterceptor, UnaryOperator responseInterceptor, - HttpClientAdapter clientAdapter, WebSocketAdapter webSocketAdapter, - @Singular("endOfStream") List endsOfStream, ObjectMapper objectMapper) { + HttpClientAdapter clientAdapter, @Singular("endOfStream") List endsOfStream, + ObjectMapper objectMapper) { this.baseUrl = baseUrl; this.headers = Optional.ofNullable(headers).orElse(Map.of()); this.bodyInspector = bodyInspector; @@ -71,7 +72,6 @@ public CleverClient(@NonNull String baseUrl, @Singular Map heade this.clientAdapter = Optional.ofNullable(clientAdapter).orElse(new JavaHttpClientAdapter()); this.clientAdapter.setRequestInterceptor(this.requestInterceptor); this.clientAdapter.setResponseInterceptor(this.responseInterceptor); - this.webSockewAdapter = webSocketAdapter; this.httpProcessor = HttpProcessor.builder() .baseUrl(this.baseUrl) @@ -98,4 +98,69 @@ public T create(Class interfaceClass) { return this.httpProcessor.createProxy(interfaceClass); } + /** + * Handles websocket communication. + */ + @Getter + public static class WebSocket { + + private final String baseUrl; + private final Map queryParams; + private final Map headers; + private final WebSocketAdapter webSockewAdapter; + private String fullUrl; + + /** + * Constructor to create an instance of CleverClient.WebSocket + * + * @param baseUrl Root of the url of the WebSocket to call. Mandatory. + * @param queryParams Query parameters (key=value) to be added to the baseUrl. + * @param headers Http headers to be passed to the WebSocket. Optional. + * @param webSockewAdapter Component to do web socket interactions. If none is passed the + * JavaHttpWebSocketAdapter will be used. Optional. + */ + @Builder + public WebSocket(@NonNull String baseUrl, @Singular Map queryParams, + @Singular Map headers, WebSocketAdapter webSockewAdapter) { + this.baseUrl = baseUrl; + this.queryParams = Optional.ofNullable(queryParams).orElse(Map.of()); + this.headers = Optional.ofNullable(headers).orElse(Map.of()); + this.webSockewAdapter = Optional.ofNullable(webSockewAdapter).orElse(new JavaHttpWebSocketAdapter()); + this.fullUrl = buildFullUrl(); + } + + private String buildFullUrl() { + return baseUrl + CommonUtil.stringMapToUrl(queryParams); + } + + public CompletableFuture connect() { + return webSockewAdapter.connect(fullUrl, headers); + } + + public CompletableFuture send(String message) { + return webSockewAdapter.send(message); + } + + public void close() { + webSockewAdapter.close(); + } + + public void onMessage(Consumer callback) { + webSockewAdapter.onMessage(callback); + } + + public void onOpen(Action callback) { + webSockewAdapter.onOpen(callback); + } + + public void onClose(BiConsumer callback) { + webSockewAdapter.onClose(callback); + } + + public void onError(Consumer callback) { + webSockewAdapter.onError(callback); + } + + } + } diff --git a/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java b/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java index 7abccf9..ce2d729 100644 --- a/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java +++ b/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java @@ -1,6 +1,8 @@ package io.github.sashirestela.cleverclient.util; import java.lang.reflect.Array; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -75,4 +77,20 @@ public static Map listToMapOfString(List list) { return createMapString(array); } + public static String stringMapToUrl(Map stringMap) { + if (stringMap.isEmpty()) { + return ""; + } + var stringUrl = new StringBuilder(); + stringMap.forEach((key, value) -> { + if (value != null) { + stringUrl.append(stringUrl.length() == 0 ? "?" : "&") + .append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append("=") + .append(URLEncoder.encode(value, StandardCharsets.UTF_8)); + } + }); + return stringUrl.toString(); + } + } diff --git a/src/main/java/io/github/sashirestela/cleverclient/websocket/WebSocketAdapter.java b/src/main/java/io/github/sashirestela/cleverclient/websocket/WebSocketAdapter.java index e03cdd0..0c174c2 100644 --- a/src/main/java/io/github/sashirestela/cleverclient/websocket/WebSocketAdapter.java +++ b/src/main/java/io/github/sashirestela/cleverclient/websocket/WebSocketAdapter.java @@ -30,8 +30,8 @@ public void onClose(BiConsumer callback) { this.closeCallback = callback; } - public void onError(Consumer errorCallback) { - this.errorCallback = errorCallback; + public void onError(Consumer callback) { + this.errorCallback = callback; } } diff --git a/src/test/java/io/github/sashirestela/cleverclient/WebSocketTest.java b/src/test/java/io/github/sashirestela/cleverclient/WebSocketTest.java new file mode 100644 index 0000000..59a9efd --- /dev/null +++ b/src/test/java/io/github/sashirestela/cleverclient/WebSocketTest.java @@ -0,0 +1,150 @@ +package io.github.sashirestela.cleverclient; + +import io.github.sashirestela.cleverclient.websocket.Action; +import io.github.sashirestela.cleverclient.websocket.JavaHttpWebSocketAdapter; +import io.github.sashirestela.cleverclient.websocket.WebSocketAdapter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WebSocketTest { + + private static final String BASE_URL = "ws://example.com/socket"; + + @Mock + private WebSocketAdapter mockWebSocketAdapter; + + private CleverClient.WebSocket webSocket; + + @Test + void shouldSetPropertiesToDefaultValuesWhenBuilderIsCalledWithoutThoseProperties() { + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .build(); + assertEquals(Map.of(), webSocket.getQueryParams()); + assertEquals(Map.of(), webSocket.getHeaders()); + assertNotNull(webSocket.getWebSockewAdapter()); + assertTrue(webSocket.getWebSockewAdapter() instanceof JavaHttpWebSocketAdapter); + } + + @Test + void testConnect() { + CompletableFuture expectedFuture = CompletableFuture.completedFuture(null); + when(mockWebSocketAdapter.connect(anyString(), anyMap())).thenReturn(expectedFuture); + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + CompletableFuture resultFuture = webSocket.connect(); + + assertEquals(expectedFuture, resultFuture); + verify(mockWebSocketAdapter).connect(BASE_URL, Map.of()); + } + + @Test + void testSend() { + String message = "test message"; + CompletableFuture expectedFuture = CompletableFuture.completedFuture(null); + when(mockWebSocketAdapter.send(message)).thenReturn(expectedFuture); + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + CompletableFuture resultFuture = webSocket.send(message); + + assertEquals(expectedFuture, resultFuture); + verify(mockWebSocketAdapter).send(message); + } + + @Test + void testClose() { + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + webSocket.close(); + + verify(mockWebSocketAdapter).close(); + } + + @Test + void testOnMessage() { + Consumer callback = message -> { + }; + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + webSocket.onMessage(callback); + + verify(mockWebSocketAdapter).onMessage(callback); + } + + @Test + void testOnOpen() { + Action callback = () -> { + }; + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + webSocket.onOpen(callback); + + verify(mockWebSocketAdapter).onOpen(callback); + } + + @Test + void testOnClose() { + BiConsumer callback = (code, reason) -> { + }; + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + webSocket.onClose(callback); + + verify(mockWebSocketAdapter).onClose(callback); + } + + @Test + void testOnError() { + Consumer callback = throwable -> { + }; + + webSocket = CleverClient.WebSocket.builder() + .baseUrl(BASE_URL) + .webSockewAdapter(mockWebSocketAdapter) + .build(); + + webSocket.onError(callback); + + verify(mockWebSocketAdapter).onError(callback); + } + +} diff --git a/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java b/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java index 6b4dd57..5236197 100644 --- a/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java +++ b/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java @@ -155,4 +155,27 @@ void shouldConvertListToMapOfStringWhenAListIsPassed() { assertEquals(expectedMap, actualMap); } + @SuppressWarnings("unchecked") + @Test + void shouldConvertStringMapToUrlStringWhenMapIsPassed() { + Object[][] testData = { + { Map.of(), "", "" }, + { Map.of("key", "val"), "?key=val", "?key=val" }, + { Map.of("key1", "val1", "key2", "val2"), "?key1=val1&key2=val2", "?key2=val2&key1=val1" } + }; + for (var data : testData) { + var expectedResult1 = (String) data[1]; + var expectedResult2 = (String) data[2]; + var actualResult = CommonUtil.stringMapToUrl((Map) data[0]); + assertTrue(actualResult.equals(expectedResult1) || actualResult.equals(expectedResult2)); + } + + var stringMap = new HashMap(); + stringMap.put("key1", null); + stringMap.put("key2", "val2"); + var expectedResult = "?key2=val2"; + var actualResult = CommonUtil.stringMapToUrl(stringMap); + assertEquals(expectedResult, actualResult); + } + }