You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ab...@apache.org on 2026/07/10 11:33:50 UTC

(httpcomponents-client) branch master updated: Added application/x-www-form-urlencoded support (request and response) to the Jakarta REST client backed by a collection of NameValuePair

This is an automated email from the ASF dual-hosted git repository.

arturobernalg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


The following commit(s) were added to refs/heads/master by this push:
     new 62716cf44 Added application/x-www-form-urlencoded support (request and response) to the Jakarta REST client backed by a collection of NameValuePair
62716cf44 is described below

commit 62716cf44edead47a8ba82a71142e0e5e170c05e
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Sat Jul 4 19:23:22 2026 +0200

    Added application/x-www-form-urlencoded support (request and response) to the Jakarta REST client backed by a collection of NameValuePair
    
    Corrects the @Consumes/@Produces content-type mapping to JAX-RS semantics (@Consumes = request body, @Produces = Accept).
---
 .../hc/client5/http/rest/ResourceIfaceScanner.java |   1 +
 .../client5/http/rest/RestInvocationHandler.java   |  74 ++++++++++--
 .../client5/http/rest/RestClientBuilderTest.java   |   6 +-
 .../http/rest/RestClientIntegrationTest.java       | 129 +++++++++++++++++++++
 4 files changed, 199 insertions(+), 11 deletions(-)

diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java
index 21d26f166..390bec789 100644
--- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java
@@ -137,6 +137,7 @@ static List<ContentType> parseMediaTypes(final String... mediaTypes) {
                     final ContentType contentType = ContentType.create(mimeType, elem.getParameters());
                     if (!contentType.isSameMimeType(ContentType.APPLICATION_JSON) &&
                             !contentType.isSameMimeType(ContentType.TEXT_PLAIN) &&
+                            !contentType.isSameMimeType(ContentType.APPLICATION_FORM_URLENCODED) &&
                             !contentType.isSameMimeType(ContentType.APPLICATION_OCTET_STREAM)) {
                         throw new RestResourceException("Unsupported media type: " + contentType);
                     }
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
index 628544827..8f7b2ad6f 100644
--- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
@@ -33,7 +33,10 @@
 import java.lang.reflect.Type;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -73,6 +76,7 @@
 import org.apache.hc.core5.jackson2.http.JsonObjectEntityProducer;
 import org.apache.hc.core5.jackson2.http.JsonResponseConsumers;
 import org.apache.hc.core5.net.URIBuilder;
+import org.apache.hc.core5.net.WWWFormCodec;
 import org.apache.hc.core5.util.Args;
 
 /**
@@ -162,19 +166,21 @@ private Object executeRequest(final ResourceMethod rm,
         for (final Header header : headers) {
             request.addHeader(header);
         }
-        final List<ContentType> consumesContentTypes = rm.getConsumesContentTypes();
-        if (consumesContentTypes != null && !consumesContentTypes.isEmpty()) {
+        // @Produces declares the media type(s) the client accepts back (Accept header).
+        final List<ContentType> acceptTypes = rm.getProducesContentTypes();
+        if (acceptTypes != null && !acceptTypes.isEmpty()) {
             request.setHeader(MessageSupport.headerOfTokens(
                     HttpHeaders.ACCEPT,
-                    consumesContentTypes.stream()
+                    acceptTypes.stream()
                     .map(ContentType::getMimeType)
                     .collect(Collectors.toList())));
         }
+        // @Consumes declares the media type of the request body the client sends.
         final AsyncEntityProducer entityProducer;
         if (bodyParam != null) {
-            final List<ContentType> producesContentTypes = rm.getProducesContentTypes();
-            final ContentType contentType = producesContentTypes != null && !producesContentTypes.isEmpty() ?
-                    producesContentTypes.get(0) : null;
+            final List<ContentType> requestBodyTypes = rm.getConsumesContentTypes();
+            final ContentType contentType = requestBodyTypes != null && !requestBodyTypes.isEmpty() ?
+                    requestBodyTypes.get(0) : null;
             entityProducer = createEntityProducer(bodyParam, contentType);
         } else {
             entityProducer = null;
@@ -184,7 +190,7 @@ private Object executeRequest(final ResourceMethod rm,
 
         final boolean isAsync = isAsync(rm.getMethod());
         final Class<?> rawType = resolveResponseType(rm.getMethod(), isAsync);
-        final Future<?> future = dispatchAsync(rawType, requestProducer);
+        final Future<?> future = dispatchAsync(rm.getMethod(), isAsync, rawType, requestProducer);
         if (isAsync) {
             return future;
         }
@@ -216,7 +222,8 @@ private static Class<?> resolveResponseType(final Method method, final boolean a
         return Object.class;
     }
 
-    private CompletableFuture<?> dispatchAsync(final Class<?> rawType,
+    private CompletableFuture<?> dispatchAsync(final Method method, final boolean async,
+                                               final Class<?> rawType,
                                                final BasicRequestProducer requestProducer) {
         if (rawType == Response.class) {
             return submit(requestProducer, new BasicResponseConsumer<>(new RestContentConsumer(objectMapper)))
@@ -244,6 +251,23 @@ private CompletableFuture<?> dispatchAsync(final Class<?> rawType,
                         return result.getBody();
                     });
         }
+        if (isNameValuePairCollection(method, async)) {
+            return submit(requestProducer, new RestResponseConsumer<>(objectMapper, StringAsyncEntityConsumer::new))
+                    .thenApply(result -> {
+                        throwIfError(result);
+                        final Header header = result.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE);
+                        final ContentType contentType = header != null
+                                ? MessageSupport.parserHeaderValue(header, ContentType::parse)
+                                : null;
+                        if (contentType != null && !ContentType.APPLICATION_FORM_URLENCODED.isSameMimeType(contentType)) {
+                            throw new RestResourceException(
+                                    "Expected an application/x-www-form-urlencoded response but received: " + contentType.getMimeType());
+                        }
+                        final Charset charset = ContentType.getCharset(contentType, StandardCharsets.UTF_8);
+                        final String body = result.getBody();
+                        return WWWFormCodec.parse(body != null ? body : "", charset);
+                    });
+        }
         @SuppressWarnings("unchecked") final Class<Object> objectType = (Class<Object>) rawType;
         return submit(requestProducer,
                 JsonResponseConsumers.create(objectMapper, objectType, () -> new RestContentConsumer(objectMapper)))
@@ -253,6 +277,23 @@ private CompletableFuture<?> dispatchAsync(final Class<?> rawType,
                 });
     }
 
+    private static boolean isNameValuePairCollection(final Method method, final boolean async) {
+        Type type = method.getGenericReturnType();
+        if (async && type instanceof ParameterizedType) {
+            type = ((ParameterizedType) type).getActualTypeArguments()[0];
+        }
+        if (!(type instanceof ParameterizedType)) {
+            return false;
+        }
+        final ParameterizedType parameterizedType = (ParameterizedType) type;
+        final Type rawType = parameterizedType.getRawType();
+        if (!(rawType instanceof Class) || !Collection.class.isAssignableFrom((Class<?>) rawType)) {
+            return false;
+        }
+        final Type[] typeArguments = parameterizedType.getActualTypeArguments();
+        return typeArguments.length == 1 && typeArguments[0] == NameValuePair.class;
+    }
+
     private <T> CompletableFuture<Message<HttpResponse, T>> submit(
             final BasicRequestProducer requestProducer,
             final AsyncResponseConsumer<Message<HttpResponse, T>> responseConsumer) {
@@ -314,6 +355,23 @@ private AsyncEntityProducer createEntityProducer(final Object body,
             final ContentType ct = consumeType != null ? consumeType : ContentType.TEXT_PLAIN;
             return new StringAsyncEntityProducer((CharSequence) body, ct);
         }
+        if (body instanceof Collection) {
+            final Collection<?> items = (Collection<?>) body;
+            final boolean formByType = consumeType != null
+                    && ContentType.APPLICATION_FORM_URLENCODED.isSameMimeType(consumeType);
+            final boolean formByContent = consumeType == null
+                    && !items.isEmpty()
+                    && items.stream().allMatch(e -> e instanceof NameValuePair);
+            if (formByType || formByContent) {
+                final ContentType ct = formByType ? consumeType : ContentType.APPLICATION_FORM_URLENCODED;
+                final Charset charset = ContentType.getCharset(ct, StandardCharsets.UTF_8);
+                final List<NameValuePair> pairs = new ArrayList<>(items.size());
+                for (final Object e : items) {
+                    pairs.add((NameValuePair) e);
+                }
+                return new StringAsyncEntityProducer(WWWFormCodec.format(pairs, charset), ct);
+            }
+        }
         return new JsonObjectEntityProducer<>(body, objectMapper);
     }
 
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java
index 86f8a1393..39bd2b4a4 100644
--- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java
@@ -154,7 +154,7 @@ public interface InspectApi {
 
         @POST
         @Path("/bytes")
-        @Produces(MediaType.TEXT_PLAIN)
+        @Consumes(MediaType.TEXT_PLAIN)
         String postBytes(byte[] body);
     }
 
@@ -162,8 +162,8 @@ public interface InspectApi {
     public interface EchoBodyApi {
 
         @POST
-        @Produces("text/plain; charset=ISO-8859-1")
-        @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+        @Consumes("text/plain; charset=ISO-8859-1")
+        @Produces(MediaType.APPLICATION_OCTET_STREAM)
         byte[] post(String body);
     }
 
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java
index 440a1d69f..159a3ffd6 100644
--- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java
@@ -26,12 +26,15 @@
  */
 package org.apache.hc.client5.http.rest;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.sun.net.httpserver.HttpExchange;
@@ -46,11 +49,14 @@
 import jakarta.ws.rs.QueryParam;
 import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
 import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class RestClientIntegrationTest {
 
@@ -103,6 +109,68 @@ void executesRealRoundTripAgainstLocalServer() throws Exception {
         assertThat(fetched.lang).isEqualTo("en");
     }
 
+    @Test
+    void sendsFormUrlEncodedFromNameValuePairs() throws Exception {
+        final BookResource client = RestClientBuilder.newBuilder()
+                .baseUri(baseUri)
+                .httpClient(httpClient)
+                .build(BookResource.class);
+
+        final List<NameValuePair> form = Arrays.asList(
+                new BasicNameValuePair("title", "HttpComponents in Action"),
+                new BasicNameValuePair("lang", "en & fr"));
+
+        final FormEcho echo = client.submitForm("token-1", form);
+
+        assertThat(echo.contentType).startsWith("application/x-www-form-urlencoded");
+        assertThat(echo.body).isEqualTo("title=HttpComponents+in+Action&lang=en+%26+fr");
+    }
+
+    @Test
+    void nameValuePairListStaysJsonWhenConsumesIsJson() throws Exception {
+        final BookResource client = RestClientBuilder.newBuilder()
+                .baseUri(baseUri)
+                .httpClient(httpClient)
+                .build(BookResource.class);
+
+        final List<NameValuePair> form = Arrays.asList(
+                new BasicNameValuePair("title", "HttpComponents in Action"),
+                new BasicNameValuePair("lang", "en"));
+
+        final FormEcho echo = client.submitJsonList("token-1", form);
+
+        assertThat(echo.contentType).startsWith("application/json");
+        assertThat(echo.body).doesNotContain("title=HttpComponents");
+    }
+
+    @Test
+    void readsFormUrlEncodedResponseAsNameValuePairs() throws Exception {
+        final BookResource client = RestClientBuilder.newBuilder()
+                .baseUri(baseUri)
+                .httpClient(httpClient)
+                .build(BookResource.class);
+
+        final List<NameValuePair> pairs = client.readForm("token-1");
+
+        assertThat(pairs).hasSize(2);
+        assertThat(pairs.get(0).getName()).isEqualTo("greeting");
+        assertThat(pairs.get(0).getValue()).isEqualTo("hello world");
+        assertThat(pairs.get(1).getName()).isEqualTo("lang");
+        assertThat(pairs.get(1).getValue()).isEqualTo("en");
+    }
+
+    @Test
+    void rejectsNonFormResponseForNameValuePairReturnType() {
+        final BookResource client = RestClientBuilder.newBuilder()
+                .baseUri(baseUri)
+                .httpClient(httpClient)
+                .build(BookResource.class);
+
+        assertThatThrownBy(() -> client.readNotForm("token-1"))
+                .isInstanceOf(RestResourceException.class)
+                .hasMessageContaining("application/x-www-form-urlencoded");
+    }
+
     private void handleBooks(final HttpExchange exchange) throws IOException {
         try {
             final String method = exchange.getRequestMethod();
@@ -115,6 +183,16 @@ private void handleBooks(final HttpExchange exchange) throws IOException {
                 return;
             }
 
+            if ("GET".equals(method) && "/books/not-form".equals(path)) {
+                sendJson(exchange, 200, new Book("x", "not a form"));
+                return;
+            }
+
+            if ("GET".equals(method) && "/books/form-echo".equals(path)) {
+                send(exchange, 200, "application/x-www-form-urlencoded", "greeting=hello+world&lang=en");
+                return;
+            }
+
             if ("GET".equals(method) && path.startsWith("/books/")) {
                 final String id = path.substring("/books/".length());
                 final String lang = extractQueryParam(query, "lang");
@@ -138,6 +216,14 @@ private void handleBooks(final HttpExchange exchange) throws IOException {
                 return;
             }
 
+            if ("POST".equals(method)) {
+                final FormEcho echo = new FormEcho();
+                echo.contentType = exchange.getRequestHeaders().getFirst("Content-Type");
+                echo.body = readString(exchange.getRequestBody());
+                sendJson(exchange, 200, echo);
+                return;
+            }
+
             send(exchange, 404, "text/plain", "Not found");
         } finally {
             exchange.close();
@@ -148,6 +234,16 @@ private <T> T readJson(final InputStream inputStream, final Class<T> type) throw
         return objectMapper.readValue(inputStream, type);
     }
 
+    private static String readString(final InputStream inputStream) throws IOException {
+        final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+        final byte[] tmp = new byte[1024];
+        int n;
+        while ((n = inputStream.read(tmp)) != -1) {
+            buffer.write(tmp, 0, n);
+        }
+        return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
+    }
+
     private void sendJson(final HttpExchange exchange, final int statusCode, final Object payload)
             throws IOException {
         final byte[] body = objectMapper.writeValueAsBytes(payload);
@@ -198,6 +294,30 @@ Book createBook(@QueryParam("id") String id,
                         @QueryParam("lang") String lang,
                         @HeaderParam("X-Auth") String auth,
                         Book book);
+
+        @POST
+        @Path("/form")
+        @Consumes("application/x-www-form-urlencoded")
+        @Produces("application/json")
+        FormEcho submitForm(@HeaderParam("X-Auth") String auth,
+                            List<NameValuePair> form);
+
+        @POST
+        @Path("/echo")
+        @Produces("application/json")
+        @Consumes("application/json")
+        FormEcho submitJsonList(@HeaderParam("X-Auth") String auth,
+                                List<NameValuePair> form);
+
+        @GET
+        @Path("/form-echo")
+        @Produces("application/x-www-form-urlencoded")
+        List<NameValuePair> readForm(@HeaderParam("X-Auth") String auth);
+
+        @GET
+        @Path("/not-form")
+        @Produces("application/x-www-form-urlencoded")
+        List<NameValuePair> readNotForm(@HeaderParam("X-Auth") String auth);
     }
 
     static final class Book {
@@ -215,4 +335,13 @@ static final class Book {
         }
     }
 
+    static final class FormEcho {
+
+        public String contentType;
+        public String body;
+
+        FormEcho() {
+        }
+    }
+
 }
\ No newline at end of file