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