From 000b297d2b264a1d147040495bcc23d4c7d89e88 Mon Sep 17 00:00:00 2001
From: Sashir Estela <sashirestela@yahoo.com>
Date: Mon, 12 Feb 2024 22:26:11 +0000
Subject: [PATCH] Extend interceptor to handle body request

---
 .../cleverclient/CleverClient.java            | 27 ++++---
 .../cleverclient/http/HttpConnector.java      | 77 +++++++++++++++----
 .../cleverclient/http/HttpProcessor.java      | 63 +++++++--------
 .../cleverclient/http/HttpRequestData.java    | 18 +++++
 .../metadata/InterfaceMetadata.java           | 11 ++-
 .../cleverclient/support/ContentType.java     | 28 +++++++
 .../cleverclient/util/CommonUtil.java         |  5 ++
 .../cleverclient/util/Constant.java           |  9 ---
 .../cleverclient/CleverClientTest.java        | 59 +++++++++++---
 .../cleverclient/util/CommonUtilTest.java     | 10 +++
 .../util/HttpRequestBodyTestUtility.java      | 48 ++++++++++++
 11 files changed, 273 insertions(+), 82 deletions(-)
 create mode 100644 src/main/java/io/github/sashirestela/cleverclient/http/HttpRequestData.java
 create mode 100644 src/main/java/io/github/sashirestela/cleverclient/support/ContentType.java
 create mode 100644 src/test/java/io/github/sashirestela/cleverclient/util/HttpRequestBodyTestUtility.java

diff --git a/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java b/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java
index 77e1fd2..a727950 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/CleverClient.java
@@ -3,12 +3,13 @@
 import java.net.http.HttpClient;
 import java.util.Map;
 import java.util.Optional;
-import java.util.function.Function;
+import java.util.function.UnaryOperator;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.github.sashirestela.cleverclient.http.HttpProcessor;
+import io.github.sashirestela.cleverclient.http.HttpRequestData;
 import io.github.sashirestela.cleverclient.support.CleverClientSSE;
 import io.github.sashirestela.cleverclient.util.CommonUtil;
 import lombok.Builder;
