diff --git a/httpclient5-jakarta-rest-client/pom.xml b/httpclient5-jakarta-rest-client/pom.xml
new file mode 100644
index 0000000000..e86049a4ba
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/pom.xml
@@ -0,0 +1,135 @@
+
+
+
+ httpclient5-parent
+ org.apache.httpcomponents.client5
+ 5.7-alpha1-SNAPSHOT
+
+ 4.0.0
+
+ httpclient5-jakarta-rest-client
+ Jakarta REST Client for Apache HttpClient
+ Type-safe Jakarta REST client backed by Apache HttpClient
+
+
+ org.apache.hc.client5.http.rest
+ 11
+ 11
+
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.apache.httpcomponents.core5
+ httpcore5-testing
+ test
+
+
+ org.slf4j
+ slf4j-api
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ test
+
+
+
+
+
+
+ org.codehaus.mojo
+ animal-sniffer-maven-plugin
+
+ true
+
+
+
+
+
+
+
+ skip-on-java8
+
+
+ hc.build.toolchain.version
+ 1.8
+
+
+
+ true
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+ maven-project-info-reports-plugin
+ false
+
+
+
+ index
+ dependencies
+ dependency-info
+ summary
+
+
+
+
+
+
+
+
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java
new file mode 100644
index 0000000000..33f08adbf0
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java
@@ -0,0 +1,98 @@
+/*
+ * ====================================================================
+ * 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.Locale;
+
+/**
+ * Builds the value of the {@code Accept} header from the media types declared in a
+ * {@code @Produces} annotation. Implementations control how multiple media types are
+ * ordered and whether quality values are assigned.
+ *
+ * @since 5.7
+ */
+public interface AcceptStrategy {
+
+ /**
+ * Default strategy that assigns descending quality values (1.0, 0.9, 0.8, ...) when
+ * more than one media type is declared. A single type is sent without a quality parameter.
+ */
+ AcceptStrategy QUALITY_DESCENDING = new AcceptStrategy() {
+
+ /** Quality-value step. */
+ private static final double Q_STEP = 0.1;
+ /** Minimum quality value. */
+ private static final double Q_MIN = 0.1;
+
+ @Override
+ public String buildAcceptHeader(final String[] produces) {
+ if (produces.length == 1) {
+ return produces[0];
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < produces.length; i++) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(produces[i]);
+ final double q = 1.0 - i * Q_STEP;
+ final double qClamped = Math.max(q, Q_MIN);
+ sb.append(";q=");
+ if (qClamped == 1.0) {
+ sb.append("1.0");
+ } else {
+ sb.append(String.format(Locale.ROOT, "%.1f", qClamped));
+ }
+ }
+ return sb.toString();
+ }
+ };
+
+ /**
+ * Strategy that lists all media types without quality values, giving them equal
+ * preference.
+ */
+ AcceptStrategy UNWEIGHTED = produces -> {
+ final StringBuilder sb = new StringBuilder();
+ for (final String type : produces) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(type);
+ }
+ return sb.toString();
+ };
+
+ /**
+ * Formats the given media types into an Accept header value.
+ *
+ * @param produces the media types from {@code @Produces}, never empty.
+ * @return the formatted Accept header value.
+ */
+ String buildAcceptHeader(String[] produces);
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java
new file mode 100644
index 0000000000..a4e04830f8
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java
@@ -0,0 +1,52 @@
+/*
+ * ====================================================================
+ * 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.InputStream;
+import java.lang.reflect.Type;
+
+/**
+ * Deserializes an HTTP response body into a Java object. Implementations handle a specific
+ * content type such as JSON or XML.
+ *
+ * @since 5.7
+ */
+public interface EntityReader {
+
+ /**
+ * Reads the response body and converts it to the requested type.
+ *
+ * @param stream the response body stream.
+ * @param rawType the raw return type of the interface method.
+ * @param genericType the full generic return type, which may be a
+ * {@link java.lang.reflect.ParameterizedType}.
+ * @return the deserialized object.
+ * @throws IOException if reading or deserialization fails.
+ */
+ Object read(InputStream stream, Class> rawType, Type genericType) throws IOException;
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java
new file mode 100644
index 0000000000..3f649d16ba
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java
@@ -0,0 +1,48 @@
+/*
+ * ====================================================================
+ * 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;
+
+/**
+ * Serializes a Java object into a byte array for use as an HTTP request body.
+ * Implementations handle a specific content type such as JSON or XML.
+ *
+ * @since 5.7
+ */
+public interface EntityWriter {
+
+ /**
+ * Serializes the given object into a byte array suitable for use as a request body.
+ *
+ * @param body the object to serialize.
+ * @param mediaType the target media type from the {@code @Consumes} annotation.
+ * @return the serialized bytes.
+ * @throws IOException if serialization fails.
+ */
+ byte[] write(Object body, String mediaType) throws IOException;
+}
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
new file mode 100644
index 0000000000..c192856f3c
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java
@@ -0,0 +1,257 @@
+/*
+ * ====================================================================
+ * 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.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.rest.impl.ClientResourceMethod;
+import org.apache.hc.client5.http.rest.impl.JacksonEntityReader;
+import org.apache.hc.client5.http.rest.impl.JacksonEntityWriter;
+import org.apache.hc.client5.http.rest.impl.RestInvocationHandler;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Builds type-safe REST client proxies from Jakarta REST annotated interfaces. The proxy
+ * translates each method call into an HTTP request executed through the Apache HttpClient
+ * async transport.
+ *
+ *
Both {@code baseUri} and {@code httpAsyncClient} are required. The caller owns the
+ * client lifecycle. A default Jackson {@link ObjectMapper} is created when none is
+ * provided. Custom {@link EntityReader} and {@link EntityWriter} implementations can
+ * replace Jackson for other serialization formats.
+ *
+ *
Methods that return {@code CompletableFuture} or {@code Future} execute
+ * asynchronously without blocking. Methods that return a plain type block until the
+ * response is available.
+ *
+ *
The proxy supports {@code @Path}, {@code @GET}, {@code @POST}, {@code @PUT},
+ * {@code @DELETE}, {@code @PathParam}, {@code @QueryParam}, {@code @HeaderParam},
+ * {@code @Produces}, {@code @Consumes} and {@code @DefaultValue} annotations. Generic
+ * return types such as {@code List} are fully supported.
+ *
+ * @since 5.7
+ */
+public final class RestClientBuilder {
+
+ /**
+ * Base URI for all requests made through the proxy.
+ */
+ private URI baseUri;
+ /**
+ * Async HTTP client used to execute requests.
+ */
+ private CloseableHttpAsyncClient httpAsyncClient;
+ /**
+ * Jackson object mapper for JSON serialization.
+ */
+ private ObjectMapper objectMapper;
+ /**
+ * Strategy for reading response entities.
+ */
+ private EntityReader entityReader;
+ /**
+ * Strategy for writing request entities.
+ */
+ private EntityWriter entityWriter;
+ /**
+ * Strategy for building Accept header values.
+ */
+ private AcceptStrategy acceptStrategy;
+
+ private RestClientBuilder() {
+ }
+
+ /**
+ * Creates a new builder instance.
+ *
+ * @return a fresh builder.
+ */
+ public static RestClientBuilder newBuilder() {
+ return new RestClientBuilder();
+ }
+
+ /**
+ * Sets the base URI for all requests.
+ *
+ * @param uri the base URI string, must not be {@code null}.
+ * @return this builder for chaining.
+ */
+ public RestClientBuilder baseUri(final String uri) {
+ Args.notBlank(uri, "Base URI");
+ this.baseUri = URI.create(uri);
+ return this;
+ }
+
+ /**
+ * Sets the base URI for all requests.
+ *
+ * @param uri the base URI, must not be {@code null}.
+ * @return this builder for chaining.
+ */
+ public RestClientBuilder baseUri(final URI uri) {
+ Args.notNull(uri, "Base URI");
+ this.baseUri = uri;
+ return this;
+ }
+
+ /**
+ * Sets the {@link CloseableHttpAsyncClient} to use for requests. The client must
+ * already be started. This is required; the builder does not create a default client
+ * because the caller must manage its lifecycle.
+ *
+ * @param client the async HTTP client, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder httpAsyncClient(final CloseableHttpAsyncClient client) {
+ Args.notNull(client, "Async HTTP client");
+ this.httpAsyncClient = client;
+ return this;
+ }
+
+ /**
+ * Sets the Jackson {@link ObjectMapper} for the default JSON entity reader and writer.
+ * Ignored when custom {@link EntityReader} and {@link EntityWriter} implementations
+ * are provided.
+ *
+ * @param mapper the object mapper, must not be {@code null}.
+ * @return this builder for chaining.
+ */
+ public RestClientBuilder objectMapper(final ObjectMapper mapper) {
+ Args.notNull(mapper, "Object mapper");
+ this.objectMapper = mapper;
+ return this;
+ }
+
+ /**
+ * Sets a custom entity reader for response deserialization. Overrides the default
+ * Jackson-based reader.
+ *
+ * @param reader the entity reader, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder entityReader(final EntityReader reader) {
+ Args.notNull(reader, "Entity reader");
+ this.entityReader = reader;
+ return this;
+ }
+
+ /**
+ * Sets a custom entity writer for request serialization. Overrides the default
+ * Jackson-based writer.
+ *
+ * @param writer the entity writer, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder entityWriter(final EntityWriter writer) {
+ Args.notNull(writer, "Entity writer");
+ this.entityWriter = writer;
+ return this;
+ }
+
+ /**
+ * Sets the strategy for building {@code Accept} header values from {@code @Produces}
+ * annotations. The default is {@link AcceptStrategy#QUALITY_DESCENDING} which assigns
+ * descending quality values.
+ *
+ * @param strategy the accept strategy, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder acceptStrategy(final AcceptStrategy strategy) {
+ Args.notNull(strategy, "Accept strategy");
+ this.acceptStrategy = strategy;
+ return this;
+ }
+
+ /**
+ * Scans the given interface for Jakarta REST annotations and creates a proxy that
+ * implements it by dispatching HTTP requests through the configured async client.
+ *
+ * @param the interface type.
+ * @param iface the Jakarta REST annotated interface class.
+ * @return a proxy implementing the interface.
+ * @throws IllegalArgumentException if the class is not an interface.
+ * @throws IllegalStateException if no base URI or client has been set, or if the
+ * interface has no Jakarta REST annotated methods.
+ */
+ @SuppressWarnings("unchecked")
+ public T build(final Class iface) {
+ Args.notNull(iface, "Interface class");
+ if (!iface.isInterface()) {
+ throw new IllegalArgumentException(iface.getName() + " is not an interface");
+ }
+ if (baseUri == null) {
+ throw new IllegalStateException("baseUri is required");
+ }
+ if (httpAsyncClient == null) {
+ throw new IllegalStateException("httpAsyncClient is required");
+ }
+ final CloseableHttpAsyncClient client = httpAsyncClient;
+ final ObjectMapper mapper = objectMapper != null ? objectMapper : new ObjectMapper();
+ final EntityReader reader = entityReader != null ? entityReader : new JacksonEntityReader(mapper);
+ final EntityWriter writer = entityWriter != null ? entityWriter : new JacksonEntityWriter(mapper);
+ final AcceptStrategy accept = acceptStrategy != null ? acceptStrategy : AcceptStrategy.QUALITY_DESCENDING;
+
+ final List methods = ClientResourceMethod.scan(iface);
+ if (methods.isEmpty()) {
+ throw new IllegalStateException("No Jakarta REST methods found on " + iface.getName());
+ }
+ final Map methodMap = new HashMap<>(methods.size());
+ for (final ClientResourceMethod rm : methods) {
+ methodMap.put(rm.getMethod(), rm);
+ }
+
+ return (T) Proxy.newProxyInstance(
+ iface.getClassLoader(),
+ new Class>[]{iface},
+ new RestInvocationHandler(client, baseUri, reader, writer, accept, methodMap));
+ }
+
+}
\ No newline at end of file
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java
new file mode 100644
index 0000000000..c9904eb633
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java
@@ -0,0 +1,436 @@
+/*
+ * ====================================================================
+ * 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.impl;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+
+/**
+ * Describes a single method on a Jakarta REST annotated client interface together with its
+ * HTTP method, URI template, content types and parameter extraction rules.
+ *
+ * @since 5.7
+ */
+public final class ClientResourceMethod {
+
+ enum ParamSource {
+ /**
+ * Bound via {@code @PathParam}.
+ */
+ PATH,
+ /**
+ * Bound via {@code @QueryParam}.
+ */
+ QUERY,
+ /**
+ * Bound via {@code @HeaderParam}.
+ */
+ HEADER,
+ /**
+ * Unannotated parameter treated as request body.
+ */
+ BODY
+ }
+
+ static final class ParamInfo {
+ /**
+ * Where this parameter is bound.
+ */
+ private final ParamSource source;
+ /**
+ * Annotation-declared parameter name.
+ */
+ private final String name;
+ /**
+ * Java type of the parameter.
+ */
+ private final Class> type;
+ /**
+ * Value from {@code @DefaultValue}, or null.
+ */
+ private final String defaultValue;
+
+ ParamInfo(final ParamSource paramSource, final String paramName, final Class> paramType, final String defValue) {
+ this.source = paramSource;
+ this.name = paramName;
+ this.type = paramType;
+ this.defaultValue = defValue;
+ }
+
+ ParamSource getSource() {
+ return source;
+ }
+
+ String getName() {
+ return name;
+ }
+
+ Class> getType() {
+ return type;
+ }
+
+ String getDefaultValue() {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * The Java method on the proxy interface.
+ */
+ private final Method method;
+ /**
+ * The generic return type including parameterized info.
+ */
+ private final Type genericReturnType;
+ /**
+ * The HTTP verb (GET, POST, PUT, DELETE, etc.).
+ */
+ private final String httpMethod;
+ /**
+ * The URI path template with regex constraints stripped.
+ */
+ private final String pathTemplate;
+ /**
+ * Media types from {@code @Produces}.
+ */
+ private final String[] produces;
+ /**
+ * Media types from {@code @Consumes}.
+ */
+ private final String[] consumes;
+ /**
+ * Extracted parameter bindings.
+ */
+ private final ParamInfo[] parameters;
+ /**
+ * Number of PATH parameters.
+ */
+ private final int pathParamCount;
+ /**
+ * Number of QUERY parameters.
+ */
+ private final int queryParamCount;
+ /**
+ * Number of HEADER parameters.
+ */
+ private final int headerParamCount;
+
+ ClientResourceMethod(final Method m, final String verb, final String path, final String[] prod,
+ final String[] cons, final ParamInfo[] params) {
+ this.method = m;
+ this.genericReturnType = m.getGenericReturnType();
+ this.httpMethod = verb;
+ this.pathTemplate = path;
+ this.produces = prod;
+ this.consumes = cons;
+ this.parameters = params;
+ int pathCount = 0;
+ int queryCount = 0;
+ int headerCount = 0;
+ for (final ParamInfo pi : params) {
+ switch (pi.getSource()) {
+ case PATH:
+ pathCount++;
+ break;
+ case QUERY:
+ queryCount++;
+ break;
+ case HEADER:
+ headerCount++;
+ break;
+ default:
+ break;
+ }
+ }
+ this.pathParamCount = pathCount;
+ this.queryParamCount = queryCount;
+ this.headerParamCount = headerCount;
+ }
+
+ /**
+ * Returns the Java method on the proxy interface.
+ *
+ * @return the reflected method.
+ */
+ public Method getMethod() {
+ return method;
+ }
+
+ Type getGenericReturnType() {
+ return genericReturnType;
+ }
+
+ String getHttpMethod() {
+ return httpMethod;
+ }
+
+ String getPathTemplate() {
+ return pathTemplate;
+ }
+
+ String[] getProduces() {
+ return produces;
+ }
+
+ String[] getConsumes() {
+ return consumes;
+ }
+
+ ParamInfo[] getParameters() {
+ return parameters;
+ }
+
+ int getPathParamCount() {
+ return pathParamCount;
+ }
+
+ int getQueryParamCount() {
+ return queryParamCount;
+ }
+
+ int getHeaderParamCount() {
+ return headerParamCount;
+ }
+
+ /**
+ * Scans a Jakarta REST annotated interface and returns descriptors for every method that
+ * carries an HTTP method annotation.
+ *
+ * @param iface the interface class to scan.
+ * @return the discovered client resource methods.
+ */
+ public static List scan(final Class> iface) {
+ final Path classPath = iface.getAnnotation(Path.class);
+ final String basePath = classPath != null ? classPath.value() : "";
+ final Produces classProduces = iface.getAnnotation(Produces.class);
+ final Consumes classConsumes = iface.getAnnotation(Consumes.class);
+
+ final List result = new ArrayList<>();
+ for (final Method m : iface.getMethods()) {
+ final String verb = resolveHttpMethod(m);
+ if (verb == null) {
+ continue;
+ }
+ final Path methodPath = m.getAnnotation(Path.class);
+ final String combinedPath = combinePaths(basePath, methodPath != null ? methodPath.value() : null);
+
+ final Produces mp = m.getAnnotation(Produces.class);
+ final String[] prod = mp != null ? mp.value()
+ : classProduces != null
+ ? classProduces.value()
+ : new String[]{
+ MediaType.APPLICATION_JSON};
+
+ final Consumes mc = m.getAnnotation(Consumes.class);
+ final String[] cons = mc != null
+ ? mc.value()
+ : classConsumes != null
+ ? classConsumes.value()
+ : new String[]{
+ MediaType.APPLICATION_JSON};
+
+ final ParamInfo[] params = scanParameters(m);
+ validatePathParams(m, combinedPath, params);
+ final String strippedPath = stripRegex(combinedPath);
+ result.add(new ClientResourceMethod(m, verb, strippedPath, prod, cons, params));
+ }
+ return result;
+ }
+
+ private static String resolveHttpMethod(final Method m) {
+ for (final Annotation a : m.getAnnotations()) {
+ final HttpMethod hm = a.annotationType().getAnnotation(HttpMethod.class);
+ if (hm != null) {
+ return hm.value();
+ }
+ }
+ return null;
+ }
+
+ private static ParamInfo[] scanParameters(final Method m) {
+ final Class>[] types = m.getParameterTypes();
+ final Annotation[][] annotations = m.getParameterAnnotations();
+ final ParamInfo[] result = new ParamInfo[types.length];
+ int bodyCount = 0;
+ for (int i = 0; i < types.length; i++) {
+ result[i] = resolveParam(types[i], annotations[i]);
+ if (result[i].getSource() == ParamSource.BODY) {
+ bodyCount++;
+ }
+ }
+ if (bodyCount > 1) {
+ throw new IllegalStateException("Method " + m.getName() + " has " + bodyCount + " unannotated (body) parameters;" + " at most one is allowed");
+ }
+ return result;
+ }
+
+ private static ParamInfo resolveParam(final Class> type, final Annotation[] annotations) {
+ String defVal = null;
+ for (final Annotation a : annotations) {
+ if (a instanceof DefaultValue) {
+ defVal = ((DefaultValue) a).value();
+ }
+ }
+ for (final Annotation a : annotations) {
+ if (a instanceof PathParam) {
+ return new ParamInfo(ParamSource.PATH, ((PathParam) a).value(), type, defVal);
+ }
+ if (a instanceof QueryParam) {
+ return new ParamInfo(ParamSource.QUERY, ((QueryParam) a).value(), type, defVal);
+ }
+ if (a instanceof HeaderParam) {
+ return new ParamInfo(ParamSource.HEADER, ((HeaderParam) a).value(), type, defVal);
+ }
+ }
+ return new ParamInfo(ParamSource.BODY, null, type, null);
+ }
+
+ /**
+ * Validates that every {@code @PathParam} name matches a template variable in the URI
+ * path, and that every template variable has a corresponding {@code @PathParam}.
+ *
+ * @param m the method being scanned.
+ * @param path the combined URI path template.
+ * @param params the scanned parameter bindings.
+ */
+ private static void validatePathParams(final Method m, final String path,
+ final ParamInfo[] params) {
+ final Set templateVars = extractTemplateVariables(path);
+ final Set paramNames = new LinkedHashSet<>();
+ for (final ParamInfo pi : params) {
+ if (pi.getSource() == ParamSource.PATH) {
+ paramNames.add(pi.getName());
+ }
+ }
+ for (final String name : paramNames) {
+ if (!templateVars.contains(name)) {
+ throw new IllegalStateException("Method " + m.getName()
+ + ": @PathParam(\"" + name + "\") has no matching {"
+ + name + "} in path \"" + path + "\"");
+ }
+ }
+ for (final String name : templateVars) {
+ if (!paramNames.contains(name)) {
+ throw new IllegalStateException("Method " + m.getName()
+ + ": path variable {" + name + "} has no matching"
+ + " @PathParam in path \"" + path + "\"");
+ }
+ }
+ }
+
+ /**
+ * Extracts variable names from a URI template, stripping any regex constraints
+ * (e.g. {@code {id:\d+}} yields {@code id}).
+ *
+ * @param template the URI path template.
+ * @return the set of variable names.
+ */
+ static Set extractTemplateVariables(final String template) {
+ final Set vars = new LinkedHashSet<>();
+ int i = 0;
+ while (i < template.length()) {
+ if (template.charAt(i) == '{') {
+ final int close = template.indexOf('}', i);
+ if (close < 0) {
+ break;
+ }
+ final String content = template.substring(i + 1, close);
+ final int colon = content.indexOf(':');
+ final String name = colon >= 0 ? content.substring(0, colon).trim() : content.trim();
+ if (!name.isEmpty()) {
+ vars.add(name);
+ }
+ i = close + 1;
+ } else {
+ i++;
+ }
+ }
+ return vars;
+ }
+
+ /**
+ * Strips regex constraints from URI template variables, e.g. {@code {id:\d+}} becomes
+ * {@code {id}}.
+ *
+ * @param template the URI template string.
+ * @return the template with regex constraints removed.
+ */
+ static String stripRegex(final String template) {
+ final StringBuilder sb = new StringBuilder(template.length());
+ int i = 0;
+ while (i < template.length()) {
+ final char c = template.charAt(i);
+ if (c == '{') {
+ final int close = template.indexOf('}', i);
+ if (close < 0) {
+ sb.append(c);
+ i++;
+ continue;
+ }
+ final String content = template.substring(i + 1, close);
+ final int colon = content.indexOf(':');
+ if (colon >= 0) {
+ sb.append('{');
+ sb.append(content, 0, colon);
+ sb.append('}');
+ } else {
+ sb.append(template, i, close + 1);
+ }
+ i = close + 1;
+ } else {
+ sb.append(c);
+ i++;
+ }
+ }
+ return sb.toString();
+ }
+
+ static String combinePaths(final String base, final String sub) {
+ if (sub == null || sub.isEmpty()) {
+ return base.isEmpty() ? "/" : base;
+ }
+ final String left = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
+ final String right = sub.startsWith("/") ? sub : "/" + sub;
+ return left + right;
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java
new file mode 100644
index 0000000000..f3d567cfb6
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java
@@ -0,0 +1,67 @@
+/*
+ * ====================================================================
+ * 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.impl;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Type;
+
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.hc.client5.http.rest.EntityReader;
+
+/**
+ * Jackson-based {@link EntityReader} that deserializes JSON
+ * response bodies using an {@link ObjectMapper}. Supports
+ * parameterized types such as {@code List}.
+ *
+ * @since 5.7
+ */
+public final class JacksonEntityReader implements EntityReader {
+
+ /**
+ * Jackson object mapper used for deserialization.
+ */
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a reader backed by the given mapper.
+ *
+ * @param mapper the Jackson object mapper.
+ */
+ public JacksonEntityReader(final ObjectMapper mapper) {
+ this.objectMapper = mapper;
+ }
+
+ @Override
+ public Object read(final InputStream stream, final Class> rawType, final Type genericType)
+ throws IOException {
+ final JavaType javaType = objectMapper.getTypeFactory().constructType(genericType);
+ return objectMapper.readValue(stream, javaType);
+ }
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java
new file mode 100644
index 0000000000..4f26036e1f
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java
@@ -0,0 +1,69 @@
+/*
+ * ====================================================================
+ * 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.impl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.hc.client5.http.rest.EntityWriter;
+
+/**
+ * Jackson-based {@link EntityWriter} that serializes request bodies to JSON
+ * using an {@link ObjectMapper}. Passes through {@code String} and
+ * {@code byte[]} bodies without conversion.
+ *
+ * @since 5.7
+ */
+public final class JacksonEntityWriter implements EntityWriter {
+
+ /**
+ * Jackson object mapper used for serialization.
+ */
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Creates a writer backed by the given mapper.
+ *
+ * @param mapper the Jackson object mapper.
+ */
+ public JacksonEntityWriter(final ObjectMapper mapper) {
+ this.objectMapper = mapper;
+ }
+
+ @Override
+ public byte[] write(final Object body, final String mediaType) throws IOException {
+ if (body instanceof byte[]) {
+ return (byte[]) body;
+ }
+ if (body instanceof String) {
+ return ((String) body).getBytes(StandardCharsets.UTF_8);
+ }
+ return objectMapper.writeValueAsBytes(body);
+ }
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java
new file mode 100644
index 0000000000..9d63281754
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java
@@ -0,0 +1,599 @@
+/*
+ * ====================================================================
+ * 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.impl;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+
+import jakarta.ws.rs.core.EntityTag;
+import jakarta.ws.rs.core.GenericType;
+import jakarta.ws.rs.core.HttpHeaders;
+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.rest.EntityReader;
+
+/**
+ * {@link Response} implementation that holds status, headers and a buffered entity without
+ * requiring a full Jakarta REST runtime. The entity is always stored as {@code byte[]} so the
+ * response is inherently buffered and repeatable.
+ *
+ *
Header-derived accessors ({@link #getMediaType()}, {@link #getLanguage()},
+ * {@link #getDate()}, {@link #getCookies()}, {@link #getLinks()}, etc.) parse the stored
+ * headers on each call.
+ *
+ *
The {@link #readEntity} methods support {@code byte[]} and {@code String} natively.
+ * For other types an {@link EntityReader} is used when one was provided at construction
+ * time.
+ *
+ * @since 5.7
+ */
+final class MinimalResponse extends Response {
+
+ /**
+ * RFC 1123 date format used in HTTP headers.
+ */
+ private static final String RFC_1123_PATTERN = "EEE, dd MMM yyyy HH:mm:ss zzz";
+
+ /**
+ * Length of the "path=" cookie attribute prefix.
+ */
+ private static final int PATH_PREFIX_LEN = 5;
+
+ /**
+ * Length of the "domain=" cookie attribute prefix.
+ */
+ private static final int DOMAIN_PREFIX_LEN = 7;
+
+ /**
+ * Length of the "max-age=" cookie attribute prefix.
+ */
+ private static final int MAX_AGE_PREFIX_LEN = 8;
+
+ /**
+ * The HTTP status code.
+ */
+ private final int status;
+ /**
+ * The response entity, typically {@code byte[]}.
+ */
+ private final Object entity;
+ /**
+ * The response headers.
+ */
+ private final MultivaluedMap headers;
+ /**
+ * The entity reader for deserialization, may be null.
+ */
+ private final EntityReader entityReader;
+
+ MinimalResponse(final int code, final Object ent, final MultivaluedMap hdrs, final EntityReader reader) {
+ this.status = code;
+ this.entity = ent;
+ this.headers = hdrs;
+ this.entityReader = reader;
+ }
+
+ @Override
+ public int getStatus() {
+ return status;
+ }
+
+ @Override
+ public StatusType getStatusInfo() {
+ return Status.fromStatusCode(status);
+ }
+
+ @Override
+ public Object getEntity() {
+ return entity;
+ }
+
+ @Override
+ public T readEntity(final Class type) {
+ return readEntityInternal(type, type);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T readEntity(final GenericType type) {
+ return (T) readEntityInternal(type.getRawType(), type.getType());
+ }
+
+ @Override
+ public T readEntity(final Class type, final Annotation[] annotations) {
+ return readEntityInternal(type, type);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T readEntity(final GenericType type, final Annotation[] annotations) {
+ return (T) readEntityInternal(type.getRawType(), type.getType());
+ }
+
+ @SuppressWarnings("unchecked")
+ private T readEntityInternal(final Class rawType, final Type genericType) {
+ if (entity == null) {
+ return null;
+ }
+ if (rawType.isInstance(entity)) {
+ return rawType.cast(entity);
+ }
+ if (rawType == String.class && entity instanceof byte[]) {
+ return (T) new String((byte[]) entity, resolveCharset());
+ }
+ if (rawType == byte[].class && entity instanceof String) {
+ return (T) ((String) entity).getBytes(resolveCharset());
+ }
+ if (entityReader != null && entity instanceof byte[]) {
+ try {
+ return (T) entityReader.read(new ByteArrayInputStream((byte[]) entity), rawType, genericType);
+ } catch (final IOException e) {
+ throw new IllegalStateException("Failed to read entity as " + rawType.getName(), e);
+ }
+ }
+ throw new IllegalStateException("Cannot read entity as " + rawType.getName()
+ + "; entity type is " + entity.getClass().getName());
+ }
+
+ @Override
+ public boolean hasEntity() {
+ return entity != null;
+ }
+
+ @Override
+ public boolean bufferEntity() {
+ return true;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public MediaType getMediaType() {
+ final String ct = firstHeaderString(HttpHeaders.CONTENT_TYPE);
+ if (ct == null) {
+ return null;
+ }
+ return parseMediaType(ct);
+ }
+
+ @Override
+ public Locale getLanguage() {
+ final String lang = firstHeaderString(HttpHeaders.CONTENT_LANGUAGE);
+ if (lang == null) {
+ return null;
+ }
+ return Locale.forLanguageTag(lang.trim());
+ }
+
+ @Override
+ public int getLength() {
+ final String cl = firstHeaderString(HttpHeaders.CONTENT_LENGTH);
+ if (cl == null) {
+ return -1;
+ }
+ try {
+ return Integer.parseInt(cl.trim());
+ } catch (final NumberFormatException ignored) {
+ return -1;
+ }
+ }
+
+ @Override
+ public Set getAllowedMethods() {
+ final String allow = firstHeaderString("Allow");
+ if (allow == null) {
+ return Collections.emptySet();
+ }
+ final Set methods = new LinkedHashSet<>();
+ for (final String m : allow.split(",")) {
+ final String trimmed = m.trim();
+ if (!trimmed.isEmpty()) {
+ methods.add(trimmed.toUpperCase(Locale.ROOT));
+ }
+ }
+ return Collections.unmodifiableSet(methods);
+ }
+
+ @Override
+ public Map getCookies() {
+ final List