From b1be68b8b48586bb19f7fd7bc41e5f8c3de86800 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sat, 4 Jul 2026 19:23:22 +0200 Subject: [PATCH] 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). --- .../http/rest/ResourceIfaceScanner.java | 1 + .../http/rest/RestInvocationHandler.java | 72 ++++++++-- .../http/rest/RestClientBuilderTest.java | 6 +- .../http/rest/RestClientIntegrationTest.java | 129 ++++++++++++++++++ 4 files changed, 197 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 21d26f1663..390bec7898 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 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 6285448274..4718b5afa5 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 consumesContentTypes = rm.getConsumesContentTypes(); - if (consumesContentTypes != null && !consumesContentTypes.isEmpty()) { + // @Produces declares the media type(s) the client accepts back (Accept header). + final List 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 producesContentTypes = rm.getProducesContentTypes(); - final ContentType contentType = producesContentTypes != null && !producesContentTypes.isEmpty() ? - producesContentTypes.get(0) : null; + final List 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,21 @@ 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 ? ContentType.parse(header.getValue()) : 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 objectType = (Class) rawType; return submit(requestProducer, JsonResponseConsumers.create(objectMapper, objectType, () -> new RestContentConsumer(objectMapper))) @@ -253,6 +275,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 CompletableFuture> submit( final BasicRequestProducer requestProducer, final AsyncResponseConsumer> responseConsumer) { @@ -314,6 +353,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 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 86f8a1393b..39bd2b4a4b 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 440a1d69f3..159a3ffd6a 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 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 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 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 readJson(final InputStream inputStream, final Class 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 form); + + @POST + @Path("/echo") + @Produces("application/json") + @Consumes("application/json") + FormEcho submitJsonList(@HeaderParam("X-Auth") String auth, + List form); + + @GET + @Path("/form-echo") + @Produces("application/x-www-form-urlencoded") + List readForm(@HeaderParam("X-Auth") String auth); + + @GET + @Path("/not-form") + @Produces("application/x-www-form-urlencoded") + List 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