@@ -29,33 +30,35 @@ public class CleverClient {
     private final String baseUrl;
     private final Map<String, String> headers;
     private final HttpClient httpClient;
-    private final Function<String, String> urlInterceptor;
+    private final UnaryOperator<HttpRequestData> requestInterceptor;
     private final HttpProcessor httpProcessor;
 
     /**
      * Constructor to create an instance of CleverClient.
      * 
-     * @param baseUrl        Root of the url of the API service to call. Mandatory.
-     * @param headers        Http headers for all the API service. Optional.
-     * @param httpClient     Custom Java's HttpClient component. One is created by
-     *                       default if none is passed. Optional.
-     * @param urlInterceptor Function to modify the url once it has been built.
-     * @param endOfStream    Text used to mark the final of streams when handling
-     *                       server sent events (SSE). Optional.
+     * @param baseUrl            Root of the url of the API service to call.
+     *                           Mandatory.
+     * @param headers            Http headers for all the API service. Optional.
+     * @param httpClient         Custom Java's HttpClient component. One is created
+     *                           by default if none is passed. Optional.
+     * @param requestInterceptor Function to modify the request once it has been
+     *                           built.
+     * @param endOfStream        Text used to mark the final of streams when
+     *                           handling server sent events (SSE). Optional.
      */
     @Builder
     public CleverClient(@NonNull String baseUrl, @Singular Map<String, String> headers, HttpClient httpClient,
-            Function<String, String> urlInterceptor, String endOfStream) {
+            UnaryOperator<HttpRequestData> requestInterceptor, String endOfStream) {
         this.baseUrl = baseUrl;
         this.headers = Optional.ofNullable(headers).orElse(Map.of());
         this.httpClient = Optional.ofNullable(httpClient).orElse(HttpClient.newHttpClient());
-        this.urlInterceptor = urlInterceptor;
+        this.requestInterceptor = requestInterceptor;
         CleverClientSSE.setEndOfStream(endOfStream);
         this.httpProcessor = HttpProcessor.builder()
                 .baseUrl(this.baseUrl)
                 .headers(CommonUtil.mapToListOfString(this.headers))
                 .httpClient(this.httpClient)
-                .urlInterceptor(this.urlInterceptor)
+                .requestInterceptor(this.requestInterceptor)
                 .build();
         logger.debug("CleverClient has been created.");
     }
diff --git a/src/main/java/io/github/sashirestela/cleverclient/http/HttpConnector.java b/src/main/java/io/github/sashirestela/cleverclient/http/HttpConnector.java
index e4c4807..b3b5825 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/http/HttpConnector.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/http/HttpConnector.java
@@ -5,14 +5,18 @@
 import java.net.http.HttpRequest;
 import java.net.http.HttpRequest.BodyPublisher;
 import java.net.http.HttpRequest.BodyPublishers;
+import java.util.List;
+import java.util.Map;
+import java.util.function.UnaryOperator;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.github.sashirestela.cleverclient.sender.HttpSenderFactory;
+import io.github.sashirestela.cleverclient.support.ContentType;
 import io.github.sashirestela.cleverclient.support.HttpMultipart;
 import io.github.sashirestela.cleverclient.support.ReturnType;
-import io.github.sashirestela.cleverclient.util.JsonUtil;
+import io.github.sashirestela.cleverclient.util.CommonUtil;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 
@@ -23,15 +27,16 @@
 @AllArgsConstructor
 @Builder
 public class HttpConnector {
-    private static Logger logger = LoggerFactory.getLogger(HttpConnector.class);
+    private static final Logger logger = LoggerFactory.getLogger(HttpConnector.class);
 
     private HttpClient httpClient;
     private String url;
     private String httpMethod;
     private ReturnType returnType;
     private Object bodyObject;
-    private boolean isMultipart;
-    private String[] headersArray;
+    private ContentType contentType;
+    private List<String> headers;
+    private UnaryOperator<HttpRequestData> requestInterceptor;
 
     /**
      * Prepares the request to call Java's HttpClient and delegates it to a
@@ -40,9 +45,16 @@ public class HttpConnector {
      * @return The response coming from the HttpSender's sendRequest method.
      */
     public Object sendRequest() {
-        var bodyPublisher = createBodyPublisher(bodyObject, isMultipart);
+        if (requestInterceptor != null) {
+            interceptRequest();
+        }
+        logger.debug("Http Call : {} {}", httpMethod, url);
+        logger.debug("Request Headers : {}", printHeaders(headers));
+
+        var bodyPublisher = createBodyPublisher(bodyObject, contentType);
         var responseClass = returnType.getBaseClass();
         var genericClass = returnType.getGenericClassIfExists();
+        var headersArray = headers.toArray(new String[0]);
         HttpRequest httpRequest = null;
         if (headersArray.length > 0) {
             httpRequest = HttpRequest.newBuilder()
@@ -60,21 +72,56 @@ public Object sendRequest() {
         return httpSender.sendRequest(httpClient, httpRequest, responseClass, genericClass);
     }
 
-    private BodyPublisher createBodyPublisher(Object bodyObject, boolean isMultipart) {
+    private void interceptRequest() {
+        var httpRequestData = HttpRequestData.builder()
+                .url(url)
+                .body(bodyObject)
+                .headers(CommonUtil.listToMapOfString(headers))
+                .httpMethod(httpMethod)
+                .contentType(contentType)
+                .build();
+
+        httpRequestData = requestInterceptor.apply(httpRequestData);
+
+        url = httpRequestData.getUrl();
+        bodyObject = httpRequestData.getBody();
+        headers = CommonUtil.mapToListOfString(httpRequestData.getHeaders());
+    }
+
+    @SuppressWarnings("unchecked")
+    private BodyPublisher createBodyPublisher(Object bodyObject, ContentType contentType) {
         BodyPublisher bodyPublisher = null;
-        if (bodyObject == null) {
+        if (contentType == null) {
             logger.debug("Request Body : (Empty)");
             bodyPublisher = BodyPublishers.noBody();
-        } else if (isMultipart) {
-            var data = JsonUtil.objectToMap(bodyObject);
-            var requestBytes = HttpMultipart.toByteArrays(data);
-            logger.debug("Request Body : {}", data);
-            bodyPublisher = BodyPublishers.ofByteArrays(requestBytes);
         } else {
-            var requestString = JsonUtil.objectToJson(bodyObject);
-            logger.debug("Request Body : {}", requestString);
-            bodyPublisher = BodyPublishers.ofString(requestString);
+            switch (contentType) {
+                case MULTIPART_FORMDATA:
+                    logger.debug("Request Body : {}", (Map<String, Object>) bodyObject);
+                    var bodyBytes = HttpMultipart.toByteArrays((Map<String, Object>) bodyObject);
+                    bodyPublisher = BodyPublishers.ofByteArrays(bodyBytes);
+                    break;
+
+                case APPLICATION_JSON:
+                    logger.debug("Request Body : {}", (String) bodyObject);
+                    bodyPublisher = BodyPublishers.ofString((String) bodyObject);
+                    break;
+            }
         }
         return bodyPublisher;
     }
+
+    private String printHeaders(List<String> headers) {
+        var print = "{";
+        for (var i = 0; i < headers.size(); i++) {
+            if (i > 1) {
+                print += ", ";
+            }
+            var headerKey = headers.get(i++);
+            var headerVal = headerKey.equals("Authorization") ? "*".repeat(10) : headers.get(i);
+            print += headerKey + " = " + headerVal;
+        }
+        print += "}";
+        return print;
+    }
 }
\ No newline at end of file
diff --git a/src/main/java/io/github/sashirestela/cleverclient/http/HttpProcessor.java b/src/main/java/io/github/sashirestela/cleverclient/http/HttpProcessor.java
index 77070fe..0469a52 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/http/HttpProcessor.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/http/HttpProcessor.java
@@ -7,14 +7,15 @@
 import java.net.http.HttpClient;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.function.Function;
+import java.util.function.UnaryOperator;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import io.github.sashirestela.cleverclient.metadata.InterfaceMetadata.MethodMetadata;
+import io.github.sashirestela.cleverclient.support.ContentType;
 import io.github.sashirestela.cleverclient.metadata.InterfaceMetadataStore;
-import io.github.sashirestela.cleverclient.util.Constant;
+import io.github.sashirestela.cleverclient.util.JsonUtil;
 import io.github.sashirestela.cleverclient.util.ReflectUtil;
 import lombok.Builder;
 
@@ -28,7 +29,7 @@ public class HttpProcessor implements InvocationHandler {
     private final String baseUrl;
     private final List<String> headers;
     private final HttpClient httpClient;
-    private final Function<String, String> urlInterceptor;
+    private final UnaryOperator<HttpRequestData> requestInterceptor;
 
     /**
      * Creates a generic dynamic proxy with this HttpProcessor object acting as an
@@ -98,59 +99,49 @@ private Object resolve(Method method, Object[] arguments) {
         var methodMetadata = interfaceMetadata.getMethodBySignature().get(method.toString());
         var urlMethod = interfaceMetadata.getFullUrlByMethod(methodMetadata);
         var url = baseUrl + URLBuilder.one().build(urlMethod, methodMetadata, arguments);
-        if (urlInterceptor != null) {
-            url = urlInterceptor.apply(url);
-        }
         var httpMethod = methodMetadata.getHttpAnnotationName();
         var returnType = methodMetadata.getReturnType();
-        var isMultipart = methodMetadata.isMultipart();
         var bodyObject = calculateBodyObject(methodMetadata, arguments);
+        var contentType = methodMetadata.getContentType();
         var fullHeaders = new ArrayList<>(this.headers);
-        fullHeaders.addAll(calculateHeaderContentType(bodyObject, isMultipart));
+        fullHeaders.addAll(calculateHeaderContentType(contentType));
         fullHeaders.addAll(interfaceMetadata.getFullHeadersByMethod(methodMetadata));
-        var fullHeadersArray = fullHeaders.toArray(new String[0]);
         var httpConnector = HttpConnector.builder()
                 .httpClient(httpClient)
                 .url(url)
                 .httpMethod(httpMethod)
                 .returnType(returnType)
                 .bodyObject(bodyObject)
-                .isMultipart(isMultipart)
-                .headersArray(fullHeadersArray)
+                .contentType(contentType)
+                .headers(fullHeaders)
+                .requestInterceptor(requestInterceptor)
                 .build();
-        logger.debug("Http Call : {} {}", httpMethod, url);
-        logger.debug("Request Headers : {}", printHeaders(fullHeaders));
         return httpConnector.sendRequest();
     }
 
     private Object calculateBodyObject(MethodMetadata methodMetadata, Object[] arguments) {
-        var indexBody = methodMetadata.getBodyIndex();
-        return indexBody >= 0 ? arguments[indexBody] : null;
-    }
-
-    private List<String> calculateHeaderContentType(Object bodyObject, boolean isMultipart) {
-        List<String> headerContentType = new ArrayList<>();
+        var bodyIndex = methodMetadata.getBodyIndex();
+        var bodyObject = bodyIndex >= 0 ? arguments[bodyIndex] : null;
         if (bodyObject != null) {
-            headerContentType.add(Constant.HEADER_CONTENT_TYPE);
-            var contentType = isMultipart
-                    ? Constant.TYPE_MULTIPART + Constant.BOUNDARY_TITLE + "\"" + Constant.BOUNDARY_VALUE + "\""
-                    : Constant.TYPE_APP_JSON;
-            headerContentType.add(contentType);
+            switch (methodMetadata.getContentType()) {
+                case MULTIPART_FORMDATA:
+                    bodyObject = JsonUtil.objectToMap(bodyObject);
+                    break;
+                case APPLICATION_JSON:
+                    bodyObject = JsonUtil.objectToJson(bodyObject);
+                    break;
+            }
         }
-        return headerContentType;
+        return bodyObject;
     }
 
-    private String printHeaders(List<String> headers) {
-        var print = "{";
-        for (var i = 0; i < headers.size(); i++) {
-            if (i > 1) {
-                print += ", ";
-            }
-            var headerKey = headers.get(i++);
-            var headerVal = headerKey.equals("Authorization") ? "*".repeat(10) : headers.get(i);
-            print += headerKey + " = " + headerVal;
+    private List<String> calculateHeaderContentType(ContentType contentType) {
+        final String HEADER_CONTENT_TYPE = "Content-Type";
+        List<String> headerContentType = new ArrayList<>();
+        if (contentType != null) {
+            headerContentType.add(HEADER_CONTENT_TYPE);
+            headerContentType.add(contentType.getMimeType() + contentType.getDetails());
         }
-        print += "}";
-        return print;
+        return headerContentType;
     }
 }
\ No newline at end of file
diff --git a/src/main/java/io/github/sashirestela/cleverclient/http/HttpRequestData.java b/src/main/java/io/github/sashirestela/cleverclient/http/HttpRequestData.java
new file mode 100644
index 0000000..eb4808a
--- /dev/null
+++ b/src/main/java/io/github/sashirestela/cleverclient/http/HttpRequestData.java
@@ -0,0 +1,18 @@
+package io.github.sashirestela.cleverclient.http;
+
+import java.util.Map;
+
+import io.github.sashirestela.cleverclient.support.ContentType;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+@Builder
+@Getter
+public class HttpRequestData {
+    @Setter private String url;
+    @Setter private Object body;
+    @Setter private Map<String, String> headers;
+    private String httpMethod;
+    private ContentType contentType;
+}
diff --git a/src/main/java/io/github/sashirestela/cleverclient/metadata/InterfaceMetadata.java b/src/main/java/io/github/sashirestela/cleverclient/metadata/InterfaceMetadata.java
index a5b1355..a972822 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/metadata/InterfaceMetadata.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/metadata/InterfaceMetadata.java
@@ -5,6 +5,7 @@
 import java.util.Map;
 import java.util.stream.Collectors;
 
+import io.github.sashirestela.cleverclient.support.ContentType;
 import io.github.sashirestela.cleverclient.support.ReturnType;
 import lombok.Builder;
 import lombok.Value;
@@ -75,7 +76,15 @@ public String getHttpAnnotationName() {
                     .getName();
         }
 
-        public boolean isMultipart() {
+        public ContentType getContentType() {
+            return getBodyIndex() == -1
+                    ? null
+                    : isMultipart()
+                            ? ContentType.MULTIPART_FORMDATA
+                            : ContentType.APPLICATION_JSON;
+        }
+
+        private boolean isMultipart() {
             return annotations.stream()
                     .anyMatch(annot -> annot.getName().equals(ANNOT_MULTIPART));
         }
diff --git a/src/main/java/io/github/sashirestela/cleverclient/support/ContentType.java b/src/main/java/io/github/sashirestela/cleverclient/support/ContentType.java
new file mode 100644
index 0000000..c8c735e
--- /dev/null
+++ b/src/main/java/io/github/sashirestela/cleverclient/support/ContentType.java
@@ -0,0 +1,28 @@
+package io.github.sashirestela.cleverclient.support;
+
+import io.github.sashirestela.cleverclient.util.Constant;
+
+public enum ContentType {
+    MULTIPART_FORMDATA(
+            "multipart/form-data",
+            "; boundary=\"" + Constant.BOUNDARY_VALUE + "\""),
+    APPLICATION_JSON(
+            "application/json",
+            "");
+
+    private String mimeType;
+    private String details;
+
+    ContentType(String mimeType, String details) {
+        this.mimeType = mimeType;
+        this.details = details;
+    }
+
+    public String getMimeType() {
+        return this.mimeType;
+    }
+
+    public String getDetails() {
+        return this.details;
+    }
+}
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 79d7641..ae302b8 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/util/CommonUtil.java
@@ -68,4 +68,9 @@ public static List<String> mapToListOfString(Map<String, String> map) {
         }).collect(Collectors.counting());
         return list;
     }
+
+    public static Map<String, String> listToMapOfString(List<String> list) {
+        var array = list.toArray(new String[0]);
+        return createMapString(array);
+    }
 }
\ No newline at end of file
diff --git a/src/main/java/io/github/sashirestela/cleverclient/util/Constant.java b/src/main/java/io/github/sashirestela/cleverclient/util/Constant.java
index 64c9c12..0a21655 100644
--- a/src/main/java/io/github/sashirestela/cleverclient/util/Constant.java
+++ b/src/main/java/io/github/sashirestela/cleverclient/util/Constant.java
@@ -7,15 +7,6 @@ public class Constant {
 
     private Constant() {
     }
-
-    public static final String JSON_EMPTY_CLASS = "{\"type\":\"object\",\"properties\":{}}";
-
-    public static final String HEADER_CONTENT_TYPE = "Content-Type";
-
-    public static final String TYPE_APP_JSON = "application/json";
-    public static final String TYPE_MULTIPART = "multipart/form-data";
-
-    public static final String BOUNDARY_TITLE = "; boundary=";
     public static final String BOUNDARY_VALUE = new BigInteger(256, new Random()).toString();
 
     public static final String REGEX_PATH_PARAM_URL = "\\{(.*?)\\}";
diff --git a/src/test/java/io/github/sashirestela/cleverclient/CleverClientTest.java b/src/test/java/io/github/sashirestela/cleverclient/CleverClientTest.java
index e4f6342..957d422 100644
--- a/src/test/java/io/github/sashirestela/cleverclient/CleverClientTest.java
+++ b/src/test/java/io/github/sashirestela/cleverclient/CleverClientTest.java
@@ -15,14 +15,20 @@
 import java.net.http.HttpResponse;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
-import java.util.function.Function;
+import java.util.function.UnaryOperator;
 
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
+import io.github.sashirestela.cleverclient.annotation.Body;
 import io.github.sashirestela.cleverclient.annotation.GET;
 import io.github.sashirestela.cleverclient.annotation.Query;
 import io.github.sashirestela.cleverclient.annotation.Resource;
+import io.github.sashirestela.cleverclient.http.HttpRequestData;
+import io.github.sashirestela.cleverclient.support.ContentType;
+import io.github.sashirestela.cleverclient.util.HttpRequestBodyTestUtility;
+import lombok.Builder;
+import lombok.Value;
 
 class CleverClientTest {
 
@@ -35,7 +41,7 @@ void shouldSetPropertiesToDefaultValuesWhenBuilderIsCalledWithoutThoseProperties
         assertEquals(HttpClient.Version.HTTP_2, cleverClient.getHttpClient().version());
         assertNotNull(cleverClient.getBaseUrl());
         assertNotNull(cleverClient.getHttpProcessor());
-        assertNull(cleverClient.getUrlInterceptor());
+        assertNull(cleverClient.getRequestInterceptor());
     }
 
     @Test
@@ -62,38 +68,73 @@ void shouldThrownExceptionWhenTryingToPassAnEmptyBaseUrl() {
 
     @SuppressWarnings("unchecked")
     @Test
-    void shouldModifyUrlWhenPassingUrlInterceptorFunction() {
+    void shouldModifyRequestWhenPassingInterceptorFunction() {
         var httpClient = mock(HttpClient.class);
-        Function<String, String> customUrlInterceptor = url -> {
+        UnaryOperator<HttpRequestData> requestInterceptor = request -> {
+            var url = request.getUrl();
+            var contentType = request.getContentType();
+            var body = request.getBody();
+
             // add a query parameter to url
             url = url + (url.contains("?") ? "&" : "?") + "api-version=2024-01-31";
             // remove '/vN' or '/vN.M' from url
             url = url.replaceFirst("(\\/v\\d+\\.*\\d*)", "");
-            return url;
+            request.setUrl(url);
+
+            if (contentType != null) {
+                if (contentType.equals(ContentType.APPLICATION_JSON)) {
+                    var bodyJson = (String) request.getBody();
+                    // remove a field from body (as Json)
+                    bodyJson = bodyJson.replaceFirst(",?\"model\":\"[^\"]*\",?", "");
+                    bodyJson = bodyJson.replaceFirst("\"\"", "\",\"");
+                    body = bodyJson;
+                }
+                if (contentType.equals(ContentType.MULTIPART_FORMDATA)) {
+                    Map<String, Object> bodyMap = (Map<String, Object>) request.getBody();
+                    // remove a field from body (as Map)
+                    bodyMap.remove("model");
+                    body = bodyMap;
+                }
+                request.setBody(body);
+            }
+
+            return request;
         };
         var cleverClient = CleverClient.builder()
                 .baseUrl("https://test")
-                .urlInterceptor(customUrlInterceptor)
+                .requestInterceptor(requestInterceptor)
                 .httpClient(httpClient)
                 .build();
         when(httpClient.sendAsync(any(), any()))
                 .thenReturn(CompletableFuture.completedFuture(mock(HttpResponse.class)));
 
         var test = cleverClient.create(TestCleverClient.class);
-        test.getText("geo");
+        test.getText(Sample.builder().id("1").model("abc").description("sample").build(), "geo");
 
         ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
         verify(httpClient, times(1)).sendAsync(requestCaptor.capture(), any());
 
-        var actualUrl = requestCaptor.getValue().uri().toString();
+        var httpRequestCaptor = requestCaptor.getValue();
+        var actualUrl = httpRequestCaptor.uri().toString();
         var expectedUrl = "https://test/api/text?prefix=geo&api-version=2024-01-31";
+        var actualBody = HttpRequestBodyTestUtility.extractBody(httpRequestCaptor);
+        var expectedBody = "{\"id\":\"1\",\"description\":\"sample\"}";
         assertEquals(expectedUrl, actualUrl);
+        assertEquals(expectedBody, actualBody);
+    }
+
+    @Value
+    @Builder
+    static class Sample {
+        String id;
+        String model;
+        String description;
     }
 
     @Resource("/v1.2/api")
     interface TestCleverClient {
 
         @GET("/text")
-        CompletableFuture<String> getText(@Query("prefix") String prefix);
+        CompletableFuture<String> getText(@Body Sample sample, @Query("prefix") String prefix);
     }
 }
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 b2052e1..b318fec 100644
--- a/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java
+++ b/src/test/java/io/github/sashirestela/cleverclient/util/CommonUtilTest.java
@@ -144,4 +144,14 @@ void shouldReturnAnEmptyListWhenCallingMapToListAnEmptyMapIsPassed() {
         List<String> actualList = CommonUtil.mapToListOfString(map);
         assertLinesMatch(expectedList, actualList);
     }
+
+    @Test
+    void shouldConvertListToMapOfStringWhenAListIsPassed() {
+        List<String> list = List.of("key1", "val1", "key2", "val2");
+        Map<String, String> expectedMap = new HashMap<>();
+        expectedMap.put("key1", "val1");
+        expectedMap.put("key2", "val2");
+        Map<String, String> actualMap = CommonUtil.listToMapOfString(list);
+        assertEquals(expectedMap, actualMap);
+    }
 }
\ No newline at end of file
diff --git a/src/test/java/io/github/sashirestela/cleverclient/util/HttpRequestBodyTestUtility.java b/src/test/java/io/github/sashirestela/cleverclient/util/HttpRequestBodyTestUtility.java
new file mode 100644
index 0000000..7ec8c44
--- /dev/null
+++ b/src/test/java/io/github/sashirestela/cleverclient/util/HttpRequestBodyTestUtility.java
@@ -0,0 +1,48 @@
+package io.github.sashirestela.cleverclient.util;
+
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.Flow;
+
+public class HttpRequestBodyTestUtility {
+
+    public static String extractBody(HttpRequest httpRequest) {
+        return httpRequest.bodyPublisher().map(p -> {
+            var bodySubscriber = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8);
+            var flowSubscriber = new HttpRequestBodyTestUtility.StringSubscriber(bodySubscriber);
+            p.subscribe(flowSubscriber);
+            return bodySubscriber.getBody().toCompletableFuture().join();
+        }).orElseThrow();
+    }
+
+    static final class StringSubscriber implements Flow.Subscriber<ByteBuffer> {
+        final HttpResponse.BodySubscriber<String> wrapped;
+
+        StringSubscriber(HttpResponse.BodySubscriber<String> wrapped) {
+            this.wrapped = wrapped;
+        }
+
+        @Override
+        public void onSubscribe(Flow.Subscription subscription) {
+            wrapped.onSubscribe(subscription);
+        }
+
+        @Override
+        public void onNext(ByteBuffer item) {
+            wrapped.onNext(List.of(item));
+        }
+
+        @Override
+        public void onError(Throwable throwable) {
+            wrapped.onError(throwable);
+        }
+
+        @Override
+        public void onComplete() {
+            wrapped.onComplete();
+        }
+    }
+}