From 3731ea7e91b3f00fe6d751b6949f77f8b670775e Mon Sep 17 00:00:00 2001 From: Oleg Kalnichevski Date: Mon, 25 May 2026 11:10:22 +0200 Subject: [PATCH] Improved efficincy of Jakarta REST Response implementation --- .../client5/http/rest/RestClientResponse.java | 189 +++++++----------- .../http/rest/RestInvocationHandler.java | 30 ++- .../http/rest/RestResponseConsumer.java | 160 +++++++++++++++ .../http/rest/RestClientResponseTest.java | 7 +- 4 files changed, 257 insertions(+), 129 deletions(-) create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResponseConsumer.java 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 bf939fe36e..2a16f56bb8 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 @@ -36,8 +36,8 @@ import java.time.Instant; import java.util.Collections; import java.util.Date; +import java.util.Iterator; import java.util.LinkedHashSet; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -54,13 +54,16 @@ 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.client5.http.validator.ETag; +import org.apache.hc.client5.http.validator.ValidatorType; 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.http.message.MessageSupport; import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.CharArrayBuffer; /** * Minimal {@link Response} implementation backed by a consumed {@link JsonNode} @@ -83,30 +86,26 @@ 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 final HttpResponse response; + private final JsonNode body; + private final ContentType contentType; + private final long len; 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; + RestClientResponse( + final ObjectMapper objectMapper, + final HttpResponse response, + final JsonNode jsonNode, + final ContentType contentType, + final long len) { 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; + this.response = Args.notNull(response, "Response"); + this.contentType = contentType; + this.body = jsonNode; + this.len = len; } private static MediaType toMediaType(final ContentType ct) { @@ -125,20 +124,21 @@ private static MediaType toMediaType(final ContentType ct) { @Override public int getStatus() { - return status; + return response.getCode(); } @Override public StatusType getStatusInfo() { - final Status standard = Status.fromStatusCode(status); - final String reason = reasonPhrase != null ? reasonPhrase + final int statusCode = response.getCode(); + final Status standard = Status.fromStatusCode(statusCode); + final String reason = response.getReasonPhrase() != null ? response.getReasonPhrase() : standard != null ? standard.getReasonPhrase() : ""; - final Status.Family family = Status.Family.familyOf(status); + final Status.Family family = Status.Family.familyOf(statusCode); return new StatusType() { @Override public int getStatusCode() { - return status; + return statusCode; } @Override @@ -162,7 +162,7 @@ public Object getEntity() { @Override public T readEntity(final Class entityType) { - return readEntity(entityType, (Annotation[]) null); + return readEntity(entityType, null); } @Override @@ -245,13 +245,7 @@ private byte[] bodyAsBytes() { } 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; + return ContentType.getCharset(contentType, StandardCharsets.UTF_8); } @Override @@ -278,43 +272,25 @@ private void ensureOpen() { @Override public MediaType getMediaType() { - return mediaType; + return toMediaType(contentType); } @Override public Locale getLanguage() { - final String lang = getHeaderString(HttpHeaders.CONTENT_LANGUAGE); - return lang != null ? Locale.forLanguageTag(lang) : null; + final Header h = response.getFirstHeader(HttpHeaders.CONTENT_LANGUAGE); + return h != null ? Locale.forLanguageTag(h.getValue()) : 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; + return (int) len; } @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; + final LinkedHashSet allowedMethods = new LinkedHashSet<>(); + MessageSupport.parseTokens(response, HttpHeaders.ALLOW, allowedMethods::add); + return allowedMethods; } @Override @@ -324,51 +300,32 @@ public Map getCookies() { @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); + final ETag eTag = ETag.get(response); + return eTag != null ? new EntityTag(eTag.getValue(), eTag.getType() == ValidatorType.WEAK) : null; } @Override public Date getDate() { - return parseHttpDate(getHeaderString(HttpHeaders.DATE)); + final Instant instant = DateUtils.parseStandardDate(response, HttpHeaders.DATE); + return instant != null ? Date.from(instant) : null; } @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); + final Instant instant = DateUtils.parseStandardDate(response, HttpHeaders.LAST_MODIFIED); 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; + final Header h = response.getFirstHeader(HttpHeaders.LOCATION); + if (h != null) { + try { + return new URI(h.getValue()); + } catch (final URISyntaxException ignore) { + } } + return null; } @Override @@ -394,52 +351,46 @@ public Link.Builder getLinkBuilder(final String relation) { @Override public MultivaluedMap getMetadata() { - return metadata; + final MultivaluedMap multimap = new MultivaluedHashMap<>(); + for (final Iterator
it = response.headerIterator(); it.hasNext(); ) { + final Header h = it.next(); + multimap.add(h.getName(), h.getValue()); + } + return multimap; } @Override public MultivaluedMap getStringHeaders() { - return stringHeaders; + final MultivaluedMap multimap = new MultivaluedHashMap<>(); + for (final Iterator
it = response.headerIterator(); it.hasNext(); ) { + final Header h = it.next(); + multimap.add(h.getName(), h.getValue()); + } + return multimap; } @Override public String getHeaderString(final String name) { - final List values = headerValues(name); - if (values == null || values.isEmpty()) { + final Header[] headers = response.getHeaders(name); + if (headers.length == 0) { 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(','); + } else if (headers.length == 1) { + return headers[0].getValue(); + } else { + final CharArrayBuffer buf = new CharArrayBuffer(128); + buf.append(headers[0].getValue()); + for (int i = 1; i < headers.length; i++) { + buf.append(", "); + buf.append(headers[i].getValue()); } - 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 buf.toString(); } - 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(); + return response.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 5858f4a650..aebb088b0f 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 @@ -43,6 +43,7 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.stream.Collectors; import com.fasterxml.jackson.databind.ObjectMapper; @@ -68,7 +69,6 @@ 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; @@ -183,7 +183,7 @@ private Object executeRequest(final ResourceMethod rm, final boolean isAsync = isAsync(rm.getMethod()); final Class rawType = resolveResponseType(rm.getMethod(), isAsync); - final CompletableFuture future = dispatchAsync(rawType, requestProducer); + final Future future = dispatchAsync(rawType, requestProducer); if (isAsync) { return future; } @@ -215,7 +215,7 @@ private static Class resolveResponseType(final Method method, final boolean a return Object.class; } - private CompletableFuture dispatchAsync(final Class rawType, + private CompletableFuture dispatchAsync(final Class rawType, final BasicRequestProducer requestProducer) { if (rawType == void.class || rawType == Void.class) { return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) @@ -225,8 +225,26 @@ private CompletableFuture dispatchAsync(final Class rawType, }); } if (rawType == Response.class) { - return submit(requestProducer, new BasicResponseConsumer<>(new JsonNodeEntityFallbackConsumer(objectMapper))) - .thenApply(result -> new RestClientResponse(result.getHead(), result.getBody(), objectMapper)); + final CompletableFuture cf = new CompletableFuture<>(); + httpClient.execute(requestProducer, new RestResponseConsumer(objectMapper), null, + new FutureCallback<>() { + + @Override + public void completed(final Response result) { + cf.complete(result); + } + + @Override + public void failed(final Exception ex) { + cf.completeExceptionally(ex); + } + + @Override + public void cancelled() { + cf.cancel(false); + } + }); + return cf; } if (rawType == byte[].class) { return submit(requestProducer, new BasicResponseConsumer<>(new BasicAsyncEntityConsumer())) @@ -277,7 +295,7 @@ public void cancelled() { return cf; } - private static Object awaitSync(final CompletableFuture future) { + private static Object awaitSync(final Future future) { try { return future.get(); } catch (final ExecutionException ex) { diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResponseConsumer.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResponseConsumer.java new file mode 100644 index 0000000000..42ca110c14 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResponseConsumer.java @@ -0,0 +1,160 @@ +/* + * ==================================================================== + * 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.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; + +import jakarta.ws.rs.core.Response; +import org.apache.hc.core5.concurrent.CallbackContribution; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.nio.AsyncEntityConsumer; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.entity.StringAsyncEntityConsumer; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.jackson2.http.JsonNodeEntityConsumer; +import org.apache.hc.core5.util.Args; + +final class RestResponseConsumer implements AsyncResponseConsumer { + + private final ObjectMapper objectMapper; + private final AtomicReference> entityConsumerRef; + + public RestResponseConsumer(final ObjectMapper objectMapper) { + this.objectMapper = Args.notNull(objectMapper, "Object mapper"); + this.entityConsumerRef = new AtomicReference<>(); + } + + @Override + public void informationResponse(final HttpResponse response, final HttpContext context) { + } + + @Override + public void consumeResponse(final HttpResponse httpResponse, + final EntityDetails entityDetails, + final HttpContext context, + final FutureCallback resultCallback) throws HttpException, IOException { + if (entityDetails == null) { + resultCallback.completed(new RestClientResponse( + objectMapper, + httpResponse, + null, + null, + -1)); + return; + } + final ContentType contentType = ContentType.parseLenient(entityDetails.getContentType()); + if (contentType == null || ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { + final AsyncEntityConsumer entityConsumer = new JsonNodeEntityConsumer(objectMapper.getFactory()); + entityConsumerRef.set(entityConsumer); + entityConsumer.streamStart(entityDetails, new CallbackContribution<>(resultCallback) { + + @Override + public void completed(final JsonNode result) { + resultCallback.completed(new RestClientResponse( + objectMapper, + httpResponse, + result, + contentType, + entityDetails.getContentLength())); + } + + }); + } else { + final AsyncEntityConsumer entityConsumer = new StringAsyncEntityConsumer(); + entityConsumerRef.set(entityConsumer); + entityConsumer.streamStart(entityDetails, new CallbackContribution<>(resultCallback) { + + @Override + public void completed(final String result) { + resultCallback.completed(new RestClientResponse( + objectMapper, + httpResponse, + JsonNodeFactory.instance.textNode(result), + contentType, + entityDetails.getContentLength())); + } + + }); + } + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + final AsyncEntityConsumer entityConsumer = entityConsumerRef.get(); + if (entityConsumer != null) { + entityConsumer.updateCapacity(capacityChannel); + } else { + capacityChannel.update(Integer.MAX_VALUE); + } + } + + @Override + public void consume(final ByteBuffer data) throws IOException { + final AsyncEntityConsumer entityConsumer = entityConsumerRef.get(); + if (entityConsumer != null) { + entityConsumer.consume(data); + } + } + + @Override + public void streamEnd(final List trailers) throws HttpException, IOException { + final AsyncEntityConsumer entityConsumer = entityConsumerRef.get(); + if (entityConsumer != null) { + entityConsumer.streamEnd(trailers); + } + } + + public void failed(final Exception cause) { + final AsyncEntityConsumer entityConsumer = entityConsumerRef.get(); + if (entityConsumer != null) { + entityConsumer.failed(cause); + } + releaseResources(); + } + + @Override + public void releaseResources() { + final AsyncEntityConsumer entityConsumer = entityConsumerRef.getAndSet(null); + if (entityConsumer != null) { + entityConsumer.releaseResources(); + } + } + +} 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 64217853d4..f3a7351381 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 @@ -26,6 +26,8 @@ */ package org.apache.hc.client5.http.rest; +import static org.assertj.core.api.Assertions.assertThat; + import java.io.IOException; import java.io.OutputStream; import java.net.InetSocketAddress; @@ -44,7 +46,6 @@ 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; @@ -54,8 +55,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - class RestClientResponseTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -144,7 +143,7 @@ void responseJsonEntityIsBackedByJsonNode() { final JsonNode node = OBJECT_MAPPER.createObjectNode().put("id", "abc"); - try (Response response = new RestClientResponse(httpResponse, node, OBJECT_MAPPER)) { + try (Response response = new RestClientResponse(OBJECT_MAPPER, httpResponse, node, ContentType.APPLICATION_JSON, -1)) { assertThat(response.readEntity(JsonNode.class)).isSameAs(node); assertThat(response.readEntity(Echo.class).id).isEqualTo("abc"); assertThat(response.readEntity(String.class)).isEqualTo("{\"id\":\"abc\"}");