diff --git a/httpclient5-jakarta-rest-client/pom.xml b/httpclient5-jakarta-rest-client/pom.xml new file mode 100644 index 0000000000..23b0e4b56f --- /dev/null +++ b/httpclient5-jakarta-rest-client/pom.xml @@ -0,0 +1,120 @@ + + + + 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 + + + org.apache.httpcomponents.core5 + httpcore5-jackson2 + + + 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 + + + + + diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java new file mode 100644 index 0000000000..4e328ed83e --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java @@ -0,0 +1,357 @@ +/* + * ==================================================================== + * 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.annotation.Annotation; +import java.lang.reflect.Method; +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; + +/** + * Describes a single method on a Jakarta REST annotated client interface together with its + * HTTP method, URI template, content types and parameter extraction rules. + */ +final class ClientResourceMethod { + + enum ParamSource { + PATH, + QUERY, + HEADER, + BODY + } + + static final class ParamInfo { + + private final ParamSource source; + private final String name; + private final String defaultValue; + + ParamInfo(final ParamSource paramSource, final String paramName, + final String defValue) { + this.source = paramSource; + this.name = paramName; + this.defaultValue = defValue; + } + + ParamSource getSource() { + return source; + } + + String getName() { + return name; + } + + String getDefaultValue() { + return defaultValue; + } + } + + private final Method method; + private final String httpMethod; + private final String pathTemplate; + private final String[] produces; + private final String[] consumes; + private final ParamInfo[] parameters; + private final int pathParamCount; + private final int queryParamCount; + 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.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; + } + + Method getMethod() { + return method; + } + + 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; + } + + 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[0]; + + final Consumes mc = m.getAnnotation(Consumes.class); + final String[] cons = mc != null + ? mc.value() + : classConsumes != null + ? classConsumes.value() + : new String[0]; + + final ParamInfo[] params = scanParameters(m); + validatePathParams(m, combinedPath, params); + validateConsumes(m, cons, 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 Annotation[][] annotations = m.getParameterAnnotations(); + final ParamInfo[] result = new ParamInfo[annotations.length]; + int bodyCount = 0; + for (int i = 0; i < annotations.length; i++) { + result[i] = resolveParam(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 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(), defVal); + } + if (a instanceof QueryParam) { + return new ParamInfo(ParamSource.QUERY, ((QueryParam) a).value(), defVal); + } + if (a instanceof HeaderParam) { + return new ParamInfo(ParamSource.HEADER, ((HeaderParam) a).value(), defVal); + } + } + return new ParamInfo(ParamSource.BODY, null, null); + } + + 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 + "\""); + } + } + } + + private static void validateConsumes(final Method m, + final String[] consumes, + final ParamInfo[] params) { + if (consumes.length <= 1) { + return; + } + for (final ParamInfo pi : params) { + if (pi.getSource() == ParamSource.BODY) { + throw new IllegalStateException("Method " + m.getName() + + " has a request body and multiple @Consumes" + + " values; exactly one is required"); + } + } + } + + 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; + } + + 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 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