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;
+ }
+
+ 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()) {
+ if (base.isEmpty()) {
+ return "/";
+ }
+ return base.startsWith("/") ? base : "/" + base;
+ }
+ final String left = base.endsWith("/")
+ ? base.substring(0, base.length() - 1) : base;
+ final String right = sub.startsWith("/") ? sub : "/" + sub;
+ final String combined = left + right;
+ return combined.startsWith("/") ? combined : "/" + combined;
+ }
+
+}
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..95d8978926
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java
@@ -0,0 +1,182 @@
+/*
+ * ====================================================================
+ * 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.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 async
+ * {@link CloseableHttpAsyncClient} transport, which supports both HTTP/1.1 and HTTP/2.
+ *
+ * Minimal usage:
+ *
+ * try (CloseableHttpAsyncClient client = HttpAsyncClients.createDefault()) {
+ * client.start();
+ * UserApi api = RestClientBuilder.newBuilder()
+ * .baseUri("... ")
+ * .httpClient(client)
+ * .build(UserApi.class);
+ * String json = api.getUser(42);
+ * }
+ *
+ *
+ * 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}.
+ *
+ * @since 5.7
+ */
+public final class RestClientBuilder {
+
+ private URI baseUri;
+ private CloseableHttpAsyncClient httpClient;
+ private ObjectMapper objectMapper;
+
+ 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 async HTTP client to use for requests. The caller owns the client
+ * lifecycle, including the call to {@link CloseableHttpAsyncClient#start()}.
+ *
+ * @param client the async HTTP client, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder httpClient(final CloseableHttpAsyncClient client) {
+ Args.notNull(client, "HTTP client");
+ this.httpClient = client;
+ return this;
+ }
+
+ /**
+ * Sets the Jackson {@link ObjectMapper} for JSON serialization and deserialization.
+ * If not set, a default ObjectMapper is used.
+ *
+ * @param mapper the object mapper, must not be {@code null}.
+ * @return this builder for chaining.
+ * @since 5.7
+ */
+ public RestClientBuilder objectMapper(final ObjectMapper mapper) {
+ Args.notNull(mapper, "Object mapper");
+ this.objectMapper = mapper;
+ 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 (httpClient == null) {
+ throw new IllegalStateException("httpClient is required");
+ }
+
+ 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);
+ }
+
+ final ObjectMapper mapper = objectMapper != null
+ ? objectMapper : new ObjectMapper();
+
+ return (T) Proxy.newProxyInstance(
+ iface.getClassLoader(),
+ new Class>[]{iface},
+ new RestInvocationHandler(httpClient, baseUri, methodMap, mapper));
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponseException.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponseException.java
new file mode 100644
index 0000000000..3f5863e0fb
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientResponseException.java
@@ -0,0 +1,66 @@
+/*
+ * ====================================================================
+ * 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;
+
+/**
+ * Signals a non-2xx HTTP response from a REST proxy method call.
+ *
+ * @since 5.7
+ */
+public final class RestClientResponseException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int statusCode;
+ private final byte[] responseBody;
+
+ RestClientResponseException(final int statusCode, final String reasonPhrase,
+ final byte[] responseBody) {
+ super("HTTP " + statusCode + (reasonPhrase != null ? " " + reasonPhrase : ""));
+ this.statusCode = statusCode;
+ this.responseBody = responseBody != null ? responseBody.clone() : null;
+ }
+
+ /**
+ * Returns the HTTP status code.
+ *
+ * @return the status code.
+ */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * Returns the response body bytes, or {@code null} if the response had no body.
+ *
+ * @return the response body.
+ */
+ public byte[] getResponseBody() {
+ return responseBody != null ? responseBody.clone() : null;
+ }
+
+}
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
new file mode 100644
index 0000000000..559273ee94
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
@@ -0,0 +1,417 @@
+/*
+ * ====================================================================
+ * 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.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+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.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.JsonObjectEntityProducer;
+import org.apache.hc.core5.jackson2.http.JsonResponseConsumers;
+import org.apache.hc.core5.net.URIBuilder;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * {@link InvocationHandler} that translates interface method calls into HTTP requests
+ * executed through the async {@link CloseableHttpAsyncClient} transport. Each method is
+ * mapped to its HTTP verb, URI template and parameter bindings at proxy creation time.
+ */
+final class RestInvocationHandler implements InvocationHandler {
+
+ private static final int ERROR_STATUS_THRESHOLD = 300;
+
+ private final CloseableHttpAsyncClient httpClient;
+ private final URI baseUri;
+ private final ObjectMapper objectMapper;
+ private final Map invokerMap;
+
+ RestInvocationHandler(final CloseableHttpAsyncClient client, final URI base,
+ final Map methods,
+ final ObjectMapper mapper) {
+ this.httpClient = client;
+ this.baseUri = base;
+ this.objectMapper = mapper;
+ this.invokerMap = buildInvokers(methods);
+ }
+
+ private static Map buildInvokers(
+ final Map methods) {
+ final Map result = new HashMap<>(methods.size());
+ for (final Map.Entry entry : methods.entrySet()) {
+ 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));
+ }
+ return result;
+ }
+
+ private static String joinMediaTypes(final String[] types) {
+ if (types.length == 1) {
+ return types[0];
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (final String type : types) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(type);
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public Object invoke(final Object proxy, final Method method,
+ final Object[] args) throws Throwable {
+ if (method.getDeclaringClass() == Object.class) {
+ return handleObjectMethod(proxy, method, args);
+ }
+ final MethodInvoker invoker = invokerMap.get(method);
+ if (invoker == null) {
+ throw new UnsupportedOperationException(
+ "No Jakarta REST mapping for " + method.getName());
+ }
+ return executeRequest(invoker, args);
+ }
+
+ private Object executeRequest(final MethodInvoker invoker,
+ final Object[] args) {
+ final ClientResourceMethod rm = invoker.resourceMethod;
+ final ClientResourceMethod.ParamInfo[] params = rm.getParameters();
+ final Map pathParams = rm.getPathParamCount() > 0
+ ? new LinkedHashMap<>(rm.getPathParamCount()) : Collections.emptyMap();
+ final Map> queryParams = rm.getQueryParamCount() > 0
+ ? new LinkedHashMap<>(rm.getQueryParamCount()) : Collections.emptyMap();
+ final Map headerParams = rm.getHeaderParamCount() > 0
+ ? new LinkedHashMap<>(rm.getHeaderParamCount()) : Collections.emptyMap();
+ Object bodyParam = null;
+
+ if (args != null) {
+ for (int i = 0; i < params.length; i++) {
+ final ClientResourceMethod.ParamInfo pi = params[i];
+ final Object val = args[i];
+ final String strVal = val != null ? paramToString(val) : pi.getDefaultValue();
+ switch (pi.getSource()) {
+ case PATH:
+ if (strVal == null) {
+ throw new IllegalArgumentException(
+ "Path parameter \"" + pi.getName()
+ + "\" must not be null");
+ }
+ pathParams.put(pi.getName(), strVal);
+ break;
+ case QUERY:
+ if (strVal != null) {
+ queryParams.computeIfAbsent(pi.getName(),
+ k -> new ArrayList<>()).add(strVal);
+ }
+ break;
+ case HEADER:
+ if (strVal != null) {
+ headerParams.put(pi.getName(), strVal);
+ }
+ break;
+ case BODY:
+ bodyParam = val;
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ final URI requestUri = buildRequestUri(rm.getPathTemplate(), pathParams, queryParams);
+ final BasicHttpRequest request =
+ new BasicHttpRequest(rm.getHttpMethod(), requestUri);
+
+ if (invoker.acceptHeader != null) {
+ request.addHeader(HttpHeaders.ACCEPT, invoker.acceptHeader);
+ }
+ for (final Map.Entry entry : headerParams.entrySet()) {
+ request.addHeader(entry.getKey(), entry.getValue());
+ }
+
+ final AsyncEntityProducer entityProducer;
+ if (bodyParam != null) {
+ entityProducer = createEntityProducer(bodyParam, invoker.consumeType);
+ } else {
+ entityProducer = null;
+ }
+
+ final Class> rawType = rm.getMethod().getReturnType();
+ final BasicRequestProducer requestProducer =
+ new BasicRequestProducer(request, entityProducer);
+ 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);
+ }
+ }
+
+ private URI buildRequestUri(final String pathTemplate,
+ final Map pathParams,
+ final Map> queryParams) {
+ try {
+ final URIBuilder uriBuilder = new URIBuilder(baseUri);
+ final String[] segments = expandPathSegments(pathTemplate, pathParams);
+ if (segments.length > 0) {
+ uriBuilder.appendPathSegments(segments);
+ }
+ for (final Map.Entry> entry : queryParams.entrySet()) {
+ for (final String value : entry.getValue()) {
+ uriBuilder.addParameter(entry.getKey(), value);
+ }
+ }
+ return uriBuilder.build();
+ } catch (final URISyntaxException ex) {
+ throw new IllegalStateException("Invalid URI: " + ex.getMessage(), ex);
+ }
+ }
+
+ /**
+ * Expands a path template by splitting it into segments, substituting template
+ * variables with raw values. Encoding is deferred to {@link URIBuilder}.
+ */
+ static String[] expandPathSegments(final String template,
+ final Map variables) {
+ if (template == null || template.isEmpty() || "/".equals(template)) {
+ return new String[0];
+ }
+ final String[] rawSegments = template.split("/");
+ final List result = new ArrayList<>(rawSegments.length);
+ for (final String segment : rawSegments) {
+ if (segment.isEmpty()) {
+ continue;
+ }
+ result.add(expandSegment(segment, variables));
+ }
+ return result.toArray(new String[0]);
+ }
+
+ /**
+ * Expands template variables within a single path segment.
+ */
+ static String expandSegment(final String segment,
+ final Map variables) {
+ if (segment.indexOf('{') < 0) {
+ return segment;
+ }
+ final StringBuilder sb = new StringBuilder(segment.length());
+ int i = 0;
+ while (i < segment.length()) {
+ final char c = segment.charAt(i);
+ if (c == '{') {
+ final int close = segment.indexOf('}', i);
+ if (close < 0) {
+ sb.append(segment, i, segment.length());
+ break;
+ }
+ final String name = segment.substring(i + 1, close);
+ final String value = variables.get(name);
+ if (value != null) {
+ sb.append(value);
+ } else {
+ sb.append(segment, i, close + 1);
+ }
+ i = close + 1;
+ } else {
+ sb.append(c);
+ i++;
+ }
+ }
+ return sb.toString();
+ }
+
+ private AsyncEntityProducer createEntityProducer(final Object body,
+ final ContentType consumeType) {
+ if (body instanceof byte[]) {
+ final ContentType ct = consumeType != null
+ ? consumeType : ContentType.APPLICATION_OCTET_STREAM;
+ return new BasicAsyncEntityProducer((byte[]) body, ct);
+ }
+ if (body instanceof String) {
+ final ContentType ct = consumeType != null
+ ? consumeType : ContentType.create("text/plain", StandardCharsets.UTF_8);
+ return new StringAsyncEntityProducer((CharSequence) body, ct);
+ }
+ return new JsonObjectEntityProducer<>(body, objectMapper, consumeType);
+ }
+
+ 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) {
+ throw new RestClientResponseException(
+ response.getCode(), response.getReasonPhrase(),
+ body != null && body.length > 0 ? body : null);
+ }
+ }
+
+ /**
+ * Throws {@link RestClientResponseException} if the message carries an error.
+ * Both {@link StringResponseConsumer} and the Jackson2 response consumer are
+ * configured with {@link BasicAsyncEntityConsumer} on the error path, so the
+ * error object is always {@code byte[]}.
+ */
+ private static void throwIfError(final Message result) {
+ final Object error = result.error();
+ if (error != null) {
+ if (!(error instanceof byte[])) {
+ throw new IllegalStateException(
+ "Expected byte[] error body, got "
+ + error.getClass().getName());
+ }
+ final HttpResponse head = result.getHead();
+ final byte[] errorBytes = (byte[]) error;
+ throw new RestClientResponseException(
+ head.getCode(), head.getReasonPhrase(),
+ errorBytes.length > 0 ? errorBytes : null);
+ }
+ }
+
+ /**
+ * Converts a parameter value to its string representation for use in URI
+ * path segments, query parameters or HTTP headers. Enums are converted using
+ * {@link Enum#name()} to ensure round-trip compatibility with {@code valueOf}.
+ */
+ static String paramToString(final Object value) {
+ Args.notNull(value, "Parameter value");
+ if (value instanceof Enum) {
+ return ((Enum>) value).name();
+ }
+ return value.toString();
+ }
+
+ private Object handleObjectMethod(final Object proxy, final Method method,
+ final Object[] args) {
+ final String name = method.getName();
+ if ("toString".equals(name)) {
+ return "RestProxy[" + baseUri + "]";
+ }
+ if ("hashCode".equals(name)) {
+ return System.identityHashCode(proxy);
+ }
+ if ("equals".equals(name)) {
+ return args[0] == proxy;
+ }
+ throw new UnsupportedOperationException(name);
+ }
+
+ static final class MethodInvoker {
+
+ final ClientResourceMethod resourceMethod;
+ final String acceptHeader;
+ final ContentType consumeType;
+
+ MethodInvoker(final ClientResourceMethod rm, final String accept,
+ final ContentType consume) {
+ this.resourceMethod = rm;
+ this.acceptHeader = accept;
+ this.consumeType = consume;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/StringResponseConsumer.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/StringResponseConsumer.java
new file mode 100644
index 0000000000..c568977700
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/StringResponseConsumer.java
@@ -0,0 +1,146 @@
+/*
+ * ====================================================================
+ * 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 org.apache.hc.core5.concurrent.CallbackContribution;
+import org.apache.hc.core5.concurrent.FutureCallback;
+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.HttpStatus;
+import org.apache.hc.core5.http.Message;
+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.BasicAsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.entity.StringAsyncEntityConsumer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * Response consumer for {@code String} return types that routes success and error
+ * responses to different entity consumers. Successful responses are decoded to
+ * {@code String} using the response charset via {@link StringAsyncEntityConsumer}.
+ * Error responses are consumed as raw bytes via {@link BasicAsyncEntityConsumer}
+ * and stored in {@link Message#error()}.
+ */
+final class StringResponseConsumer
+ implements AsyncResponseConsumer> {
+
+ private AsyncEntityConsumer> entityConsumer;
+
+ @Override
+ public void consumeResponse(final HttpResponse response,
+ final EntityDetails entityDetails,
+ final HttpContext context,
+ final FutureCallback> resultCallback)
+ throws HttpException, IOException {
+ if (entityDetails == null) {
+ if (response.getCode() >= HttpStatus.SC_REDIRECTION) {
+ resultCallback.completed(Message.error(response, null));
+ } else {
+ resultCallback.completed(Message.of(response, (String) null));
+ }
+ return;
+ }
+ if (response.getCode() >= HttpStatus.SC_REDIRECTION) {
+ final BasicAsyncEntityConsumer consumer = new BasicAsyncEntityConsumer();
+ this.entityConsumer = consumer;
+ consumer.streamStart(entityDetails,
+ new CallbackContribution(resultCallback) {
+
+ @Override
+ public void completed(final byte[] bytes) {
+ resultCallback.completed(Message.error(response, bytes));
+ }
+
+ });
+ } else {
+ final StringAsyncEntityConsumer consumer = new StringAsyncEntityConsumer();
+ this.entityConsumer = consumer;
+ consumer.streamStart(entityDetails,
+ new CallbackContribution(resultCallback) {
+
+ @Override
+ public void completed(final String body) {
+ resultCallback.completed(Message.of(response, body));
+ }
+
+ });
+ }
+ }
+
+ @Override
+ public void informationResponse(final HttpResponse response,
+ final HttpContext context)
+ throws HttpException, IOException {
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel)
+ throws IOException {
+ if (entityConsumer != null) {
+ entityConsumer.updateCapacity(capacityChannel);
+ }
+ }
+
+ @Override
+ public void consume(final ByteBuffer data) throws IOException {
+ if (entityConsumer != null) {
+ entityConsumer.consume(data);
+ }
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers)
+ throws HttpException, IOException {
+ if (entityConsumer != null) {
+ entityConsumer.streamEnd(trailers);
+ }
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ if (entityConsumer != null) {
+ entityConsumer.failed(cause);
+ }
+ releaseResources();
+ }
+
+ @Override
+ public void releaseResources() {
+ if (entityConsumer != null) {
+ entityConsumer.releaseResources();
+ entityConsumer = null;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/package-info.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/package-info.java
new file mode 100644
index 0000000000..b56fb128bc
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * ====================================================================
+ * 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
+ * .
+ *
+ */
+
+/**
+ * Type-safe REST client that generates implementations from Jakarta REST annotated
+ * interfaces, backed by the async Apache HttpClient transport.
+ */
+package org.apache.hc.client5.http.rest;
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java
new file mode 100644
index 0000000000..a4e257372a
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java
@@ -0,0 +1,143 @@
+/*
+ * ====================================================================
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+class ClientResourceMethodTest {
+
+ // --- combinePaths ---
+
+ @Test
+ void testCombineBaseAndSub() {
+ assertEquals("/api/users",
+ ClientResourceMethod.combinePaths("/api", "/users"));
+ }
+
+ @Test
+ void testCombineTrailingSlash() {
+ assertEquals("/api/users",
+ ClientResourceMethod.combinePaths("/api/", "/users"));
+ }
+
+ @Test
+ void testCombineSubWithoutLeadingSlash() {
+ assertEquals("/api/users",
+ ClientResourceMethod.combinePaths("/api", "users"));
+ }
+
+ @Test
+ void testCombineBaseWithoutLeadingSlash() {
+ assertEquals("/widgets",
+ ClientResourceMethod.combinePaths("widgets", null));
+ }
+
+ @Test
+ void testCombineBothWithoutLeadingSlash() {
+ assertEquals("/widgets/items",
+ ClientResourceMethod.combinePaths("widgets", "items"));
+ }
+
+ @Test
+ void testCombineBaseWithoutSlashSubWithSlash() {
+ assertEquals("/widgets/items",
+ ClientResourceMethod.combinePaths("widgets", "/items"));
+ }
+
+ @Test
+ void testCombineEmptyBase() {
+ assertEquals("/users",
+ ClientResourceMethod.combinePaths("", "/users"));
+ }
+
+ @Test
+ void testCombineNullSub() {
+ assertEquals("/api",
+ ClientResourceMethod.combinePaths("/api", null));
+ }
+
+ @Test
+ void testCombineBothEmpty() {
+ assertEquals("/",
+ ClientResourceMethod.combinePaths("", null));
+ }
+
+ // --- stripRegex ---
+
+ @Test
+ void testStripRegexSimple() {
+ assertEquals("{id}",
+ ClientResourceMethod.stripRegex("{id:\\d+}"));
+ }
+
+ @Test
+ void testStripRegexNoRegex() {
+ assertEquals("{id}",
+ ClientResourceMethod.stripRegex("{id}"));
+ }
+
+ @Test
+ void testStripRegexMultipleVars() {
+ assertEquals("/{group}/{id}",
+ ClientResourceMethod.stripRegex("/{group:\\w+}/{id:\\d+}"));
+ }
+
+ // --- extractTemplateVariables ---
+
+ @Test
+ void testExtractSingleVar() {
+ final Set vars =
+ ClientResourceMethod.extractTemplateVariables("/items/{id}");
+ assertEquals(Set.of("id"), vars);
+ }
+
+ @Test
+ void testExtractMultipleVars() {
+ final Set vars =
+ ClientResourceMethod.extractTemplateVariables("/{group}/{id}");
+ assertEquals(Set.of("group", "id"), vars);
+ }
+
+ @Test
+ void testExtractVarWithRegex() {
+ final Set vars =
+ ClientResourceMethod.extractTemplateVariables("/items/{id:\\d+}");
+ assertEquals(Set.of("id"), vars);
+ }
+
+ @Test
+ void testExtractNoVars() {
+ assertTrue(ClientResourceMethod
+ .extractTemplateVariables("/plain/path").isEmpty());
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java
new file mode 100644
index 0000000000..eae63b3ea9
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java
@@ -0,0 +1,782 @@
+/*
+ * ====================================================================
+ * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+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;
+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.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class RestClientBuilderTest {
+
+ static HttpServer server;
+ static int port;
+ static CloseableHttpAsyncClient httpClient;
+
+ // --- Test interfaces ---
+
+ @Path("/widgets")
+ @Produces(MediaType.APPLICATION_JSON)
+ public interface WidgetApi {
+
+ @GET
+ String list();
+
+ @GET
+ @Path("/{id}")
+ String get(@PathParam("id") int id);
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ String create(String body);
+
+ @PUT
+ @Path("/{id}")
+ @Consumes(MediaType.APPLICATION_JSON)
+ String update(@PathParam("id") int id, String body);
+
+ @DELETE
+ @Path("/{id}")
+ void delete(@PathParam("id") int id);
+ }
+
+ @Path("/echo")
+ public interface EchoApi {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ String echo(@QueryParam("msg") String msg);
+
+ @GET
+ @Path("/multi")
+ @Produces(MediaType.TEXT_PLAIN)
+ String echoMulti(@QueryParam("tag") String tag1,
+ @QueryParam("tag") String tag2);
+
+ @GET
+ @Path("/header")
+ @Produces(MediaType.TEXT_PLAIN)
+ String echoHeader(@HeaderParam("X-Tag") String tag);
+ }
+
+ @Path("/status")
+ public interface StatusApi {
+
+ @GET
+ @Path("/{code}")
+ @Produces(MediaType.TEXT_PLAIN)
+ String getStatus(@PathParam("code") int code);
+ }
+
+ @Path("/echopath")
+ public interface EchoPathApi {
+
+ @GET
+ @Path("/{value}")
+ @Produces(MediaType.TEXT_PLAIN)
+ String echoPath(@PathParam("value") String value);
+ }
+
+ @Path("/defaults")
+ public interface DefaultsApi {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ String withDefault(
+ @QueryParam("color") @DefaultValue("red") String color);
+ }
+
+ @Path("/bytes")
+ public interface BytesApi {
+
+ @GET
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ byte[] getBytes();
+ }
+
+ @Path("/inspect")
+ public interface InspectApi {
+
+ @POST
+ @Path("/string")
+ @Produces(MediaType.TEXT_PLAIN)
+ String postString(String body);
+
+ @POST
+ @Path("/bytes")
+ @Produces(MediaType.TEXT_PLAIN)
+ String postBytes(byte[] body);
+ }
+
+ @Path("/echobody")
+ public interface EchoBodyApi {
+
+ @POST
+ @Consumes("text/plain; charset=ISO-8859-1")
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ byte[] post(String body);
+ }
+
+ @Path("/noproduce")
+ public interface NoProduceApi {
+
+ @GET
+ String get();
+ }
+
+ @Path("/charset")
+ public interface CharsetApi {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ String get();
+ }
+
+ public static class Widget {
+
+ public int id;
+ public String name;
+
+ public Widget() {
+ }
+
+ public Widget(final int id, final String name) {
+ this.id = id;
+ this.name = name;
+ }
+ }
+
+ @Path("/json")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Consumes(MediaType.APPLICATION_JSON)
+ public interface JsonWidgetApi {
+
+ @GET
+ @Path("/{id}")
+ Widget get(@PathParam("id") int id);
+
+ @POST
+ Widget create(Widget widget);
+ }
+
+ @Path("/jsonerror")
+ @Produces(MediaType.APPLICATION_JSON)
+ public interface JsonErrorApi {
+
+ @GET
+ @Path("/{code}")
+ Widget getError(@PathParam("code") int code);
+ }
+
+ @Path("/inspect")
+ public interface InspectJsonApi {
+
+ @POST
+ @Path("/json")
+ @Consumes("application/vnd.api+json")
+ @Produces(MediaType.TEXT_PLAIN)
+ String postJson(Widget widget);
+ }
+
+ @Path("/charstatus")
+ public interface CharStatusApi {
+
+ @GET
+ @Path("/{code}")
+ @Produces(MediaType.TEXT_PLAIN)
+ String getStatus(@PathParam("code") int code);
+ }
+
+ public enum Color { RED, GREEN, BLUE }
+
+ @Path("/echo")
+ public interface EnumQueryApi {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ String echo(@QueryParam("msg") Color color);
+ }
+
+ @Path("/bad")
+ public interface BadMultiBodyApi {
+
+ @POST
+ String post(String body1, String body2);
+ }
+
+ @Path("/mismatch")
+ public interface BadPathParamApi {
+
+ @GET
+ @Path("/{id}")
+ String get(@PathParam("userId") int id);
+ }
+
+ @Path("/missing")
+ public interface MissingPathParamApi {
+
+ @GET
+ @Path("/{id}/{version}")
+ String get(@PathParam("id") int id);
+ }
+
+ @Path("/multi")
+ public interface MultiConsumesBodyApi {
+
+ @POST
+ @Consumes({"application/json", "application/xml"})
+ String post(String body);
+ }
+
+ // --- Server setup ---
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ server = ServerBootstrap.bootstrap()
+ .setCanonicalHostName("localhost")
+ .register("/widgets", (request, response, context) -> {
+ final String method = request.getMethod();
+ if ("GET".equals(method)) {
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ "[{\"id\":1,\"name\":\"A\"},"
+ + "{\"id\":2,\"name\":\"B\"}]",
+ ContentType.APPLICATION_JSON));
+ } else if ("POST".equals(method)) {
+ response.setCode(201);
+ response.setEntity(new StringEntity(
+ "{\"id\":99,\"name\":\"Created\"}",
+ ContentType.APPLICATION_JSON));
+ }
+ })
+ .register("/widgets/*", (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final String idStr =
+ path.substring(path.lastIndexOf('/') + 1);
+ final String method = request.getMethod();
+ if ("GET".equals(method)) {
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ "{\"id\":" + idStr
+ + ",\"name\":\"W-" + idStr + "\"}",
+ ContentType.APPLICATION_JSON));
+ } else if ("PUT".equals(method)) {
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ "{\"id\":" + idStr
+ + ",\"name\":\"Updated\"}",
+ ContentType.APPLICATION_JSON));
+ } else if ("DELETE".equals(method)) {
+ response.setCode(204);
+ }
+ })
+ .register("/echo", (request, response, context) -> {
+ final String uri = request.getRequestUri();
+ final int qi = uri.indexOf("msg=");
+ final String msg = qi >= 0 ? uri.substring(qi + 4) : "";
+ response.setCode(200);
+ response.setEntity(
+ new StringEntity(msg, ContentType.TEXT_PLAIN));
+ })
+ .register("/echo/multi",
+ (request, response, context) -> {
+ final String uri = request.getRequestUri();
+ final int qi = uri.indexOf('?');
+ final String query = qi >= 0
+ ? uri.substring(qi + 1) : "";
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ query, ContentType.TEXT_PLAIN));
+ })
+ .register("/echo/header",
+ (request, response, context) -> {
+ final String tag =
+ request.getFirstHeader("X-Tag") != null
+ ? request.getFirstHeader("X-Tag")
+ .getValue()
+ : "none";
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ tag, ContentType.TEXT_PLAIN));
+ })
+ .register("/echopath/*",
+ (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final String raw = path.substring(
+ path.lastIndexOf('/') + 1);
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ raw, ContentType.TEXT_PLAIN));
+ })
+ .register("/defaults",
+ (request, response, context) -> {
+ final String uri = request.getRequestUri();
+ final int qi = uri.indexOf("color=");
+ final String color = qi >= 0
+ ? uri.substring(qi + 6) : "none";
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ color, ContentType.TEXT_PLAIN));
+ })
+ .register("/inspect/*",
+ (request, response, context) -> {
+ final String ct =
+ request.getFirstHeader("Content-Type")
+ != null
+ ? request.getFirstHeader("Content-Type")
+ .getValue()
+ : "none";
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ ct, ContentType.TEXT_PLAIN));
+ })
+ .register("/echobody",
+ (request, response, context) -> {
+ final byte[] body =
+ EntityUtils.toByteArray(request.getEntity());
+ response.setCode(200);
+ response.setEntity(new ByteArrayEntity(body,
+ ContentType.APPLICATION_OCTET_STREAM));
+ })
+ .register("/noproduce",
+ (request, response, context) -> {
+ final String accept =
+ request.getFirstHeader("Accept") != null
+ ? request.getFirstHeader("Accept")
+ .getValue()
+ : "none";
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ accept, ContentType.TEXT_PLAIN));
+ })
+ .register("/bytes",
+ (request, response, context) -> {
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ "binary-data",
+ ContentType.APPLICATION_OCTET_STREAM));
+ })
+ .register("/json",
+ (request, response, context) -> {
+ final String body =
+ EntityUtils.toString(request.getEntity());
+ response.setCode(201);
+ response.setEntity(new StringEntity(
+ body, ContentType.APPLICATION_JSON));
+ })
+ .register("/json/*",
+ (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final String idStr = path.substring(
+ path.lastIndexOf('/') + 1);
+ response.setCode(200);
+ response.setEntity(new StringEntity(
+ "{\"id\":" + idStr
+ + ",\"name\":\"W-" + idStr + "\"}",
+ ContentType.APPLICATION_JSON));
+ })
+ .register("/jsonerror/*",
+ (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final int code = Integer.parseInt(
+ path.substring(
+ path.lastIndexOf('/') + 1));
+ response.setCode(code);
+ response.setEntity(new StringEntity(
+ "{\"error\":\"status " + code + "\"}",
+ ContentType.APPLICATION_JSON));
+ })
+ .register("/charset",
+ (request, response, context) -> {
+ final byte[] bytes = "caf\u00e9"
+ .getBytes(StandardCharsets.ISO_8859_1);
+ response.setCode(200);
+ response.setEntity(new ByteArrayEntity(bytes,
+ ContentType.create("text/plain",
+ "ISO-8859-1")));
+ })
+ .register("/charstatus/*",
+ (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final int code = Integer.parseInt(
+ path.substring(
+ path.lastIndexOf('/') + 1));
+ final byte[] bytes = "caf\u00e9"
+ .getBytes(StandardCharsets.ISO_8859_1);
+ response.setCode(code);
+ response.setEntity(new ByteArrayEntity(bytes,
+ ContentType.create("text/plain",
+ "ISO-8859-1")));
+ })
+ .register("/status/*",
+ (request, response, context) -> {
+ final String path = request.getRequestUri();
+ final int code = Integer.parseInt(path.substring(
+ path.lastIndexOf('/') + 1));
+ response.setCode(code);
+ response.setEntity(new StringEntity(
+ "status:" + code,
+ ContentType.TEXT_PLAIN));
+ })
+ .create();
+ server.start();
+ port = server.getLocalPort();
+ httpClient = HttpAsyncClients.createDefault();
+ httpClient.start();
+ }
+
+ @AfterAll
+ static void tearDown() throws Exception {
+ if (httpClient != null) {
+ httpClient.close();
+ }
+ if (server != null) {
+ server.close();
+ }
+ }
+
+ private T proxy(final Class iface) {
+ return RestClientBuilder.newBuilder()
+ .baseUri("http://localhost:" + port)
+ .httpClient(httpClient)
+ .build(iface);
+ }
+
+ // --- Tests ---
+
+ @Test
+ void testGetSingleWidget() {
+ final WidgetApi api = proxy(WidgetApi.class);
+ final String json = api.get(42);
+ assertTrue(json.contains("\"id\":42"));
+ assertTrue(json.contains("\"name\":\"W-42\""));
+ }
+
+ @Test
+ void testGetWidgetList() {
+ final WidgetApi api = proxy(WidgetApi.class);
+ final String json = api.list();
+ assertTrue(json.contains("\"name\":\"A\""));
+ assertTrue(json.contains("\"name\":\"B\""));
+ }
+
+ @Test
+ void testPostWidget() {
+ final WidgetApi api = proxy(WidgetApi.class);
+ final String json = api.create("{\"id\":0,\"name\":\"New\"}");
+ assertTrue(json.contains("\"id\":99"));
+ assertTrue(json.contains("\"name\":\"Created\""));
+ }
+
+ @Test
+ void testPutWidget() {
+ final WidgetApi api = proxy(WidgetApi.class);
+ final String json = api.update(7, "{\"id\":0,\"name\":\"Up\"}");
+ assertTrue(json.contains("\"id\":7"));
+ assertTrue(json.contains("\"name\":\"Updated\""));
+ }
+
+ @Test
+ void testDeleteWidget() {
+ final WidgetApi api = proxy(WidgetApi.class);
+ api.delete(1);
+ }
+
+ @Test
+ void testQueryParam() {
+ final EchoApi api = proxy(EchoApi.class);
+ assertEquals("hello", api.echo("hello"));
+ }
+
+ @Test
+ void testMultiValueQueryParam() {
+ final EchoApi api = proxy(EchoApi.class);
+ final String result = api.echoMulti("alpha", "beta");
+ assertTrue(result.contains("tag=alpha"));
+ assertTrue(result.contains("tag=beta"));
+ }
+
+ @Test
+ void testHeaderParam() {
+ final EchoApi api = proxy(EchoApi.class);
+ assertEquals("myTag", api.echoHeader("myTag"));
+ }
+
+ @Test
+ void testErrorThrowsException() {
+ final StatusApi api = proxy(StatusApi.class);
+ final RestClientResponseException ex = assertThrows(
+ RestClientResponseException.class,
+ () -> api.getStatus(404));
+ assertEquals(404, ex.getStatusCode());
+ }
+
+ @Test
+ void testPathParamEncoding() {
+ final EchoPathApi api = proxy(EchoPathApi.class);
+ assertEquals("hello%20world", api.echoPath("hello world"));
+ assertEquals("~user", api.echoPath("~user"));
+ }
+
+ @Test
+ void testDefaultValue() {
+ final DefaultsApi api = proxy(DefaultsApi.class);
+ assertEquals("red", api.withDefault(null));
+ assertEquals("blue", api.withDefault("blue"));
+ }
+
+ @Test
+ void testByteArrayReturn() {
+ final BytesApi api = proxy(BytesApi.class);
+ final byte[] data = api.getBytes();
+ assertEquals("binary-data", new String(data,
+ StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void testProxyToString() {
+ final EchoApi api = proxy(EchoApi.class);
+ assertTrue(api.toString().startsWith("RestProxy["));
+ }
+
+ @Test
+ void testInferredContentTypeForStringBody() {
+ final InspectApi api = proxy(InspectApi.class);
+ final String ct = api.postString("hello");
+ assertTrue(ct.startsWith("text/plain"), ct);
+ }
+
+ @Test
+ void testInferredContentTypeForByteArrayBody() {
+ final InspectApi api = proxy(InspectApi.class);
+ final String ct = api.postBytes(new byte[]{1, 2, 3});
+ assertTrue(ct.startsWith("application/octet-stream"), ct);
+ }
+
+ @Test
+ void testNoAcceptHeaderWithoutProduces() {
+ final NoProduceApi api = proxy(NoProduceApi.class);
+ assertEquals("none", api.get());
+ }
+
+ @Test
+ void testJsonPojoGet() {
+ final JsonWidgetApi api = proxy(JsonWidgetApi.class);
+ final Widget w = api.get(42);
+ assertEquals(42, w.id);
+ assertEquals("W-42", w.name);
+ }
+
+ @Test
+ void testJsonPojoPostRoundTrip() {
+ final JsonWidgetApi api = proxy(JsonWidgetApi.class);
+ final Widget w = api.create(new Widget(0, "New"));
+ assertEquals(0, w.id);
+ assertEquals("New", w.name);
+ }
+
+ @Test
+ void testJsonPojoErrorResponse() {
+ final JsonErrorApi api = proxy(JsonErrorApi.class);
+ final RestClientResponseException ex = assertThrows(
+ RestClientResponseException.class,
+ () -> api.getError(500));
+ assertEquals(500, ex.getStatusCode());
+ assertNotNull(ex.getResponseBody());
+ }
+
+ @Test
+ void testPojoBodyHonorsConsumesMediaType() {
+ final InspectJsonApi api = proxy(InspectJsonApi.class);
+ final String ct = api.postJson(new Widget(1, "test"));
+ assertTrue(ct.startsWith("application/vnd.api+json"), ct);
+ }
+
+ @Test
+ void testNonUtf8ErrorResponsePreservesBytes() {
+ final CharStatusApi api = proxy(CharStatusApi.class);
+ final RestClientResponseException ex = assertThrows(
+ RestClientResponseException.class,
+ () -> api.getStatus(500));
+ assertEquals(500, ex.getStatusCode());
+ assertArrayEquals(
+ "caf\u00e9".getBytes(StandardCharsets.ISO_8859_1),
+ ex.getResponseBody());
+ }
+
+ // --- Validation tests ---
+
+ @Test
+ void testRejectsNonInterface() {
+ assertThrows(IllegalArgumentException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .httpClient(httpClient)
+ .build(String.class));
+ }
+
+ @Test
+ void testRequiresBaseUri() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .httpClient(httpClient)
+ .build(EchoApi.class));
+ }
+
+ @Test
+ void testRequiresHttpClient() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .build(EchoApi.class));
+ }
+
+ @Test
+ void testRejectsMultipleBodyParams() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .httpClient(httpClient)
+ .build(BadMultiBodyApi.class));
+ }
+
+ @Test
+ void testRejectsPathParamMismatch() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .httpClient(httpClient)
+ .build(BadPathParamApi.class));
+ }
+
+ @Test
+ void testRejectsMissingPathParam() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .httpClient(httpClient)
+ .build(MissingPathParamApi.class));
+ }
+
+ @Test
+ void testRejectsMultipleConsumesWithBody() {
+ assertThrows(IllegalStateException.class, () ->
+ RestClientBuilder.newBuilder()
+ .baseUri("http://localhost")
+ .httpClient(httpClient)
+ .build(MultiConsumesBodyApi.class));
+ }
+
+ @Test
+ void testNullPathParamThrows() {
+ final EchoPathApi api = proxy(EchoPathApi.class);
+ assertThrows(IllegalArgumentException.class,
+ () -> api.echoPath(null));
+ }
+
+ @Test
+ void testQueryParamSpaceEncodedAsPercent20() {
+ final EchoApi api = proxy(EchoApi.class);
+ final String result = api.echo("hello world");
+ assertEquals("hello%20world", result);
+ }
+
+ @Test
+ void testQueryParamLiteralPlusEncodedAsPercent2B() {
+ final EchoApi api = proxy(EchoApi.class);
+ final String result = api.echo("a+b");
+ assertEquals("a%2Bb", result);
+ }
+
+ @Test
+ void testStringResponseRespectsEntityCharset() {
+ final CharsetApi api = proxy(CharsetApi.class);
+ assertEquals("caf\u00e9", api.get());
+ }
+
+ @Test
+ void testResponseExceptionDefensiveCopy() {
+ final byte[] original = {1, 2, 3};
+ final RestClientResponseException ex =
+ new RestClientResponseException(500, "error", original);
+ original[0] = 99;
+ final byte[] body = ex.getResponseBody();
+ assertEquals(1, body[0]);
+ body[1] = 99;
+ assertEquals(2, ex.getResponseBody()[1]);
+ }
+
+ @Test
+ void testStringBodyEncodedWithConsumesCharset() {
+ final EchoBodyApi api = proxy(EchoBodyApi.class);
+ final byte[] result = api.post("caf\u00e9");
+ assertArrayEquals(
+ "caf\u00e9".getBytes(StandardCharsets.ISO_8859_1), result);
+ }
+
+ @Test
+ void testResponseExceptionNullBody() {
+ final RestClientResponseException ex =
+ new RestClientResponseException(204, "No Content", null);
+ assertNull(ex.getResponseBody());
+ }
+
+ @Test
+ void testEnumQueryParamUsesName() {
+ final EnumQueryApi api = proxy(EnumQueryApi.class);
+ assertEquals("GREEN", api.echo(Color.GREEN));
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientH2Test.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientH2Test.java
new file mode 100644
index 0000000000..89cd1b7950
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientH2Test.java
@@ -0,0 +1,329 @@
+/*
+ * ====================================================================
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+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;
+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.EntityDetails;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.Message;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.http.nio.AsyncRequestConsumer;
+import org.apache.hc.core5.http.nio.AsyncServerRequestHandler;
+import org.apache.hc.core5.http.nio.entity.DiscardingEntityConsumer;
+import org.apache.hc.core5.http.nio.entity.StringAsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.support.BasicRequestConsumer;
+import org.apache.hc.core5.http.nio.support.BasicResponseProducer;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.ListenerEndpoint;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that the REST client proxy operates correctly over cleartext HTTP/2 (h2c).
+ */
+class RestClientH2Test {
+
+ static HttpAsyncServer h2Server;
+ static int h2Port;
+ static CloseableHttpAsyncClient h2Client;
+
+ // --- Test interfaces ---
+
+ @Path("/echo")
+ public interface EchoApi {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ String echo(@QueryParam("msg") String msg);
+ }
+
+ public static class Widget {
+
+ public int id;
+ public String name;
+
+ public Widget() {
+ }
+
+ public Widget(final int id, final String name) {
+ this.id = id;
+ this.name = name;
+ }
+ }
+
+ @Path("/json")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Consumes(MediaType.APPLICATION_JSON)
+ public interface JsonWidgetApi {
+
+ @GET
+ @Path("/{id}")
+ Widget get(@PathParam("id") int id);
+
+ @POST
+ Widget create(Widget widget);
+ }
+
+ @Path("/jsonerror")
+ @Produces(MediaType.APPLICATION_JSON)
+ public interface JsonErrorApi {
+
+ @GET
+ @Path("/{code}")
+ Widget getError(@PathParam("code") int code);
+ }
+
+ @Path("/widgets")
+ public interface VoidApi {
+
+ @DELETE
+ @Path("/{id}")
+ void delete(@PathParam("id") int id);
+ }
+
+ // --- Server setup ---
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ h2Server = H2ServerBootstrap.bootstrap()
+ .setCanonicalHostName("localhost")
+ .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_2)
+ .register("/echo", echoHandler())
+ .register("/json/*", jsonGetHandler())
+ .register("/json", jsonPostHandler())
+ .register("/jsonerror/*", jsonErrorHandler())
+ .register("/widgets/*", voidHandler())
+ .create();
+ h2Server.start();
+ final ListenerEndpoint endpoint = h2Server.listen(
+ new InetSocketAddress(InetAddress.getLoopbackAddress(), 0),
+ URIScheme.HTTP).get();
+ h2Port = ((InetSocketAddress) endpoint.getAddress()).getPort();
+
+ h2Client = HttpAsyncClients.createHttp2Default();
+ h2Client.start();
+ }
+
+ @AfterAll
+ static void tearDown() throws Exception {
+ if (h2Client != null) {
+ h2Client.close();
+ }
+ if (h2Server != null) {
+ h2Server.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ private T proxy(final Class iface) {
+ return RestClientBuilder.newBuilder()
+ .baseUri("http://localhost:" + h2Port)
+ .httpClient(h2Client)
+ .build(iface);
+ }
+
+ // --- Tests ---
+
+ @Test
+ void testStringGetOverH2() {
+ final EchoApi api = proxy(EchoApi.class);
+ assertEquals("hello", api.echo("hello"));
+ }
+
+ @Test
+ void testJsonPojoGetOverH2() {
+ final JsonWidgetApi api = proxy(JsonWidgetApi.class);
+ final Widget w = api.get(42);
+ assertEquals(42, w.id);
+ assertEquals("W-42", w.name);
+ }
+
+ @Test
+ void testJsonPojoPostOverH2() {
+ final JsonWidgetApi api = proxy(JsonWidgetApi.class);
+ final Widget w = api.create(new Widget(7, "Created"));
+ assertEquals(7, w.id);
+ assertEquals("Created", w.name);
+ }
+
+ @Test
+ void testErrorResponseOverH2() {
+ final JsonErrorApi api = proxy(JsonErrorApi.class);
+ final RestClientResponseException ex = assertThrows(
+ RestClientResponseException.class,
+ () -> api.getError(500));
+ assertEquals(500, ex.getStatusCode());
+ assertNotNull(ex.getResponseBody());
+ }
+
+ @Test
+ void testVoidDeleteOverH2() {
+ final VoidApi api = proxy(VoidApi.class);
+ api.delete(1);
+ }
+
+ // --- Async server handlers ---
+
+ private static AsyncServerRequestHandler> echoHandler() {
+ return new AsyncServerRequestHandler>() {
+ @Override
+ public AsyncRequestConsumer> prepare(
+ final HttpRequest request, final EntityDetails entityDetails,
+ final HttpContext context) {
+ return new BasicRequestConsumer<>(new DiscardingEntityConsumer<>());
+ }
+
+ @Override
+ public void handle(final Message message,
+ final ResponseTrigger responseTrigger,
+ final HttpContext context) throws HttpException, IOException {
+ final String uri = message.getHead().getRequestUri();
+ final int qi = uri.indexOf("msg=");
+ final String msg = qi >= 0 ? uri.substring(qi + 4) : "";
+ responseTrigger.submitResponse(
+ new BasicResponseProducer(200, msg, ContentType.TEXT_PLAIN),
+ context);
+ }
+ };
+ }
+
+ private static AsyncServerRequestHandler> jsonGetHandler() {
+ return new AsyncServerRequestHandler>() {
+ @Override
+ public AsyncRequestConsumer> prepare(
+ final HttpRequest request, final EntityDetails entityDetails,
+ final HttpContext context) {
+ return new BasicRequestConsumer<>(new DiscardingEntityConsumer<>());
+ }
+
+ @Override
+ public void handle(final Message message,
+ final ResponseTrigger responseTrigger,
+ final HttpContext context) throws HttpException, IOException {
+ final String path = message.getHead().getRequestUri();
+ final String idStr = path.substring(path.lastIndexOf('/') + 1);
+ responseTrigger.submitResponse(
+ new BasicResponseProducer(200,
+ "{\"id\":" + idStr + ",\"name\":\"W-" + idStr + "\"}",
+ ContentType.APPLICATION_JSON),
+ context);
+ }
+ };
+ }
+
+ private static AsyncServerRequestHandler> jsonPostHandler() {
+ return new AsyncServerRequestHandler>() {
+ @Override
+ public AsyncRequestConsumer> prepare(
+ final HttpRequest request, final EntityDetails entityDetails,
+ final HttpContext context) {
+ return new BasicRequestConsumer<>(new StringAsyncEntityConsumer());
+ }
+
+ @Override
+ public void handle(final Message message,
+ final ResponseTrigger responseTrigger,
+ final HttpContext context) throws HttpException, IOException {
+ responseTrigger.submitResponse(
+ new BasicResponseProducer(201, message.getBody(),
+ ContentType.APPLICATION_JSON),
+ context);
+ }
+ };
+ }
+
+ private static AsyncServerRequestHandler> jsonErrorHandler() {
+ return new AsyncServerRequestHandler>() {
+ @Override
+ public AsyncRequestConsumer> prepare(
+ final HttpRequest request, final EntityDetails entityDetails,
+ final HttpContext context) {
+ return new BasicRequestConsumer<>(new DiscardingEntityConsumer<>());
+ }
+
+ @Override
+ public void handle(final Message message,
+ final ResponseTrigger responseTrigger,
+ final HttpContext context) throws HttpException, IOException {
+ final String path = message.getHead().getRequestUri();
+ final int code = Integer.parseInt(
+ path.substring(path.lastIndexOf('/') + 1));
+ responseTrigger.submitResponse(
+ new BasicResponseProducer(code,
+ "{\"error\":\"status " + code + "\"}",
+ ContentType.APPLICATION_JSON),
+ context);
+ }
+ };
+ }
+
+ private static AsyncServerRequestHandler> voidHandler() {
+ return new AsyncServerRequestHandler>() {
+ @Override
+ public AsyncRequestConsumer> prepare(
+ final HttpRequest request, final EntityDetails entityDetails,
+ final HttpContext context) {
+ return new BasicRequestConsumer<>(new DiscardingEntityConsumer<>());
+ }
+
+ @Override
+ public void handle(final Message message,
+ final ResponseTrigger responseTrigger,
+ final HttpContext context) throws HttpException, IOException {
+ responseTrigger.submitResponse(
+ new BasicResponseProducer(new BasicHttpResponse(204)),
+ context);
+ }
+ };
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java
new file mode 100644
index 0000000000..350deb382c
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientIntegrationTest.java
@@ -0,0 +1,213 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ */
+package org.apache.hc.client5.http.rest;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+class RestClientIntegrationTest {
+
+ private HttpServer server;
+ private CloseableHttpAsyncClient httpClient;
+ private URI baseUri;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext("/books", this::handleBooks);
+ 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 executesRealRoundTripAgainstLocalServer() throws Exception {
+ final BookResource client = RestClientBuilder.newBuilder()
+ .baseUri(baseUri)
+ .httpClient(httpClient)
+ .build(BookResource.class);
+
+ final Book created = client.createBook("abc-123", "en", "token-1",
+ new Book(null, "HttpComponents in Action"));
+
+ assertNotNull(created);
+ assertEquals("abc-123", created.id);
+ assertEquals("HttpComponents in Action", created.title);
+ assertEquals("en", created.lang);
+
+ final Book fetched = client.getBook("abc-123", "en", "token-1");
+
+ assertNotNull(fetched);
+ assertEquals("abc-123", fetched.id);
+ assertEquals("HttpComponents in Action", fetched.title);
+ assertEquals("en", fetched.lang);
+ }
+
+ private void handleBooks(final HttpExchange exchange) throws IOException {
+ try {
+ final String method = exchange.getRequestMethod();
+ final String path = exchange.getRequestURI().getPath();
+ final String query = exchange.getRequestURI().getRawQuery();
+ final String auth = exchange.getRequestHeaders().getFirst("X-Auth");
+
+ if (!"token-1".equals(auth)) {
+ send(exchange, 401, "text/plain", "Unauthorized");
+ return;
+ }
+
+ if ("GET".equals(method) && path.startsWith("/books/")) {
+ final String id = path.substring("/books/".length());
+ final String lang = extractQueryParam(query, "lang");
+
+ final Book book = new Book(id, "HttpComponents in Action");
+ book.lang = lang;
+
+ sendJson(exchange, 200, book);
+ return;
+ }
+
+ if ("POST".equals(method) && "/books".equals(path)) {
+ final String id = extractQueryParam(query, "id");
+ final String lang = extractQueryParam(query, "lang");
+
+ final Book incoming = readJson(exchange.getRequestBody(), Book.class);
+ incoming.id = id;
+ incoming.lang = lang;
+
+ sendJson(exchange, 200, incoming);
+ return;
+ }
+
+ send(exchange, 404, "text/plain", "Not found");
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private T readJson(final InputStream inputStream, final Class type) throws IOException {
+ return objectMapper.readValue(inputStream, type);
+ }
+
+ private void sendJson(final HttpExchange exchange, final int statusCode, final Object payload)
+ throws IOException {
+ final byte[] body = objectMapper.writeValueAsBytes(payload);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(statusCode, body.length);
+ try (final OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ }
+
+ private static void send(final HttpExchange exchange, final int statusCode,
+ final String contentType, final String body) throws IOException {
+ final byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", contentType + "; charset=UTF-8");
+ exchange.sendResponseHeaders(statusCode, bytes.length);
+ try (final OutputStream out = exchange.getResponseBody()) {
+ out.write(bytes);
+ }
+ }
+
+ private static String extractQueryParam(final String query, final String name) {
+ if (query == null || query.isEmpty()) {
+ return null;
+ }
+ final String prefix = name + "=";
+ for (final String pair : query.split("&")) {
+ if (pair.startsWith(prefix)) {
+ return pair.substring(prefix.length());
+ }
+ }
+ return null;
+ }
+
+ @Path("/books")
+ interface BookResource {
+
+ @GET
+ @Path("/{id}")
+ @Produces("application/json")
+ Book getBook(@PathParam("id") String id,
+ @QueryParam("lang") String lang,
+ @HeaderParam("X-Auth") String auth);
+
+ @POST
+ @Consumes("application/json")
+ @Produces("application/json")
+ Book createBook(@QueryParam("id") String id,
+ @QueryParam("lang") String lang,
+ @HeaderParam("X-Auth") String auth,
+ Book book);
+ }
+
+ static final class Book {
+
+ public String id;
+ public String title;
+ public String lang;
+
+ Book() {
+ }
+
+ Book(final String id, final String title) {
+ this.id = id;
+ this.title = title;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java
new file mode 100644
index 0000000000..55d26270c2
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java
@@ -0,0 +1,137 @@
+/*
+ * ====================================================================
+ * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class RestInvocationHandlerTest {
+
+ @Test
+ void testExpandSingleVariable() {
+ final Map vars = Collections.singletonMap("id", "42");
+ assertArrayEquals(
+ new String[]{"items", "42"},
+ RestInvocationHandler.expandPathSegments("/items/{id}", vars));
+ }
+
+ @Test
+ void testExpandMultipleVariables() {
+ final Map vars = new LinkedHashMap<>();
+ vars.put("group", "admin");
+ vars.put("id", "7");
+ assertArrayEquals(
+ new String[]{"admin", "users", "7"},
+ RestInvocationHandler.expandPathSegments("/{group}/users/{id}", vars));
+ }
+
+ @Test
+ void testExpandPreservesRawValues() {
+ final Map vars = Collections.singletonMap("name", "hello world");
+ assertArrayEquals(
+ new String[]{"items", "hello world"},
+ RestInvocationHandler.expandPathSegments("/items/{name}", vars));
+ }
+
+ @Test
+ void testExpandNoVariables() {
+ assertArrayEquals(
+ new String[]{"plain"},
+ RestInvocationHandler.expandPathSegments("/plain", Collections.emptyMap()));
+ }
+
+ @Test
+ void testExpandEmptyTemplate() {
+ assertArrayEquals(
+ new String[0],
+ RestInvocationHandler.expandPathSegments("/", Collections.emptyMap()));
+ }
+
+ @Test
+ void testExpandSegmentNoVariable() {
+ assertEquals("plain",
+ RestInvocationHandler.expandSegment("plain", Collections.emptyMap()));
+ }
+
+ @Test
+ void testExpandSegmentSingleVariable() {
+ final Map vars = Collections.singletonMap("id", "42");
+ assertEquals("42",
+ RestInvocationHandler.expandSegment("{id}", vars));
+ }
+
+ @Test
+ void testExpandSegmentMixedContent() {
+ final Map vars = new LinkedHashMap<>();
+ vars.put("group", "admin");
+ vars.put("id", "7");
+ assertEquals("admin-7",
+ RestInvocationHandler.expandSegment("{group}-{id}", vars));
+ }
+
+ @Test
+ void testExpandSegmentUnknownVariable() {
+ assertEquals("{unknown}",
+ RestInvocationHandler.expandSegment("{unknown}", Collections.emptyMap()));
+ }
+
+ @Test
+ void testParamToStringBasicTypes() {
+ assertEquals("42", RestInvocationHandler.paramToString(42));
+ assertEquals("true", RestInvocationHandler.paramToString(true));
+ assertEquals("hello", RestInvocationHandler.paramToString("hello"));
+ }
+
+ enum Color { RED, GREEN, BLUE }
+
+ enum OverriddenToString {
+ ALPHA;
+
+ @Override
+ public String toString() {
+ return "custom-alpha";
+ }
+ }
+
+ @Test
+ void testParamToStringEnumUsesName() {
+ assertEquals("RED", RestInvocationHandler.paramToString(Color.RED));
+ assertEquals("GREEN", RestInvocationHandler.paramToString(Color.GREEN));
+ }
+
+ @Test
+ void testParamToStringEnumIgnoresOverriddenToString() {
+ assertEquals("ALPHA", RestInvocationHandler.paramToString(OverriddenToString.ALPHA));
+ }
+
+}
diff --git a/pom.xml b/pom.xml
index ade28c3889..2d2a6cef6e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -86,6 +86,8 @@
1.59.0
1.26.2
2.9.3
+ 3.1.0
+ 2.21.1
@@ -116,6 +118,11 @@
httpcore5-reactive
${httpcore.version}
+
+ org.apache.httpcomponents.core5
+ httpcore5-jackson2
+ ${httpcore.version}
+
org.apache.httpcomponents.client5
httpclient5
@@ -266,6 +273,16 @@
caffeine
${caffeine.version}
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+ ${jakarta.ws.rs.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
@@ -275,6 +292,7 @@
httpclient5-observation
httpclient5-fluent
httpclient5-cache
+ httpclient5-jakarta-rest-client
httpclient5-testing