From a9074b76a32bee8f10c782c35484cc646c52b56c Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sun, 3 May 2026 14:13:36 +0200 Subject: [PATCH 1/2] Add Jakarta REST Request and Response return support --- .../client5/http/rest/RestClientBuilder.java | 22 +- .../client5/http/rest/RestClientRequest.java | 91 ++++ .../client5/http/rest/RestClientResponse.java | 393 ++++++++++++++++++ .../http/rest/RestInvocationHandler.java | 205 ++++++--- .../http/rest/RestClientRequestTest.java | 164 ++++++++ .../http/rest/RestClientResponseTest.java | 234 +++++++++++ 6 files changed, 1045 insertions(+), 64 deletions(-) create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java create mode 100644 httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java create mode 100644 httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java index 95d8978926..a98d9dfbb6 100644 --- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java @@ -58,10 +58,24 @@ *

Both {@code baseUri} and {@code httpClient} are required. The caller owns the * client lifecycle, including the call to {@code start()} before use.

* - *

Methods may return {@code String}, {@code byte[]}, {@code void}, or any type - * deserializable by the configured Jackson {@link ObjectMapper}. Request bodies may be - * {@code String}, {@code byte[]}, or any type serializable by the ObjectMapper. - * Non-2xx responses throw {@link RestClientResponseException}.

+ *

Methods may return {@code String}, {@code byte[]}, {@code void}, any type + * deserializable by the configured Jackson {@link ObjectMapper}, + * {@link jakarta.ws.rs.core.Response}, or {@link jakarta.ws.rs.core.Request}. Any of + * these may also be wrapped in {@link java.util.concurrent.CompletionStage} or + * {@link java.util.concurrent.CompletableFuture} for non-blocking dispatch. Request + * bodies may be {@code String}, {@code byte[]}, or any type serializable by the + * ObjectMapper.

+ * + *

Non-2xx responses throw {@link RestClientResponseException} (or complete the + * stage exceptionally with one) unless the method returns + * {@link jakarta.ws.rs.core.Response}, in which case the response is delivered to + * the caller for direct inspection. The {@link jakarta.ws.rs.core.Request} return + * type yields a value exposing only the dispatched HTTP method; the server-side + * {@code selectVariant} and {@code evaluatePreconditions} methods are not + * supported and throw {@link UnsupportedOperationException}.

+ * + *

{@link jakarta.ws.rs.core.Response} entities are buffered in memory and + * decoded on demand by {@code readEntity(...)}.

