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; + } +}