Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arturobernalg Indeed. It looks like I made a mistake here. @Produces declares what the endpoint produces and what client can choose to accept. Good catch.

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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arturobernalg One small thing. Let's make ContentType parsing more efficient:

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)))
Expand All @@ -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 <T> CompletableFuture<Message<HttpResponse, T>> submit(
final BasicRequestProducer requestProducer,
final AsyncResponseConsumer<Message<HttpResponse, T>> responseConsumer) {
Expand Down Expand Up @@ -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<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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,16 @@ public interface InspectApi {

@POST
@Path("/bytes")
@Produces(MediaType.TEXT_PLAIN)
@Consumes(MediaType.TEXT_PLAIN)
String postBytes(byte[] body);
}

@Path("/echobody")
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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -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();
Expand All @@ -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");
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -215,4 +335,13 @@ static final class Book {
}
}

static final class FormEcho {

public String contentType;
public String body;

FormEcho() {
}
}

}
Loading