* * @since 5.7 */ diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java new file mode 100644 index 0000000000..e4c8abd1fe --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java @@ -0,0 +1,91 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.util.Date; +import java.util.List; + +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.Request; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Variant; + +import org.apache.hc.core5.util.Args; + +/** + * Client-side {@link Request} implementation that exposes the dispatched + * HTTP method. + *

+ * Server-side JAX-RS operations such as variant selection and request + * precondition evaluation are intentionally unsupported. + * + * @since 5.7 + */ +final class RestClientRequest implements Request { + + private final String method; + + RestClientRequest(final String method) { + this.method = Args.notBlank(method, "HTTP method"); + } + + @Override + public String getMethod() { + return method; + } + + @Override + public Variant selectVariant(final List variants) { + throw unsupported("selectVariant"); + } + + @Override + public Response.ResponseBuilder evaluatePreconditions(final EntityTag eTag) { + throw unsupported("evaluatePreconditions"); + } + + @Override + public Response.ResponseBuilder evaluatePreconditions(final Date lastModified) { + throw unsupported("evaluatePreconditions"); + } + + @Override + public Response.ResponseBuilder evaluatePreconditions(final Date lastModified, final EntityTag eTag) { + throw unsupported("evaluatePreconditions"); + } + + @Override + public Response.ResponseBuilder evaluatePreconditions() { + throw unsupported("evaluatePreconditions"); + } + + private static UnsupportedOperationException unsupported(final String operation) { + return new UnsupportedOperationException( + operation + " is a server-side JAX-RS operation and is not supported by the client proxy"); + } + +} \ No newline at end of file diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java new file mode 100644 index 0000000000..9c7a6b4e1d --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java @@ -0,0 +1,393 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.annotation.Annotation; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.GenericType; +import jakarta.ws.rs.core.Link; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; + +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpResponse; + +/** + * Minimal {@link Response} implementation backed by an in-memory byte body and + * the headers of an executed {@link HttpResponse}. + *

+ * The entity is fully buffered into a byte array on construction and decoded on + * demand by {@link #readEntity(Class)}. The implementation does not depend on a + * JAX-RS {@code RuntimeDelegate}: media types are constructed via the public + * {@link MediaType} constructor and {@link EntityTag} is only created on demand + * by {@link #getEntityTag()}. + *

+ * JAX-RS runtime delegate backed link builder operations such as + * {@link #getLinkBuilder(String)} are not supported and throw + * {@link UnsupportedOperationException}. + * + * @since 5.7 + */ +final class RestClientResponse extends Response { + + private final int status; + private final String reasonPhrase; + private final byte[] body; + private final MediaType mediaType; + private final MultivaluedMap metadata; + private final MultivaluedMap stringHeaders; + private final ObjectMapper objectMapper; + + private boolean closed; + private Object cachedEntity; + + RestClientResponse(final HttpResponse response, final byte[] body, final ObjectMapper objectMapper) { + this.status = response.getCode(); + this.reasonPhrase = response.getReasonPhrase(); + this.body = body != null ? body : new byte[0]; + this.objectMapper = objectMapper; + this.metadata = new MultivaluedHashMap<>(); + this.stringHeaders = new MultivaluedHashMap<>(); + for (final Header h : response.getHeaders()) { + this.metadata.add(h.getName(), h.getValue()); + this.stringHeaders.add(h.getName(), h.getValue()); + } + final Header ct = response.getFirstHeader(HttpHeaders.CONTENT_TYPE); + this.mediaType = ct != null ? toMediaType(ContentType.parse(ct.getValue())) : null; + } + + private static MediaType toMediaType(final ContentType ct) { + if (ct == null) { + return null; + } + final String mime = ct.getMimeType(); + final int slash = mime.indexOf('/'); + final String type = slash > 0 ? mime.substring(0, slash) : MediaType.MEDIA_TYPE_WILDCARD; + final String subtype = slash > 0 ? mime.substring(slash + 1) : MediaType.MEDIA_TYPE_WILDCARD; + if (ct.getCharset() != null) { + return new MediaType(type, subtype, ct.getCharset().name()); + } + return new MediaType(type, subtype); + } + + @Override + public int getStatus() { + return status; + } + + @Override + public StatusType getStatusInfo() { + final Status standard = Status.fromStatusCode(status); + final String reason = reasonPhrase != null ? reasonPhrase + : standard != null ? standard.getReasonPhrase() : ""; + final Status.Family family = Status.Family.familyOf(status); + return new StatusType() { + @Override public int getStatusCode() { return status; } + @Override public Status.Family getFamily() { return family; } + @Override public String getReasonPhrase() { return reason; } + }; + } + + @Override + public Object getEntity() { + ensureOpen(); + return body.length == 0 ? null : body; + } + + @Override + public T readEntity(final Class entityType) { + return readEntity(entityType, (Annotation[]) null); + } + + @Override + public T readEntity(final GenericType entityType) { + return readEntity(entityType, null); + } + + @SuppressWarnings("unchecked") + @Override + public T readEntity(final Class entityType, final Annotation[] annotations) { + ensureOpen(); + if (cachedEntity != null && entityType.isInstance(cachedEntity)) { + return (T) cachedEntity; + } + final T value = (T) decodeBody(entityType, null); + cachedEntity = value; + return value; + } + + @SuppressWarnings("unchecked") + @Override + public T readEntity(final GenericType entityType, final Annotation[] annotations) { + ensureOpen(); + return (T) decodeBody(entityType.getRawType(), entityType.getType()); + } + + private Object decodeBody(final Class rawType, final java.lang.reflect.Type genericType) { + if (rawType == byte[].class) { + return body; + } + if (rawType == String.class) { + return new String(body, charset()); + } + if (rawType == void.class || rawType == Void.class) { + return null; + } + if (body.length == 0) { + return null; + } + try { + if (genericType != null) { + return objectMapper.readValue(body, objectMapper.getTypeFactory().constructType(genericType)); + } + return objectMapper.readValue(body, rawType); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private Charset charset() { + if (mediaType != null) { + final String cs = mediaType.getParameters().get(MediaType.CHARSET_PARAMETER); + if (cs != null) { + return Charset.forName(cs); + } + } + return StandardCharsets.UTF_8; + } + + @Override + public boolean hasEntity() { + return body.length > 0; + } + + @Override + public boolean bufferEntity() { + ensureOpen(); + return true; + } + + @Override + public void close() { + closed = true; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Response has been closed"); + } + } + + @Override + public MediaType getMediaType() { + return mediaType; + } + + @Override + public Locale getLanguage() { + final String lang = getHeaderString(HttpHeaders.CONTENT_LANGUAGE); + return lang != null ? Locale.forLanguageTag(lang) : null; + } + + @Override + public int getLength() { + final String len = getHeaderString(HttpHeaders.CONTENT_LENGTH); + if (len != null) { + try { + return Integer.parseInt(len); + } catch (final NumberFormatException ignore) { + } + } + return body.length > 0 ? body.length : -1; + } + + @Override + public Set getAllowedMethods() { + final List values = headerValues(HttpHeaders.ALLOW); + if (values == null || values.isEmpty()) { + return Collections.emptySet(); + } + final Set result = new LinkedHashSet<>(); + for (final String v : values) { + for (final String m : v.split(",")) { + final String trimmed = m.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed.toUpperCase(Locale.ROOT)); + } + } + } + return result; + } + + @Override + public Map getCookies() { + return Collections.emptyMap(); + } + + @Override + public EntityTag getEntityTag() { + final String etag = getHeaderString(HttpHeaders.ETAG); + if (etag == null) { + return null; + } + String raw = etag.trim(); + boolean weak = false; + if (raw.startsWith("W/")) { + weak = true; + raw = raw.substring(2).trim(); + } + if (raw.length() >= 2 && raw.charAt(0) == '"' && raw.charAt(raw.length() - 1) == '"') { + raw = raw.substring(1, raw.length() - 1); + } + return new EntityTag(raw, weak); + } + + @Override + public Date getDate() { + return parseHttpDate(getHeaderString(HttpHeaders.DATE)); + } + + @Override + public Date getLastModified() { + return parseHttpDate(getHeaderString(HttpHeaders.LAST_MODIFIED)); + } + + private static Date parseHttpDate(final String value) { + if (value == null) { + return null; + } + final Instant instant = DateUtils.parseDate(value, DateUtils.STANDARD_PATTERNS); + return instant != null ? Date.from(instant) : null; + } + + @Override + public URI getLocation() { + final String loc = getHeaderString(HttpHeaders.LOCATION); + if (loc == null) { + return null; + } + try { + return new URI(loc); + } catch (final URISyntaxException ex) { + return null; + } + } + + @Override + public Set getLinks() { + return Collections.emptySet(); + } + + @Override + public boolean hasLink(final String relation) { + return false; + } + + @Override + public Link getLink(final String relation) { + return null; + } + + @Override + public Link.Builder getLinkBuilder(final String relation) { + throw new UnsupportedOperationException( + "Link.Builder requires a JAX-RS RuntimeDelegate implementation"); + } + + @Override + public MultivaluedMap getMetadata() { + return metadata; + } + + @Override + public MultivaluedMap getStringHeaders() { + return stringHeaders; + } + + @Override + public String getHeaderString(final String name) { + final List values = headerValues(name); + if (values == null || values.isEmpty()) { + return null; + } + if (values.size() == 1) { + return values.get(0); + } + final StringBuilder sb = new StringBuilder(); + for (final String v : values) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(v); + } + return sb.toString(); + } + + private List headerValues(final String name) { + for (final Map.Entry> entry : stringHeaders.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("RestClientResponse[status="); + sb.append(status); + if (mediaType != null) { + sb.append(", mediaType=").append(mediaType); + } + sb.append(", length=").append(body.length); + sb.append(']'); + return sb.toString(); + } +} 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 7544daa88b..4d71b1d3d3 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 @@ -30,6 +30,8 @@ import java.io.UncheckedIOException; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -39,12 +41,18 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.Request; +import jakarta.ws.rs.core.Response; + import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.core5.concurrent.FutureCallback; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpResponse; @@ -55,6 +63,7 @@ import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer; import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer; import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; import org.apache.hc.core5.http.nio.support.BasicRequestProducer; import org.apache.hc.core5.http.nio.support.BasicResponseConsumer; import org.apache.hc.core5.jackson2.http.JsonObjectEntityProducer; @@ -92,11 +101,38 @@ private static Map buildInvokers( final ClientResourceMethod rm = entry.getValue(); final String acceptHeader = rm.getProduces().length > 0 ? joinMediaTypes(rm.getProduces()) : null; final ContentType consumeType = rm.getConsumes().length > 0 ? ContentType.parse(rm.getConsumes()[0]) : null; - result.put(entry.getKey(), new MethodInvoker(rm, acceptHeader, consumeType)); + final boolean async = isAsync(rm.getMethod()); + final Class responseType = resolveResponseType(rm.getMethod(), async); + result.put(entry.getKey(), new MethodInvoker(rm, acceptHeader, consumeType, responseType, async)); } return result; } + private static boolean isAsync(final Method method) { + final Class rt = method.getReturnType(); + return rt == CompletionStage.class || rt == CompletableFuture.class; + } + + private static Class resolveResponseType(final Method method, final boolean async) { + if (!async) { + return method.getReturnType(); + } + final Type generic = method.getGenericReturnType(); + if (generic instanceof ParameterizedType) { + final Type inner = ((ParameterizedType) generic).getActualTypeArguments()[0]; + if (inner instanceof Class) { + return (Class) inner; + } + if (inner instanceof ParameterizedType) { + final Type raw = ((ParameterizedType) inner).getRawType(); + if (raw instanceof Class) { + return (Class) raw; + } + } + } + return Object.class; + } + private static String joinMediaTypes(final String[] types) { if (types.length == 1) { return types[0]; @@ -189,49 +225,111 @@ private Object executeRequest(final MethodInvoker invoker, entityProducer = null; } - final Class rawType = rm.getMethod().getReturnType(); final BasicRequestProducer requestProducer = new BasicRequestProducer(request, entityProducer); + final CompletableFuture future = dispatchAsync(invoker, requestProducer); + if (invoker.async) { + return future; + } + return awaitSync(future); + } + + private CompletableFuture dispatchAsync(final MethodInvoker invoker, + final BasicRequestProducer requestProducer) { + final Class rawType = invoker.responseType; + final ClientResourceMethod rm = invoker.resourceMethod; + + if (rawType == void.class || rawType == Void.class) { + return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) + .thenApply(result -> { + checkStatus(result.getHead(), null); + return null; + }); + } + if (rawType == Response.class) { + return submit(requestProducer, new BasicResponseConsumer<>(new BasicAsyncEntityConsumer())) + .thenApply(result -> new RestClientResponse(result.getHead(), result.getBody(), objectMapper)); + } + if (rawType == Request.class) { + return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) + .thenApply(result -> { + checkStatus(result.getHead(), null); + return new RestClientRequest(rm.getHttpMethod()); + }); + } + if (rawType == byte[].class) { + return submit(requestProducer, new BasicResponseConsumer<>(new BasicAsyncEntityConsumer())) + .thenApply(result -> { + final byte[] body = result.getBody(); + checkStatus(result.getHead(), body); + return body; + }); + } + if (rawType == String.class) { + return submit(requestProducer, new StringResponseConsumer()) + .thenApply(result -> { + throwIfError(result); + return result.getBody(); + }); + } + @SuppressWarnings("unchecked") final Class objectType = (Class) rawType; + return submit(requestProducer, + JsonResponseConsumers.create(objectMapper, objectType, BasicAsyncEntityConsumer::new)) + .thenApply(result -> { + throwIfError(result); + return result.getBody(); + }); + } + + private CompletableFuture> submit( + final BasicRequestProducer requestProducer, + final AsyncResponseConsumer> responseConsumer) { + final CompletableFuture> cf = new CompletableFuture<>(); + httpClient.execute(requestProducer, responseConsumer, null, + new FutureCallback>() { + + @Override + public void completed(final Message result) { + cf.complete(result); + } + + @Override + public void failed(final Exception ex) { + cf.completeExceptionally(ex); + } + + @Override + public void cancelled() { + cf.cancel(false); + } + }); + return cf; + } + + private static Object awaitSync(final CompletableFuture future) { try { - if (rawType == void.class || rawType == Void.class) { - final Message result = awaitResult( - httpClient.execute(requestProducer, - new BasicResponseConsumer<>( - new DiscardingEntityConsumer<>()), - null)); - checkStatus(result.getHead(), null); - return null; - } - if (rawType == byte[].class) { - final Message result = awaitResult( - httpClient.execute(requestProducer, - new BasicResponseConsumer<>( - new BasicAsyncEntityConsumer()), - null)); - final byte[] body = result.getBody(); - checkStatus(result.getHead(), body); - return body; - } - if (rawType == String.class) { - final Message result = awaitResult( - httpClient.execute(requestProducer, - new StringResponseConsumer(), null)); - throwIfError(result); - return result.getBody(); - } - @SuppressWarnings("unchecked") final Class objectType = (Class) rawType; - final Message result = awaitResult( - httpClient.execute(requestProducer, - JsonResponseConsumers.create(objectMapper, objectType, - BasicAsyncEntityConsumer::new), - null)); - throwIfError(result); - return result.getBody(); - } catch (final RestClientResponseException ex) { - throw ex; - } catch (final IOException ex) { - throw new UncheckedIOException(ex); + return future.get(); + } catch (final ExecutionException ex) { + throw unwrap(ex.getCause()); + } catch (final CompletionException ex) { + throw unwrap(ex.getCause()); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new UncheckedIOException(new IOException("Request interrupted", ex)); + } + } + + private static RuntimeException unwrap(final Throwable cause) { + if (cause instanceof RestClientResponseException) { + return (RestClientResponseException) cause; } + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + if (cause instanceof IOException) { + return new UncheckedIOException((IOException) cause); + } + return new UncheckedIOException(new IOException("Request execution failed", cause)); } private URI buildRequestUri(final String pathTemplate, @@ -323,24 +421,6 @@ private AsyncEntityProducer createEntityProducer(final Object body, return new JsonObjectEntityProducer<>(body, objectMapper); } - private static T awaitResult(final Future future) throws IOException { - try { - return future.get(); - } catch (final ExecutionException ex) { - final Throwable cause = ex.getCause(); - if (cause instanceof RestClientResponseException) { - throw (RestClientResponseException) cause; - } - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException("Request execution failed", cause); - } catch (final InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new IOException("Request interrupted", ex); - } - } - private static void checkStatus(final HttpResponse response, final byte[] body) { if (response.getCode() >= ERROR_STATUS_THRESHOLD) { @@ -405,12 +485,17 @@ static final class MethodInvoker { final ClientResourceMethod resourceMethod; final String acceptHeader; final ContentType consumeType; + final Class responseType; + final boolean async; MethodInvoker(final ClientResourceMethod rm, final String accept, - final ContentType consume) { + final ContentType consume, final Class responseType, + final boolean async) { this.resourceMethod = rm; this.acceptHeader = accept; this.consumeType = consume; + this.responseType = responseType; + this.async = async; } } diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java new file mode 100644 index 0000000000..4188043a60 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java @@ -0,0 +1,164 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Request; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RestClientRequestTest { + + private HttpServer server; + private CloseableHttpAsyncClient httpClient; + private URI baseUri; + private final AtomicReference lastMethod = new AtomicReference<>(); + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", this::handle); + server.start(); + + baseUri = new URI("http://localhost:" + server.getAddress().getPort()); + httpClient = HttpAsyncClients.createDefault(); + httpClient.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (httpClient != null) { + httpClient.close(); + } + if (server != null) { + server.stop(0); + } + } + + private void handle(final HttpExchange exchange) throws IOException { + try { + lastMethod.set(exchange.getRequestMethod()); + exchange.sendResponseHeaders(204, -1); + } finally { + exchange.close(); + } + } + + @Test + void syncRequestExposesHttpMethod() { + final Api api = build(); + final Request request = api.fetch(); + assertEquals("GET", request.getMethod()); + assertEquals("GET", lastMethod.get()); + } + + @Test + void syncPostExposesHttpMethod() { + final Api api = build(); + assertEquals("POST", api.create().getMethod()); + assertEquals("POST", lastMethod.get()); + } + + @Test + void completionStageRequestExposesHttpMethod() throws Exception { + final Api api = build(); + final CompletionStage stage = api.removeAsync(); + assertEquals("DELETE", stage.toCompletableFuture().get(5, TimeUnit.SECONDS).getMethod()); + assertEquals("DELETE", lastMethod.get()); + } + + @Test + void completableFutureRequestExposesHttpMethod() throws Exception { + final Api api = build(); + final CompletableFuture future = api.fetchFuture(); + + assertEquals("GET", future.get(5, TimeUnit.SECONDS).getMethod()); + assertEquals("GET", lastMethod.get()); + } + + @Test + void serverOnlyMethodsThrowUnsupportedOperationException() { + final RestClientRequest request = new RestClientRequest("GET"); + assertThrows(UnsupportedOperationException.class, + () -> request.selectVariant(Collections.emptyList())); + assertThrows(UnsupportedOperationException.class, + () -> request.evaluatePreconditions()); + assertThrows(UnsupportedOperationException.class, + () -> request.evaluatePreconditions((java.util.Date) null)); + assertThrows(UnsupportedOperationException.class, + () -> request.evaluatePreconditions((jakarta.ws.rs.core.EntityTag) null)); + assertThrows(UnsupportedOperationException.class, + () -> request.evaluatePreconditions(null, null)); + } + + private Api build() { + return RestClientBuilder.newBuilder() + .baseUri(baseUri) + .httpClient(httpClient) + .build(Api.class); + } + + @Path("/") + interface Api { + + @GET + @Path("/x") + Request fetch(); + + @POST + @Path("/x") + Request create(); + + @DELETE + @Path("/x") + CompletionStage removeAsync(); + + @GET + @Path("/x") + CompletableFuture fetchFuture(); + } +} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java new file mode 100644 index 0000000000..a02497abbc --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java @@ -0,0 +1,234 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RestClientResponseTest { + + private HttpServer server; + private CloseableHttpAsyncClient httpClient; + private URI baseUri; + + @BeforeEach + void setUp() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/echo", this::handleEcho); + server.createContext("/error", this::handleError); + server.createContext("/empty", this::handleEmpty); + server.start(); + + baseUri = new URI("http://localhost:" + server.getAddress().getPort()); + httpClient = HttpAsyncClients.createDefault(); + httpClient.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (httpClient != null) { + httpClient.close(); + } + if (server != null) { + server.stop(0); + } + } + + @Test + void successResponseExposesStatusBodyAndHeaders() { + final EchoApi api = build(); + try (Response response = api.echo("abc")) { + assertEquals(200, response.getStatus()); + assertEquals(Response.Status.OK, response.getStatusInfo().toEnum()); + assertEquals("OK", response.getStatusInfo().getReasonPhrase()); + assertNotNull(response.getMediaType()); + assertEquals("application", response.getMediaType().getType()); + assertEquals("json", response.getMediaType().getSubtype()); + assertEquals("custom-value", response.getHeaderString("X-Custom")); + assertTrue(response.hasEntity()); + assertEquals("{\"id\":\"abc\"}", response.readEntity(String.class)); + } + } + + @Test + void readEntityDecodesJsonPojo() { + final EchoApi api = build(); + try (Response response = api.echo("xyz")) { + final Echo echo = response.readEntity(Echo.class); + assertEquals("xyz", echo.id); + } + } + + @Test + void errorResponsesAreReturnedNotThrown() { + final EchoApi api = build(); + try (Response response = api.failing()) { + assertEquals(418, response.getStatus()); + assertEquals(Response.Status.Family.CLIENT_ERROR, response.getStatusInfo().getFamily()); + assertEquals("nope", response.readEntity(String.class)); + } + } + + @Test + void emptyBodyHasNoEntity() { + final EchoApi api = build(); + try (Response response = api.empty()) { + assertEquals(204, response.getStatus()); + assertFalse(response.hasEntity()); + assertTrue(response.getAllowedMethods().contains("GET")); + } + } + + @Test + void completionStageOfResponseDelivers2xx() throws Exception { + final EchoApi api = build(); + final CompletionStage stage = api.echoAsync("abc"); + try (Response response = stage.toCompletableFuture().get(5, TimeUnit.SECONDS)) { + assertEquals(200, response.getStatus()); + assertEquals("abc", response.readEntity(Echo.class).id); + } + } + + @Test + void completionStageOfResponseDeliversNon2xxAsValue() throws Exception { + final EchoApi api = build(); + try (Response response = api.failingAsync().toCompletableFuture().get(5, TimeUnit.SECONDS)) { + assertEquals(418, response.getStatus()); + assertEquals("nope", response.readEntity(String.class)); + } + } + + @Test + void completableFutureOfResponseDelivers2xx() throws Exception { + final EchoApi api = build(); + + try (Response response = api.echoFuture("abc").get(5, TimeUnit.SECONDS)) { + assertEquals(200, response.getStatus()); + assertEquals("abc", response.readEntity(Echo.class).id); + } + } + + private EchoApi build() { + return RestClientBuilder.newBuilder() + .baseUri(baseUri) + .httpClient(httpClient) + .build(EchoApi.class); + } + + private void handleEcho(final HttpExchange exchange) throws IOException { + try { + final String path = exchange.getRequestURI().getPath(); + final String id = path.substring("/echo/".length()); + final byte[] body = ("{\"id\":\"" + id + "\"}").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.getResponseHeaders().add("X-Custom", "custom-value"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } finally { + exchange.close(); + } + } + + private void handleError(final HttpExchange exchange) throws IOException { + try { + final byte[] body = "nope".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(418, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } finally { + exchange.close(); + } + } + + private void handleEmpty(final HttpExchange exchange) throws IOException { + try { + exchange.getResponseHeaders().add("Allow", "GET, HEAD"); + exchange.sendResponseHeaders(204, -1); + } finally { + exchange.close(); + } + } + + @Path("/") + interface EchoApi { + + @GET + @Path("/echo/{id}") + Response echo(@PathParam("id") String id); + + @GET + @Path("/echo/{id}") + CompletionStage echoAsync(@PathParam("id") String id); + + @GET + @Path("/echo/{id}") + CompletableFuture echoFuture(@PathParam("id") String id); + + @GET + @Path("/error") + Response failing(); + + @GET + @Path("/error") + CompletionStage failingAsync(); + + @GET + @Path("/empty") + Response empty(); + } + + static final class Echo { + public String id; + } +} From b4e103c69f627bb8f8f4420d2226d3a7a4af69ac Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Wed, 13 May 2026 09:19:16 +0200 Subject: [PATCH 2/2] Use JsonNode for REST response JSON content Represent buffered JSON response content as JsonNode internally and reuse it for entity decoding. This avoids repeatedly decoding the same JSON byte array while preserving the public Response readEntity behavior. --- .../client5/http/rest/RestClientBuilder.java | 11 +- .../client5/http/rest/RestClientRequest.java | 91 ---------- .../client5/http/rest/RestClientResponse.java | 53 +++++- .../http/rest/RestInvocationHandler.java | 9 - .../http/rest/RestClientRequestTest.java | 164 ------------------ .../http/rest/RestClientResponseTest.java | 26 +++ 6 files changed, 81 insertions(+), 273 deletions(-) delete mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java delete mode 100644 httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java index a98d9dfbb6..2a9d3a8583 100644 --- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java @@ -59,9 +59,9 @@ * client lifecycle, including the call to {@code start()} before use.

* *

Methods may return {@code String}, {@code byte[]}, {@code void}, any type - * deserializable by the configured Jackson {@link ObjectMapper}, - * {@link jakarta.ws.rs.core.Response}, or {@link jakarta.ws.rs.core.Request}. Any of - * these may also be wrapped in {@link java.util.concurrent.CompletionStage} or + * deserializable by the configured Jackson {@link ObjectMapper}, or + * {@link jakarta.ws.rs.core.Response}. Any of these may also be wrapped in + * {@link java.util.concurrent.CompletionStage} or * {@link java.util.concurrent.CompletableFuture} for non-blocking dispatch. Request * bodies may be {@code String}, {@code byte[]}, or any type serializable by the * ObjectMapper.

@@ -69,10 +69,7 @@ *

Non-2xx responses throw {@link RestClientResponseException} (or complete the * stage exceptionally with one) unless the method returns * {@link jakarta.ws.rs.core.Response}, in which case the response is delivered to - * the caller for direct inspection. The {@link jakarta.ws.rs.core.Request} return - * type yields a value exposing only the dispatched HTTP method; the server-side - * {@code selectVariant} and {@code evaluatePreconditions} methods are not - * supported and throw {@link UnsupportedOperationException}.

+ * the caller for direct inspection.

* *

{@link jakarta.ws.rs.core.Response} entities are buffered in memory and * decoded on demand by {@code readEntity(...)}.

diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java deleted file mode 100644 index e4c8abd1fe..0000000000 --- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientRequest.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ -package org.apache.hc.client5.http.rest; - -import java.util.Date; -import java.util.List; - -import jakarta.ws.rs.core.EntityTag; -import jakarta.ws.rs.core.Request; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Variant; - -import org.apache.hc.core5.util.Args; - -/** - * Client-side {@link Request} implementation that exposes the dispatched - * HTTP method. - *

- * Server-side JAX-RS operations such as variant selection and request - * precondition evaluation are intentionally unsupported. - * - * @since 5.7 - */ -final class RestClientRequest implements Request { - - private final String method; - - RestClientRequest(final String method) { - this.method = Args.notBlank(method, "HTTP method"); - } - - @Override - public String getMethod() { - return method; - } - - @Override - public Variant selectVariant(final List variants) { - throw unsupported("selectVariant"); - } - - @Override - public Response.ResponseBuilder evaluatePreconditions(final EntityTag eTag) { - throw unsupported("evaluatePreconditions"); - } - - @Override - public Response.ResponseBuilder evaluatePreconditions(final Date lastModified) { - throw unsupported("evaluatePreconditions"); - } - - @Override - public Response.ResponseBuilder evaluatePreconditions(final Date lastModified, final EntityTag eTag) { - throw unsupported("evaluatePreconditions"); - } - - @Override - public Response.ResponseBuilder evaluatePreconditions() { - throw unsupported("evaluatePreconditions"); - } - - private static UnsupportedOperationException unsupported(final String operation) { - return new UnsupportedOperationException( - operation + " is a server-side JAX-RS operation and is not supported by the client proxy"); - } - -} \ No newline at end of file diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java index 9c7a6b4e1d..68a797b851 100644 --- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java @@ -42,6 +42,8 @@ import java.util.Map; import java.util.Set; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.ws.rs.core.EntityTag; @@ -64,8 +66,11 @@ * the headers of an executed {@link HttpResponse}. *

* The entity is fully buffered into a byte array on construction and decoded on - * demand by {@link #readEntity(Class)}. The implementation does not depend on a - * JAX-RS {@code RuntimeDelegate}: media types are constructed via the public + * demand by {@link #readEntity(Class)}. JSON content is parsed lazily into a + * Jackson {@link JsonNode} on the first JSON read and reused for subsequent + * {@code readEntity(...)} calls, so repeated decoding does not reparse the + * underlying bytes. The implementation does not depend on a JAX-RS + * {@code RuntimeDelegate}: media types are constructed via the public * {@link MediaType} constructor and {@link EntityTag} is only created on demand * by {@link #getEntityTag()}. *

@@ -87,6 +92,8 @@ final class RestClientResponse extends Response { private boolean closed; private Object cachedEntity; + private JsonNode jsonNode; + private boolean jsonParsed; RestClientResponse(final HttpResponse response, final byte[] body, final ObjectMapper objectMapper) { this.status = response.getCode(); @@ -180,10 +187,24 @@ private Object decodeBody(final Class rawType, final java.lang.reflect.Type g if (rawType == void.class || rawType == Void.class) { return null; } + if (rawType == JsonNode.class) { + return jsonRoot(); + } if (body.length == 0) { return null; } try { + if (isJsonMediaType()) { + final JsonNode root = jsonRoot(); + if (root == null || root.isMissingNode()) { + return null; + } + if (genericType != null) { + final JavaType type = objectMapper.getTypeFactory().constructType(genericType); + return objectMapper.convertValue(root, type); + } + return objectMapper.treeToValue(root, rawType); + } if (genericType != null) { return objectMapper.readValue(body, objectMapper.getTypeFactory().constructType(genericType)); } @@ -193,6 +214,34 @@ private Object decodeBody(final Class rawType, final java.lang.reflect.Type g } } + private JsonNode jsonRoot() { + if (!jsonParsed) { + jsonParsed = true; + if (body.length > 0) { + try { + jsonNode = objectMapper.readTree(body); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + } + } + return jsonNode; + } + + private boolean isJsonMediaType() { + if (mediaType == null) { + return false; + } + if (!"application".equalsIgnoreCase(mediaType.getType())) { + return false; + } + final String subtype = mediaType.getSubtype(); + if (subtype == null) { + return false; + } + return "json".equalsIgnoreCase(subtype) || subtype.toLowerCase(Locale.ROOT).endsWith("+json"); + } + private Charset charset() { if (mediaType != null) { final String cs = mediaType.getParameters().get(MediaType.CHARSET_PARAMETER); 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 4d71b1d3d3..16728cb5cf 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 @@ -48,7 +48,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -237,7 +236,6 @@ private Object executeRequest(final MethodInvoker invoker, private CompletableFuture dispatchAsync(final MethodInvoker invoker, final BasicRequestProducer requestProducer) { final Class rawType = invoker.responseType; - final ClientResourceMethod rm = invoker.resourceMethod; if (rawType == void.class || rawType == Void.class) { return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) @@ -250,13 +248,6 @@ private CompletableFuture dispatchAsync(final MethodInvoker invoker, return submit(requestProducer, new BasicResponseConsumer<>(new BasicAsyncEntityConsumer())) .thenApply(result -> new RestClientResponse(result.getHead(), result.getBody(), objectMapper)); } - if (rawType == Request.class) { - return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) - .thenApply(result -> { - checkStatus(result.getHead(), null); - return new RestClientRequest(rm.getHttpMethod()); - }); - } if (rawType == byte[].class) { return submit(requestProducer, new BasicResponseConsumer<>(new BasicAsyncEntityConsumer())) .thenApply(result -> { diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java deleted file mode 100644 index 4188043a60..0000000000 --- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientRequestTest.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * ==================================================================== - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * ==================================================================== - * - * This software consists of voluntary contributions made by many - * individuals on behalf of the Apache Software Foundation. For more - * information on the Apache Software Foundation, please see - * . - * - */ -package org.apache.hc.client5.http.rest; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.util.Collections; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Request; -import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; -import org.apache.hc.client5.http.impl.async.HttpAsyncClients; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class RestClientRequestTest { - - private HttpServer server; - private CloseableHttpAsyncClient httpClient; - private URI baseUri; - private final AtomicReference lastMethod = new AtomicReference<>(); - - @BeforeEach - void setUp() throws Exception { - server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/", this::handle); - server.start(); - - baseUri = new URI("http://localhost:" + server.getAddress().getPort()); - httpClient = HttpAsyncClients.createDefault(); - httpClient.start(); - } - - @AfterEach - void tearDown() throws Exception { - if (httpClient != null) { - httpClient.close(); - } - if (server != null) { - server.stop(0); - } - } - - private void handle(final HttpExchange exchange) throws IOException { - try { - lastMethod.set(exchange.getRequestMethod()); - exchange.sendResponseHeaders(204, -1); - } finally { - exchange.close(); - } - } - - @Test - void syncRequestExposesHttpMethod() { - final Api api = build(); - final Request request = api.fetch(); - assertEquals("GET", request.getMethod()); - assertEquals("GET", lastMethod.get()); - } - - @Test - void syncPostExposesHttpMethod() { - final Api api = build(); - assertEquals("POST", api.create().getMethod()); - assertEquals("POST", lastMethod.get()); - } - - @Test - void completionStageRequestExposesHttpMethod() throws Exception { - final Api api = build(); - final CompletionStage stage = api.removeAsync(); - assertEquals("DELETE", stage.toCompletableFuture().get(5, TimeUnit.SECONDS).getMethod()); - assertEquals("DELETE", lastMethod.get()); - } - - @Test - void completableFutureRequestExposesHttpMethod() throws Exception { - final Api api = build(); - final CompletableFuture future = api.fetchFuture(); - - assertEquals("GET", future.get(5, TimeUnit.SECONDS).getMethod()); - assertEquals("GET", lastMethod.get()); - } - - @Test - void serverOnlyMethodsThrowUnsupportedOperationException() { - final RestClientRequest request = new RestClientRequest("GET"); - assertThrows(UnsupportedOperationException.class, - () -> request.selectVariant(Collections.emptyList())); - assertThrows(UnsupportedOperationException.class, - () -> request.evaluatePreconditions()); - assertThrows(UnsupportedOperationException.class, - () -> request.evaluatePreconditions((java.util.Date) null)); - assertThrows(UnsupportedOperationException.class, - () -> request.evaluatePreconditions((jakarta.ws.rs.core.EntityTag) null)); - assertThrows(UnsupportedOperationException.class, - () -> request.evaluatePreconditions(null, null)); - } - - private Api build() { - return RestClientBuilder.newBuilder() - .baseUri(baseUri) - .httpClient(httpClient) - .build(Api.class); - } - - @Path("/") - interface Api { - - @GET - @Path("/x") - Request fetch(); - - @POST - @Path("/x") - Request create(); - - @DELETE - @Path("/x") - CompletionStage removeAsync(); - - @GET - @Path("/x") - CompletableFuture fetchFuture(); - } -} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java index a02497abbc..4408806107 100644 --- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java @@ -35,6 +35,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; +import com.fasterxml.jackson.databind.JsonNode; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import jakarta.ws.rs.GET; @@ -50,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class RestClientResponseTest { @@ -106,6 +108,30 @@ void readEntityDecodesJsonPojo() { } } + @Test + void readEntityReturnsJsonNodeForJsonContent() { + final EchoApi api = build(); + try (Response response = api.echo("abc")) { + final JsonNode node = response.readEntity(JsonNode.class); + assertNotNull(node); + assertTrue(node.isObject()); + assertEquals("abc", node.get("id").asText()); + } + } + + @Test + void repeatedReadEntityReusesParsedJsonTree() { + final EchoApi api = build(); + try (Response response = api.echo("abc")) { + final JsonNode first = response.readEntity(JsonNode.class); + final Echo pojo = response.readEntity(Echo.class); + final JsonNode second = response.readEntity(JsonNode.class); + assertEquals("abc", pojo.id); + assertEquals("abc", first.get("id").asText()); + assertSame(first, second); + } + } + @Test void errorResponsesAreReturnedNotThrown() { final EchoApi api = build();