From 309dd02b80bdf7e298d2bfdc35c47b5a42f0e5de Mon Sep 17 00:00:00 2001
From: Sashir Estela <sashirestela@gmail.com>
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")
-                + "&notify_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<HttpRequestData> requestInterceptor;
     private final UnaryOperator<HttpResponseData> 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<String, String> headers, Consumer<Object> bodyInspector,
             UnaryOperator<HttpRequestData> requestInterceptor, UnaryOperator<HttpResponseData> responseInterceptor,
-            HttpClientAdapter clientAdapter, WebSocketAdapter webSocketAdapter,
-            @Singular("endOfStream") List<String> endsOfStream, ObjectMapper objectMapper) {
+            HttpClientAdapter clientAdapter, @Singular("endOfStream") List<String> 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<String, String> 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> T create(Class<T> interfaceClass) {
         return this.httpProcessor.createProxy(interfaceClass);
     }
 
+    /**
+     * Handles websocket communication.
+     */
+    @Getter
+    public static class WebSocket {
+
+        private final String baseUrl;
+        private final Map<String, String> queryParams;
+        private final Map<String, String> 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<String, String> queryParams,
+                @Singular Map<String, String> 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<Void> connect() {
+            return webSockewAdapter.connect(fullUrl, headers);
+        }
+
+        public CompletableFuture<Void> send(String message) {
+            return webSockewAdapter.send(message);
+        }
+
+        public void close() {
+            webSockewAdapter.close();
+        }
+
+        public void onMessage(Consumer<String> callback) {
+            webSockewAdapter.onMessage(callback);
+        }
+
+        public void onOpen(Action callback) {
+            webSockewAdapter.onOpen(callback);
+        }
+
+        public void onClose(BiConsumer<Integer, String> callback) {
+            webSockewAdapter.onClose(callback);
+        }
+
+        public void onError(Consumer<Throwable> 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<String, String> listToMapOfString(List<String> list) {
         return createMapString(array);
     }
 
+    public static String stringMapToUrl(Map<String, String> 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<Integer, String> callback) {
         this.closeCallback = callback;
     }
 
-    public void onError(Consumer<Throwable> errorCallback) {
-        this.errorCallback = errorCallback;
+    public void onError(Consumer<Throwable> 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<Void> expectedFuture = CompletableFuture.completedFuture(null);
+        when(mockWebSocketAdapter.connect(anyString(), anyMap())).thenReturn(expectedFuture);
+
+        webSocket = CleverClient.WebSocket.builder()
+                .baseUrl(BASE_URL)
+                .webSockewAdapter(mockWebSocketAdapter)
+                .build();
+
+        CompletableFuture<Void> resultFuture = webSocket.connect();
+
+        assertEquals(expectedFuture, resultFuture);
+        verify(mockWebSocketAdapter).connect(BASE_URL, Map.of());
+    }
+
+    @Test
+    void testSend() {
+        String message = "test message";
+        CompletableFuture<Void> expectedFuture = CompletableFuture.completedFuture(null);
+        when(mockWebSocketAdapter.send(message)).thenReturn(expectedFuture);
+
+        webSocket = CleverClient.WebSocket.builder()
+                .baseUrl(BASE_URL)
+                .webSockewAdapter(mockWebSocketAdapter)
+                .build();
+
+        CompletableFuture<Void> 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<String> 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<Integer, String> callback = (code, reason) -> {
+        };
+
+        webSocket = CleverClient.WebSocket.builder()
+                .baseUrl(BASE_URL)
+                .webSockewAdapter(mockWebSocketAdapter)
+                .build();
+
+        webSocket.onClose(callback);
+
+        verify(mockWebSocketAdapter).onClose(callback);
+    }
+
+    @Test
+    void testOnError() {
+        Consumer<Throwable> 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<String, String>) data[0]);
+            assertTrue(actualResult.equals(expectedResult1) || actualResult.equals(expectedResult2));
+        }
+
+        var stringMap = new HashMap<String, String>();
+        stringMap.put("key1", null);
+        stringMap.put("key2", "val2");
+        var expectedResult = "?key2=val2";
+        var actualResult = CommonUtil.stringMapToUrl(stringMap);
+        assertEquals(expectedResult, actualResult);
+    }
+
 }