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..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 @@ -58,10 +58,21 @@ *

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}, 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.

+ * + *

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.

+ * + *

{@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/RestClientResponse.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java new file mode 100644 index 0000000000..bf939fe36e --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponse.java @@ -0,0 +1,445 @@ +/* + * ==================================================================== + * 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.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +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; +import org.apache.hc.core5.util.Args; + +/** + * Minimal {@link Response} implementation backed by a consumed {@link JsonNode} + * entity and the headers of an executed {@link HttpResponse}. + *

+ * JSON response entities are digested directly into a {@link JsonNode}. Non-JSON + * response entities are represented as a textual {@link JsonNode} by the + * response consumer. 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 static final byte[] EMPTY = new byte[0]; + + private final int status; + private final String reasonPhrase; + private final JsonNode 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 JsonNode body, final ObjectMapper objectMapper) { + this.status = response.getCode(); + this.reasonPhrase = response.getReasonPhrase(); + this.body = body; + this.objectMapper = Args.notNull(objectMapper, "Object mapper"); + 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; + } + + @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 == void.class || rawType == Void.class) { + return null; + } + if (rawType == JsonNode.class) { + return body; + } + if (rawType == String.class) { + return bodyAsString(); + } + if (rawType == byte[].class) { + return bodyAsBytes(); + } + if (body == null || body.isMissingNode()) { + return null; + } + try { + if (genericType != null) { + final JavaType type = objectMapper.getTypeFactory().constructType(genericType); + return objectMapper.readerFor(type).readValue(body); + } + return objectMapper.treeToValue(body, rawType); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private String bodyAsString() { + if (body == null || body.isMissingNode()) { + return ""; + } + if (body.isTextual()) { + return body.asText(); + } + try { + return objectMapper.writeValueAsString(body); + } catch (final IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private byte[] bodyAsBytes() { + if (body == null || body.isMissingNode()) { + return EMPTY; + } + if (body.isTextual()) { + return body.asText().getBytes(charset()); + } + try { + return objectMapper.writeValueAsBytes(body); + } 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 != null && !body.isMissingNode(); + } + + @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 hasEntity() ? bodyAsBytes().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(getLength()); + sb.append(']'); + return sb.toString(); + } + +} \ No newline at end of file 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..e2d01772d9 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,24 +41,31 @@ 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.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; import org.apache.hc.core5.http.Message; import org.apache.hc.core5.http.message.BasicHttpRequest; import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer; 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.support.BasicRequestProducer; import org.apache.hc.core5.http.nio.support.BasicResponseConsumer; +import org.apache.hc.core5.jackson2.http.JsonNodeEntityFallbackConsumer; import org.apache.hc.core5.jackson2.http.JsonObjectEntityProducer; import org.apache.hc.core5.jackson2.http.JsonResponseConsumers; import org.apache.hc.core5.net.URIBuilder; @@ -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,51 +225,105 @@ 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; + + 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 JsonNodeEntityFallbackConsumer(objectMapper))) + .thenApply(result -> new RestClientResponse(result.getHead(), result.getBody(), objectMapper)); + } + 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, final Map pathParams, final Map> queryParams) { @@ -323,24 +413,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 +477,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/RestClientResponseTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java new file mode 100644 index 0000000000..86122485e5 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientResponseTest.java @@ -0,0 +1,283 @@ +/* + * ==================================================================== + * 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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.message.BasicHttpResponse; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RestClientResponseTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + 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 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 repeatedReadEntityReusesJsonTree() { + 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 responseJsonEntityIsBackedByJsonNode() { + final BasicHttpResponse httpResponse = new BasicHttpResponse(200); + httpResponse.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); + + final JsonNode node = OBJECT_MAPPER.createObjectNode().put("id", "abc"); + + try (Response response = new RestClientResponse(httpResponse, node, OBJECT_MAPPER)) { + assertSame(node, response.readEntity(JsonNode.class)); + assertEquals("abc", response.readEntity(Echo.class).id); + assertEquals("{\"id\":\"abc\"}", response.readEntity(String.class)); + } + } + + @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; + } +} \ No newline at end of file