diff --git a/httpclient5-jakarta-rest-client/pom.xml b/httpclient5-jakarta-rest-client/pom.xml new file mode 100644 index 0000000000..e86049a4ba --- /dev/null +++ b/httpclient5-jakarta-rest-client/pom.xml @@ -0,0 +1,135 @@ + + + + httpclient5-parent + org.apache.httpcomponents.client5 + 5.7-alpha1-SNAPSHOT + + 4.0.0 + + httpclient5-jakarta-rest-client + Jakarta REST Client for Apache HttpClient + Type-safe Jakarta REST client backed by Apache HttpClient + + + org.apache.hc.client5.http.rest + 11 + 11 + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + jakarta.ws.rs + jakarta.ws.rs-api + + + com.fasterxml.jackson.core + jackson-databind + + + org.junit.jupiter + junit-jupiter + test + + + org.apache.httpcomponents.core5 + httpcore5-testing + test + + + org.slf4j + slf4j-api + test + + + org.apache.logging.log4j + log4j-slf4j-impl + test + + + org.apache.logging.log4j + log4j-core + test + + + + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + + true + + + + + + + + skip-on-java8 + + + hc.build.toolchain.version + 1.8 + + + + true + true + true + true + true + true + + + + + + + + maven-project-info-reports-plugin + false + + + + index + dependencies + dependency-info + summary + + + + + + + + diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java new file mode 100644 index 0000000000..33f08adbf0 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/AcceptStrategy.java @@ -0,0 +1,98 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.util.Locale; + +/** + * Builds the value of the {@code Accept} header from the media types declared in a + * {@code @Produces} annotation. Implementations control how multiple media types are + * ordered and whether quality values are assigned. + * + * @since 5.7 + */ +public interface AcceptStrategy { + + /** + * Default strategy that assigns descending quality values (1.0, 0.9, 0.8, ...) when + * more than one media type is declared. A single type is sent without a quality parameter. + */ + AcceptStrategy QUALITY_DESCENDING = new AcceptStrategy() { + + /** Quality-value step. */ + private static final double Q_STEP = 0.1; + /** Minimum quality value. */ + private static final double Q_MIN = 0.1; + + @Override + public String buildAcceptHeader(final String[] produces) { + if (produces.length == 1) { + return produces[0]; + } + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < produces.length; i++) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(produces[i]); + final double q = 1.0 - i * Q_STEP; + final double qClamped = Math.max(q, Q_MIN); + sb.append(";q="); + if (qClamped == 1.0) { + sb.append("1.0"); + } else { + sb.append(String.format(Locale.ROOT, "%.1f", qClamped)); + } + } + return sb.toString(); + } + }; + + /** + * Strategy that lists all media types without quality values, giving them equal + * preference. + */ + AcceptStrategy UNWEIGHTED = produces -> { + final StringBuilder sb = new StringBuilder(); + for (final String type : produces) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(type); + } + return sb.toString(); + }; + + /** + * Formats the given media types into an Accept header value. + * + * @param produces the media types from {@code @Produces}, never empty. + * @return the formatted Accept header value. + */ + String buildAcceptHeader(String[] produces); + +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java new file mode 100644 index 0000000000..a4e04830f8 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityReader.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; + +/** + * Deserializes an HTTP response body into a Java object. Implementations handle a specific + * content type such as JSON or XML. + * + * @since 5.7 + */ +public interface EntityReader { + + /** + * Reads the response body and converts it to the requested type. + * + * @param stream the response body stream. + * @param rawType the raw return type of the interface method. + * @param genericType the full generic return type, which may be a + * {@link java.lang.reflect.ParameterizedType}. + * @return the deserialized object. + * @throws IOException if reading or deserialization fails. + */ + Object read(InputStream stream, Class rawType, Type genericType) throws IOException; +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java new file mode 100644 index 0000000000..3f649d16ba --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/EntityWriter.java @@ -0,0 +1,48 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.io.IOException; + +/** + * Serializes a Java object into a byte array for use as an HTTP request body. + * Implementations handle a specific content type such as JSON or XML. + * + * @since 5.7 + */ +public interface EntityWriter { + + /** + * Serializes the given object into a byte array suitable for use as a request body. + * + * @param body the object to serialize. + * @param mediaType the target media type from the {@code @Consumes} annotation. + * @return the serialized bytes. + * @throws IOException if serialization fails. + */ + byte[] write(Object body, String mediaType) throws IOException; +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java new file mode 100644 index 0000000000..c192856f3c --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestClientBuilder.java @@ -0,0 +1,257 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.rest.impl.ClientResourceMethod; +import org.apache.hc.client5.http.rest.impl.JacksonEntityReader; +import org.apache.hc.client5.http.rest.impl.JacksonEntityWriter; +import org.apache.hc.client5.http.rest.impl.RestInvocationHandler; +import org.apache.hc.core5.util.Args; + +/** + * Builds type-safe REST client proxies from Jakarta REST annotated interfaces. The proxy + * translates each method call into an HTTP request executed through the Apache HttpClient + * async transport. + * + *

Minimal usage:

+ *
+ * try (CloseableHttpAsyncClient client = HttpAsyncClients.createDefault()) {
+ *     client.start();
+ *     UserApi api = RestClientBuilder.newBuilder()
+ *             .baseUri("...")
+ *             .httpAsyncClient(client)
+ *             .build(UserApi.class);
+ *     User user = api.get(42);
+ * }
+ * 
+ * + *

Both {@code baseUri} and {@code httpAsyncClient} are required. The caller owns the + * client lifecycle. A default Jackson {@link ObjectMapper} is created when none is + * provided. Custom {@link EntityReader} and {@link EntityWriter} implementations can + * replace Jackson for other serialization formats.

+ * + *

Methods that return {@code CompletableFuture} or {@code Future} execute + * asynchronously without blocking. Methods that return a plain type block until the + * response is available.

+ * + *

The proxy supports {@code @Path}, {@code @GET}, {@code @POST}, {@code @PUT}, + * {@code @DELETE}, {@code @PathParam}, {@code @QueryParam}, {@code @HeaderParam}, + * {@code @Produces}, {@code @Consumes} and {@code @DefaultValue} annotations. Generic + * return types such as {@code List} are fully supported.

+ * + * @since 5.7 + */ +public final class RestClientBuilder { + + /** + * Base URI for all requests made through the proxy. + */ + private URI baseUri; + /** + * Async HTTP client used to execute requests. + */ + private CloseableHttpAsyncClient httpAsyncClient; + /** + * Jackson object mapper for JSON serialization. + */ + private ObjectMapper objectMapper; + /** + * Strategy for reading response entities. + */ + private EntityReader entityReader; + /** + * Strategy for writing request entities. + */ + private EntityWriter entityWriter; + /** + * Strategy for building Accept header values. + */ + private AcceptStrategy acceptStrategy; + + private RestClientBuilder() { + } + + /** + * Creates a new builder instance. + * + * @return a fresh builder. + */ + public static RestClientBuilder newBuilder() { + return new RestClientBuilder(); + } + + /** + * Sets the base URI for all requests. + * + * @param uri the base URI string, must not be {@code null}. + * @return this builder for chaining. + */ + public RestClientBuilder baseUri(final String uri) { + Args.notBlank(uri, "Base URI"); + this.baseUri = URI.create(uri); + return this; + } + + /** + * Sets the base URI for all requests. + * + * @param uri the base URI, must not be {@code null}. + * @return this builder for chaining. + */ + public RestClientBuilder baseUri(final URI uri) { + Args.notNull(uri, "Base URI"); + this.baseUri = uri; + return this; + } + + /** + * Sets the {@link CloseableHttpAsyncClient} to use for requests. The client must + * already be started. This is required; the builder does not create a default client + * because the caller must manage its lifecycle. + * + * @param client the async HTTP client, must not be {@code null}. + * @return this builder for chaining. + * @since 5.7 + */ + public RestClientBuilder httpAsyncClient(final CloseableHttpAsyncClient client) { + Args.notNull(client, "Async HTTP client"); + this.httpAsyncClient = client; + return this; + } + + /** + * Sets the Jackson {@link ObjectMapper} for the default JSON entity reader and writer. + * Ignored when custom {@link EntityReader} and {@link EntityWriter} implementations + * are provided. + * + * @param mapper the object mapper, must not be {@code null}. + * @return this builder for chaining. + */ + public RestClientBuilder objectMapper(final ObjectMapper mapper) { + Args.notNull(mapper, "Object mapper"); + this.objectMapper = mapper; + return this; + } + + /** + * Sets a custom entity reader for response deserialization. Overrides the default + * Jackson-based reader. + * + * @param reader the entity reader, must not be {@code null}. + * @return this builder for chaining. + * @since 5.7 + */ + public RestClientBuilder entityReader(final EntityReader reader) { + Args.notNull(reader, "Entity reader"); + this.entityReader = reader; + return this; + } + + /** + * Sets a custom entity writer for request serialization. Overrides the default + * Jackson-based writer. + * + * @param writer the entity writer, must not be {@code null}. + * @return this builder for chaining. + * @since 5.7 + */ + public RestClientBuilder entityWriter(final EntityWriter writer) { + Args.notNull(writer, "Entity writer"); + this.entityWriter = writer; + return this; + } + + /** + * Sets the strategy for building {@code Accept} header values from {@code @Produces} + * annotations. The default is {@link AcceptStrategy#QUALITY_DESCENDING} which assigns + * descending quality values. + * + * @param strategy the accept strategy, must not be {@code null}. + * @return this builder for chaining. + * @since 5.7 + */ + public RestClientBuilder acceptStrategy(final AcceptStrategy strategy) { + Args.notNull(strategy, "Accept strategy"); + this.acceptStrategy = strategy; + return this; + } + + /** + * Scans the given interface for Jakarta REST annotations and creates a proxy that + * implements it by dispatching HTTP requests through the configured async client. + * + * @param the interface type. + * @param iface the Jakarta REST annotated interface class. + * @return a proxy implementing the interface. + * @throws IllegalArgumentException if the class is not an interface. + * @throws IllegalStateException if no base URI or client has been set, or if the + * interface has no Jakarta REST annotated methods. + */ + @SuppressWarnings("unchecked") + public T build(final Class iface) { + Args.notNull(iface, "Interface class"); + if (!iface.isInterface()) { + throw new IllegalArgumentException(iface.getName() + " is not an interface"); + } + if (baseUri == null) { + throw new IllegalStateException("baseUri is required"); + } + if (httpAsyncClient == null) { + throw new IllegalStateException("httpAsyncClient is required"); + } + final CloseableHttpAsyncClient client = httpAsyncClient; + final ObjectMapper mapper = objectMapper != null ? objectMapper : new ObjectMapper(); + final EntityReader reader = entityReader != null ? entityReader : new JacksonEntityReader(mapper); + final EntityWriter writer = entityWriter != null ? entityWriter : new JacksonEntityWriter(mapper); + final AcceptStrategy accept = acceptStrategy != null ? acceptStrategy : AcceptStrategy.QUALITY_DESCENDING; + + final List methods = ClientResourceMethod.scan(iface); + if (methods.isEmpty()) { + throw new IllegalStateException("No Jakarta REST methods found on " + iface.getName()); + } + final Map methodMap = new HashMap<>(methods.size()); + for (final ClientResourceMethod rm : methods) { + methodMap.put(rm.getMethod(), rm); + } + + return (T) Proxy.newProxyInstance( + iface.getClassLoader(), + new Class[]{iface}, + new RestInvocationHandler(client, baseUri, reader, writer, accept, methodMap)); + } + +} \ No newline at end of file diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java new file mode 100644 index 0000000000..c9904eb633 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethod.java @@ -0,0 +1,436 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +/** + * Describes a single method on a Jakarta REST annotated client interface together with its + * HTTP method, URI template, content types and parameter extraction rules. + * + * @since 5.7 + */ +public final class ClientResourceMethod { + + enum ParamSource { + /** + * Bound via {@code @PathParam}. + */ + PATH, + /** + * Bound via {@code @QueryParam}. + */ + QUERY, + /** + * Bound via {@code @HeaderParam}. + */ + HEADER, + /** + * Unannotated parameter treated as request body. + */ + BODY + } + + static final class ParamInfo { + /** + * Where this parameter is bound. + */ + private final ParamSource source; + /** + * Annotation-declared parameter name. + */ + private final String name; + /** + * Java type of the parameter. + */ + private final Class type; + /** + * Value from {@code @DefaultValue}, or null. + */ + private final String defaultValue; + + ParamInfo(final ParamSource paramSource, final String paramName, final Class paramType, final String defValue) { + this.source = paramSource; + this.name = paramName; + this.type = paramType; + this.defaultValue = defValue; + } + + ParamSource getSource() { + return source; + } + + String getName() { + return name; + } + + Class getType() { + return type; + } + + String getDefaultValue() { + return defaultValue; + } + } + + /** + * The Java method on the proxy interface. + */ + private final Method method; + /** + * The generic return type including parameterized info. + */ + private final Type genericReturnType; + /** + * The HTTP verb (GET, POST, PUT, DELETE, etc.). + */ + private final String httpMethod; + /** + * The URI path template with regex constraints stripped. + */ + private final String pathTemplate; + /** + * Media types from {@code @Produces}. + */ + private final String[] produces; + /** + * Media types from {@code @Consumes}. + */ + private final String[] consumes; + /** + * Extracted parameter bindings. + */ + private final ParamInfo[] parameters; + /** + * Number of PATH parameters. + */ + private final int pathParamCount; + /** + * Number of QUERY parameters. + */ + private final int queryParamCount; + /** + * Number of HEADER parameters. + */ + private final int headerParamCount; + + ClientResourceMethod(final Method m, final String verb, final String path, final String[] prod, + final String[] cons, final ParamInfo[] params) { + this.method = m; + this.genericReturnType = m.getGenericReturnType(); + this.httpMethod = verb; + this.pathTemplate = path; + this.produces = prod; + this.consumes = cons; + this.parameters = params; + int pathCount = 0; + int queryCount = 0; + int headerCount = 0; + for (final ParamInfo pi : params) { + switch (pi.getSource()) { + case PATH: + pathCount++; + break; + case QUERY: + queryCount++; + break; + case HEADER: + headerCount++; + break; + default: + break; + } + } + this.pathParamCount = pathCount; + this.queryParamCount = queryCount; + this.headerParamCount = headerCount; + } + + /** + * Returns the Java method on the proxy interface. + * + * @return the reflected method. + */ + public Method getMethod() { + return method; + } + + Type getGenericReturnType() { + return genericReturnType; + } + + String getHttpMethod() { + return httpMethod; + } + + String getPathTemplate() { + return pathTemplate; + } + + String[] getProduces() { + return produces; + } + + String[] getConsumes() { + return consumes; + } + + ParamInfo[] getParameters() { + return parameters; + } + + int getPathParamCount() { + return pathParamCount; + } + + int getQueryParamCount() { + return queryParamCount; + } + + int getHeaderParamCount() { + return headerParamCount; + } + + /** + * Scans a Jakarta REST annotated interface and returns descriptors for every method that + * carries an HTTP method annotation. + * + * @param iface the interface class to scan. + * @return the discovered client resource methods. + */ + public static List scan(final Class iface) { + final Path classPath = iface.getAnnotation(Path.class); + final String basePath = classPath != null ? classPath.value() : ""; + final Produces classProduces = iface.getAnnotation(Produces.class); + final Consumes classConsumes = iface.getAnnotation(Consumes.class); + + final List result = new ArrayList<>(); + for (final Method m : iface.getMethods()) { + final String verb = resolveHttpMethod(m); + if (verb == null) { + continue; + } + final Path methodPath = m.getAnnotation(Path.class); + final String combinedPath = combinePaths(basePath, methodPath != null ? methodPath.value() : null); + + final Produces mp = m.getAnnotation(Produces.class); + final String[] prod = mp != null ? mp.value() + : classProduces != null + ? classProduces.value() + : new String[]{ + MediaType.APPLICATION_JSON}; + + final Consumes mc = m.getAnnotation(Consumes.class); + final String[] cons = mc != null + ? mc.value() + : classConsumes != null + ? classConsumes.value() + : new String[]{ + MediaType.APPLICATION_JSON}; + + final ParamInfo[] params = scanParameters(m); + validatePathParams(m, combinedPath, params); + final String strippedPath = stripRegex(combinedPath); + result.add(new ClientResourceMethod(m, verb, strippedPath, prod, cons, params)); + } + return result; + } + + private static String resolveHttpMethod(final Method m) { + for (final Annotation a : m.getAnnotations()) { + final HttpMethod hm = a.annotationType().getAnnotation(HttpMethod.class); + if (hm != null) { + return hm.value(); + } + } + return null; + } + + private static ParamInfo[] scanParameters(final Method m) { + final Class[] types = m.getParameterTypes(); + final Annotation[][] annotations = m.getParameterAnnotations(); + final ParamInfo[] result = new ParamInfo[types.length]; + int bodyCount = 0; + for (int i = 0; i < types.length; i++) { + result[i] = resolveParam(types[i], annotations[i]); + if (result[i].getSource() == ParamSource.BODY) { + bodyCount++; + } + } + if (bodyCount > 1) { + throw new IllegalStateException("Method " + m.getName() + " has " + bodyCount + " unannotated (body) parameters;" + " at most one is allowed"); + } + return result; + } + + private static ParamInfo resolveParam(final Class type, final Annotation[] annotations) { + String defVal = null; + for (final Annotation a : annotations) { + if (a instanceof DefaultValue) { + defVal = ((DefaultValue) a).value(); + } + } + for (final Annotation a : annotations) { + if (a instanceof PathParam) { + return new ParamInfo(ParamSource.PATH, ((PathParam) a).value(), type, defVal); + } + if (a instanceof QueryParam) { + return new ParamInfo(ParamSource.QUERY, ((QueryParam) a).value(), type, defVal); + } + if (a instanceof HeaderParam) { + return new ParamInfo(ParamSource.HEADER, ((HeaderParam) a).value(), type, defVal); + } + } + return new ParamInfo(ParamSource.BODY, null, type, null); + } + + /** + * Validates that every {@code @PathParam} name matches a template variable in the URI + * path, and that every template variable has a corresponding {@code @PathParam}. + * + * @param m the method being scanned. + * @param path the combined URI path template. + * @param params the scanned parameter bindings. + */ + private static void validatePathParams(final Method m, final String path, + final ParamInfo[] params) { + final Set templateVars = extractTemplateVariables(path); + final Set paramNames = new LinkedHashSet<>(); + for (final ParamInfo pi : params) { + if (pi.getSource() == ParamSource.PATH) { + paramNames.add(pi.getName()); + } + } + for (final String name : paramNames) { + if (!templateVars.contains(name)) { + throw new IllegalStateException("Method " + m.getName() + + ": @PathParam(\"" + name + "\") has no matching {" + + name + "} in path \"" + path + "\""); + } + } + for (final String name : templateVars) { + if (!paramNames.contains(name)) { + throw new IllegalStateException("Method " + m.getName() + + ": path variable {" + name + "} has no matching" + + " @PathParam in path \"" + path + "\""); + } + } + } + + /** + * Extracts variable names from a URI template, stripping any regex constraints + * (e.g. {@code {id:\d+}} yields {@code id}). + * + * @param template the URI path template. + * @return the set of variable names. + */ + static Set extractTemplateVariables(final String template) { + final Set vars = new LinkedHashSet<>(); + int i = 0; + while (i < template.length()) { + if (template.charAt(i) == '{') { + final int close = template.indexOf('}', i); + if (close < 0) { + break; + } + final String content = template.substring(i + 1, close); + final int colon = content.indexOf(':'); + final String name = colon >= 0 ? content.substring(0, colon).trim() : content.trim(); + if (!name.isEmpty()) { + vars.add(name); + } + i = close + 1; + } else { + i++; + } + } + return vars; + } + + /** + * Strips regex constraints from URI template variables, e.g. {@code {id:\d+}} becomes + * {@code {id}}. + * + * @param template the URI template string. + * @return the template with regex constraints removed. + */ + static String stripRegex(final String template) { + final StringBuilder sb = new StringBuilder(template.length()); + int i = 0; + while (i < template.length()) { + final char c = template.charAt(i); + if (c == '{') { + final int close = template.indexOf('}', i); + if (close < 0) { + sb.append(c); + i++; + continue; + } + final String content = template.substring(i + 1, close); + final int colon = content.indexOf(':'); + if (colon >= 0) { + sb.append('{'); + sb.append(content, 0, colon); + sb.append('}'); + } else { + sb.append(template, i, close + 1); + } + i = close + 1; + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + static String combinePaths(final String base, final String sub) { + if (sub == null || sub.isEmpty()) { + return base.isEmpty() ? "/" : base; + } + final String left = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + final String right = sub.startsWith("/") ? sub : "/" + sub; + return left + right; + } + +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java new file mode 100644 index 0000000000..f3d567cfb6 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityReader.java @@ -0,0 +1,67 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hc.client5.http.rest.EntityReader; + +/** + * Jackson-based {@link EntityReader} that deserializes JSON + * response bodies using an {@link ObjectMapper}. Supports + * parameterized types such as {@code List}. + * + * @since 5.7 + */ +public final class JacksonEntityReader implements EntityReader { + + /** + * Jackson object mapper used for deserialization. + */ + private final ObjectMapper objectMapper; + + /** + * Creates a reader backed by the given mapper. + * + * @param mapper the Jackson object mapper. + */ + public JacksonEntityReader(final ObjectMapper mapper) { + this.objectMapper = mapper; + } + + @Override + public Object read(final InputStream stream, final Class rawType, final Type genericType) + throws IOException { + final JavaType javaType = objectMapper.getTypeFactory().constructType(genericType); + return objectMapper.readValue(stream, javaType); + } +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java new file mode 100644 index 0000000000..4f26036e1f --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/JacksonEntityWriter.java @@ -0,0 +1,69 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.hc.client5.http.rest.EntityWriter; + +/** + * Jackson-based {@link EntityWriter} that serializes request bodies to JSON + * using an {@link ObjectMapper}. Passes through {@code String} and + * {@code byte[]} bodies without conversion. + * + * @since 5.7 + */ +public final class JacksonEntityWriter implements EntityWriter { + + /** + * Jackson object mapper used for serialization. + */ + private final ObjectMapper objectMapper; + + /** + * Creates a writer backed by the given mapper. + * + * @param mapper the Jackson object mapper. + */ + public JacksonEntityWriter(final ObjectMapper mapper) { + this.objectMapper = mapper; + } + + @Override + public byte[] write(final Object body, final String mediaType) throws IOException { + if (body instanceof byte[]) { + return (byte[]) body; + } + if (body instanceof String) { + return ((String) body).getBytes(StandardCharsets.UTF_8); + } + return objectMapper.writeValueAsBytes(body); + } +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java new file mode 100644 index 0000000000..9d63281754 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/MinimalResponse.java @@ -0,0 +1,599 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; + +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.GenericType; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Link; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.NewCookie; +import jakarta.ws.rs.core.Response; +import org.apache.hc.client5.http.rest.EntityReader; + +/** + * {@link Response} implementation that holds status, headers and a buffered entity without + * requiring a full Jakarta REST runtime. The entity is always stored as {@code byte[]} so the + * response is inherently buffered and repeatable. + * + *

Header-derived accessors ({@link #getMediaType()}, {@link #getLanguage()}, + * {@link #getDate()}, {@link #getCookies()}, {@link #getLinks()}, etc.) parse the stored + * headers on each call.

+ * + *

The {@link #readEntity} methods support {@code byte[]} and {@code String} natively. + * For other types an {@link EntityReader} is used when one was provided at construction + * time.

+ * + * @since 5.7 + */ +final class MinimalResponse extends Response { + + /** + * RFC 1123 date format used in HTTP headers. + */ + private static final String RFC_1123_PATTERN = "EEE, dd MMM yyyy HH:mm:ss zzz"; + + /** + * Length of the "path=" cookie attribute prefix. + */ + private static final int PATH_PREFIX_LEN = 5; + + /** + * Length of the "domain=" cookie attribute prefix. + */ + private static final int DOMAIN_PREFIX_LEN = 7; + + /** + * Length of the "max-age=" cookie attribute prefix. + */ + private static final int MAX_AGE_PREFIX_LEN = 8; + + /** + * The HTTP status code. + */ + private final int status; + /** + * The response entity, typically {@code byte[]}. + */ + private final Object entity; + /** + * The response headers. + */ + private final MultivaluedMap headers; + /** + * The entity reader for deserialization, may be null. + */ + private final EntityReader entityReader; + + MinimalResponse(final int code, final Object ent, final MultivaluedMap hdrs, final EntityReader reader) { + this.status = code; + this.entity = ent; + this.headers = hdrs; + this.entityReader = reader; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public StatusType getStatusInfo() { + return Status.fromStatusCode(status); + } + + @Override + public Object getEntity() { + return entity; + } + + @Override + public T readEntity(final Class type) { + return readEntityInternal(type, type); + } + + @Override + @SuppressWarnings("unchecked") + public T readEntity(final GenericType type) { + return (T) readEntityInternal(type.getRawType(), type.getType()); + } + + @Override + public T readEntity(final Class type, final Annotation[] annotations) { + return readEntityInternal(type, type); + } + + @Override + @SuppressWarnings("unchecked") + public T readEntity(final GenericType type, final Annotation[] annotations) { + return (T) readEntityInternal(type.getRawType(), type.getType()); + } + + @SuppressWarnings("unchecked") + private T readEntityInternal(final Class rawType, final Type genericType) { + if (entity == null) { + return null; + } + if (rawType.isInstance(entity)) { + return rawType.cast(entity); + } + if (rawType == String.class && entity instanceof byte[]) { + return (T) new String((byte[]) entity, resolveCharset()); + } + if (rawType == byte[].class && entity instanceof String) { + return (T) ((String) entity).getBytes(resolveCharset()); + } + if (entityReader != null && entity instanceof byte[]) { + try { + return (T) entityReader.read(new ByteArrayInputStream((byte[]) entity), rawType, genericType); + } catch (final IOException e) { + throw new IllegalStateException("Failed to read entity as " + rawType.getName(), e); + } + } + throw new IllegalStateException("Cannot read entity as " + rawType.getName() + + "; entity type is " + entity.getClass().getName()); + } + + @Override + public boolean hasEntity() { + return entity != null; + } + + @Override + public boolean bufferEntity() { + return true; + } + + @Override + public void close() { + } + + @Override + public MediaType getMediaType() { + final String ct = firstHeaderString(HttpHeaders.CONTENT_TYPE); + if (ct == null) { + return null; + } + return parseMediaType(ct); + } + + @Override + public Locale getLanguage() { + final String lang = firstHeaderString(HttpHeaders.CONTENT_LANGUAGE); + if (lang == null) { + return null; + } + return Locale.forLanguageTag(lang.trim()); + } + + @Override + public int getLength() { + final String cl = firstHeaderString(HttpHeaders.CONTENT_LENGTH); + if (cl == null) { + return -1; + } + try { + return Integer.parseInt(cl.trim()); + } catch (final NumberFormatException ignored) { + return -1; + } + } + + @Override + public Set getAllowedMethods() { + final String allow = firstHeaderString("Allow"); + if (allow == null) { + return Collections.emptySet(); + } + final Set methods = new LinkedHashSet<>(); + for (final String m : allow.split(",")) { + final String trimmed = m.trim(); + if (!trimmed.isEmpty()) { + methods.add(trimmed.toUpperCase(Locale.ROOT)); + } + } + return Collections.unmodifiableSet(methods); + } + + @Override + public Map getCookies() { + final List values = headers.get(HttpHeaders.SET_COOKIE); + if (values == null || values.isEmpty()) { + return Collections.emptyMap(); + } + final Map result = new LinkedHashMap<>(); + for (final Object v : values) { + if (v == null) { + continue; + } + final NewCookie cookie = parseCookie(v.toString()); + if (cookie != null) { + result.put(cookie.getName(), cookie); + } + } + return Collections.unmodifiableMap(result); + } + + @Override + public EntityTag getEntityTag() { + final String etag = firstHeaderString(HttpHeaders.ETAG); + if (etag == null) { + return null; + } + final String trimmed = etag.trim(); + if (trimmed.startsWith("W/")) { + return new EntityTag(unquote(trimmed.substring(2)), true); + } + return new EntityTag(unquote(trimmed)); + } + + @Override + public Date getDate() { + return parseDate(firstHeaderString(HttpHeaders.DATE)); + } + + @Override + public Date getLastModified() { + return parseDate(firstHeaderString(HttpHeaders.LAST_MODIFIED)); + } + + @Override + public URI getLocation() { + final String loc = firstHeaderString(HttpHeaders.LOCATION); + if (loc == null) { + return null; + } + return URI.create(loc.trim()); + } + + @Override + public Set getLinks() { + final List values = headers.get("Link"); + if (values == null || values.isEmpty()) { + return Collections.emptySet(); + } + final Set result = new LinkedHashSet<>(); + for (final Object v : values) { + if (v == null) { + continue; + } + final Link link = parseLink(v.toString()); + if (link != null) { + result.add(link); + } + } + return Collections.unmodifiableSet(result); + } + + @Override + public boolean hasLink(final String relation) { + for (final Link link : getLinks()) { + if (relation.equals(link.getRel())) { + return true; + } + } + return false; + } + + @Override + public Link getLink(final String relation) { + for (final Link link : getLinks()) { + if (relation.equals(link.getRel())) { + return link; + } + } + return null; + } + + @Override + public Link.Builder getLinkBuilder(final String relation) { + throw new UnsupportedOperationException("Link.Builder requires a Jakarta REST runtime"); + } + + @Override + public MultivaluedMap getMetadata() { + final MultivaluedMap copy = new MultivaluedHashMap<>(); + for (final Map.Entry> entry : headers.entrySet()) { + copy.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return copy; + } + + @Override + public MultivaluedMap getStringHeaders() { + final MultivaluedMap result = new MultivaluedHashMap<>(); + for (final Map.Entry> entry : headers.entrySet()) { + final List values = new ArrayList<>(); + for (final Object v : entry.getValue()) { + values.add(v != null ? v.toString() : null); + } + result.put(entry.getKey(), values); + } + return result; + } + + @Override + public String getHeaderString(final String name) { + final List values = headers.get(name); + if (values == null || values.isEmpty()) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (final Object v : values) { + if (sb.length() > 0) { + sb.append(','); + } + if (v != null) { + sb.append(v); + } + } + return sb.toString(); + } + + private String firstHeaderString(final String name) { + final Object val = headers.getFirst(name); + return val != null ? val.toString() : null; + } + + /** + * Resolves the charset from the Content-Type header, falling back to UTF-8 if absent + * or unparseable. + * + * @return the resolved charset, never {@code null}. + */ + private Charset resolveCharset() { + final MediaType mt = getMediaType(); + if (mt == null) { + return StandardCharsets.UTF_8; + } + final String charsetParam = mt.getParameters().get(MediaType.CHARSET_PARAMETER); + if (charsetParam == null) { + return StandardCharsets.UTF_8; + } + try { + return Charset.forName(charsetParam); + } catch (final IllegalArgumentException ignored) { + return StandardCharsets.UTF_8; + } + } + + private static MediaType parseMediaType(final String value) { + final String trimmed = value.trim(); + final int semi = trimmed.indexOf(';'); + final String typeSubtype = semi >= 0 ? trimmed.substring(0, semi).trim() : trimmed; + final int slash = typeSubtype.indexOf('/'); + if (slash < 0) { + return new MediaType(typeSubtype, MediaType.MEDIA_TYPE_WILDCARD); + } + final String type = typeSubtype.substring(0, slash).trim(); + final String subtype = typeSubtype.substring(slash + 1).trim(); + if (semi < 0) { + return new MediaType(type, subtype); + } + final Map params = new LinkedHashMap<>(); + for (final String param : trimmed.substring(semi + 1).split(";")) { + final int eq = param.indexOf('='); + if (eq >= 0) { + params.put(param.substring(0, eq).trim().toLowerCase(Locale.ROOT), + param.substring(eq + 1).trim()); + } + } + return new MediaType(type, subtype, params); + } + + private static Date parseDate(final String value) { + if (value == null) { + return null; + } + try { + final SimpleDateFormat fmt = new SimpleDateFormat(RFC_1123_PATTERN, Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("GMT")); + return fmt.parse(value.trim()); + } catch (final ParseException ignored) { + return null; + } + } + + private static String unquote(final String value) { + if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') { + return value.substring(1, value.length() - 1); + } + return value; + } + + private static NewCookie parseCookie(final String header) { + final String[] parts = header.split(";"); + if (parts.length == 0) { + return null; + } + final int eq = parts[0].indexOf('='); + if (eq < 0) { + return null; + } + final String name = parts[0].substring(0, eq).trim(); + final String value = parts[0].substring(eq + 1).trim(); + String path = null; + String domain = null; + int maxAge = NewCookie.DEFAULT_MAX_AGE; + boolean secure = false; + boolean httpOnly = false; + for (int i = 1; i < parts.length; i++) { + final String attr = parts[i].trim(); + final String lower = attr.toLowerCase(Locale.ROOT); + if (lower.startsWith("path=")) { + path = attr.substring(PATH_PREFIX_LEN).trim(); + } else if (lower.startsWith("domain=")) { + domain = attr.substring(DOMAIN_PREFIX_LEN).trim(); + } else if (lower.startsWith("max-age=")) { + try { + maxAge = Integer.parseInt(attr.substring(MAX_AGE_PREFIX_LEN).trim()); + } catch (final NumberFormatException ignored) { + // keep default + } + } else if ("secure".equals(lower)) { + secure = true; + } else if ("httponly".equals(lower)) { + httpOnly = true; + } + } + final NewCookie.Builder builder = new NewCookie.Builder(name); + builder.value(value); + if (path != null) { + builder.path(path); + } + if (domain != null) { + builder.domain(domain); + } + if (maxAge != NewCookie.DEFAULT_MAX_AGE) { + builder.maxAge(maxAge); + } + builder.secure(secure); + builder.httpOnly(httpOnly); + return builder.build(); + } + + private static Link parseLink(final String header) { + final int close = header.indexOf('>'); + if (!header.startsWith("<") || close < 0) { + return null; + } + final String uriStr = header.substring(1, close).trim(); + final Map params = new LinkedHashMap<>(); + final String rest = header.substring(close + 1); + for (final String param : rest.split(";")) { + final String trimmed = param.trim(); + final int eq = trimmed.indexOf('='); + if (eq >= 0) { + final String key = trimmed.substring(0, eq).trim().toLowerCase(Locale.ROOT); + final String val = unquote(trimmed.substring(eq + 1).trim()); + params.put(key, val); + } + } + return new MinimalLink(URI.create(uriStr), params); + } + + /** + * Minimal {@link Link} that avoids the {@code RuntimeDelegate}-dependent factory methods. + */ + static final class MinimalLink extends Link { + + /** + * The link URI. + */ + private final URI uri; + /** + * The link parameters (rel, title, type, etc.). + */ + private final Map params; + + MinimalLink(final URI linkUri, final Map linkParams) { + this.uri = linkUri; + this.params = Collections.unmodifiableMap(linkParams); + } + + @Override + public URI getUri() { + return uri; + } + + @Override + public jakarta.ws.rs.core.UriBuilder getUriBuilder() { + throw new UnsupportedOperationException("UriBuilder requires a Jakarta REST runtime"); + } + + @Override + public String getRel() { + return params.get("rel"); + } + + @Override + public List getRels() { + final String rel = getRel(); + if (rel == null) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(); + for (final String r : rel.split("\\s+")) { + if (!r.isEmpty()) { + result.add(r); + } + } + return result; + } + + @Override + public String getTitle() { + return params.get("title"); + } + + @Override + public String getType() { + return params.get("type"); + } + + @Override + public Map getParams() { + return params; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append('<').append(uri).append('>'); + for (final Map.Entry entry : params.entrySet()) { + sb.append("; "); + sb.append(entry.getKey()); + sb.append("=\""); + sb.append(entry.getValue()); + sb.append('"'); + } + return sb.toString(); + } + } +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandler.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandler.java new file mode 100644 index 0000000000..62b348b76d --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandler.java @@ -0,0 +1,559 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.net.URI; +import java.net.URLEncoder; +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.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.rest.AcceptStrategy; +import org.apache.hc.client5.http.rest.EntityReader; +import org.apache.hc.client5.http.rest.EntityWriter; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpHeaders; + +/** + * {@link InvocationHandler} that translates interface method calls into HTTP requests + * executed through the async transport of {@link CloseableHttpAsyncClient}. Each method + * is mapped to its HTTP verb, URI template and parameter bindings at proxy creation time. + * + *

Methods whose return type is {@link CompletableFuture} or {@link Future} execute + * without blocking. All other return types cause the handler to block on the response.

+ * + * @since 5.7 + */ +public final class RestInvocationHandler implements InvocationHandler { + + /** + * First HTTP status code that indicates an error. + */ + private static final int ERROR_STATUS_THRESHOLD = 300; + + /** + * Hexadecimal digits for percent-encoding. + */ + private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); + + /** + * Mask to convert a signed byte to an unsigned int. + */ + private static final int BYTE_MASK = 0xFF; + + /** + * Shift to extract the high nibble of a byte. + */ + private static final int HI_NIBBLE_SHIFT = 4; + + /** + * Mask to extract the low nibble of a byte. + */ + private static final int LO_NIBBLE_MASK = 0x0F; + + /** + * The async HTTP client for executing requests. + */ + private final CloseableHttpAsyncClient httpAsyncClient; + + /** + * The base URI string, cached to avoid repeated {@code URI.toString()} calls. + */ + private final String baseUriStr; + + /** + * Deserializer for response entities. + */ + private final EntityReader entityReader; + + /** + * Serializer for request entities. + */ + private final EntityWriter entityWriter; + + /** + * Pre-computed per-method request metadata. + */ + private final Map invokerMap; + + /** + * Creates a new invocation handler backed by the async transport. + * + * @param client the async HTTP client for executing requests. + * @param base the base URI for all requests. + * @param reader the entity reader for response deserialization. + * @param writer the entity writer for request serialization. + * @param accept the strategy for building Accept headers. + * @param methods the method-to-resource mapping. + */ + public RestInvocationHandler(final CloseableHttpAsyncClient client, final URI base, + final EntityReader reader, final EntityWriter writer, + final AcceptStrategy accept, + final Map methods) { + this.httpAsyncClient = client; + this.baseUriStr = base.toString(); + this.entityReader = reader; + this.entityWriter = writer; + this.invokerMap = buildInvokers(methods, accept); + } + + private static Map buildInvokers( + final Map methods, + final AcceptStrategy accept) { + 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 + ? accept.buildAcceptHeader(rm.getProduces()) : null; + final String consumeType = rm.getConsumes().length > 0 + ? rm.getConsumes()[0] : MediaType.APPLICATION_JSON; + + final Class rawReturn = rm.getMethod().getReturnType(); + final Type genericReturn = rm.getGenericReturnType(); + final ReturnMode returnMode; + final Class resultRawType; + final Type resultGenericType; + + if (CompletableFuture.class.isAssignableFrom(rawReturn) + || Future.class.isAssignableFrom(rawReturn)) { + returnMode = ReturnMode.ASYNC; + if (genericReturn instanceof ParameterizedType) { + final Type inner = ((ParameterizedType) genericReturn).getActualTypeArguments()[0]; + if (inner instanceof ParameterizedType) { + resultRawType = (Class) ((ParameterizedType) inner).getRawType(); + resultGenericType = inner; + } else if (inner instanceof Class) { + resultRawType = (Class) inner; + resultGenericType = inner; + } else { + resultRawType = Object.class; + resultGenericType = Object.class; + } + } else { + resultRawType = Object.class; + resultGenericType = Object.class; + } + } else { + returnMode = ReturnMode.SYNC; + resultRawType = rawReturn; + resultGenericType = genericReturn; + } + + result.put(entry.getKey(), new MethodInvoker( + rm, acceptHeader, consumeType, returnMode, resultRawType, resultGenericType)); + } + return result; + } + + @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) throws Exception { + 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 ? val.toString() : pi.getDefaultValue(); + switch (pi.getSource()) { + case PATH: + if (strVal != 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 String expandedPath = expandTemplate(rm.getPathTemplate(), pathParams); + final String fullUri = buildUri(expandedPath, queryParams); + final SimpleHttpRequest request = new SimpleHttpRequest(rm.getHttpMethod(), fullUri); + + if (invoker.acceptHeader != null) { + request.addHeader(HttpHeaders.ACCEPT, invoker.acceptHeader); + } + for (final Map.Entry entry : headerParams.entrySet()) { + request.addHeader(entry.getKey(), entry.getValue()); + } + if (bodyParam != null) { + final byte[] bodyBytes = entityWriter.write(bodyParam, invoker.consumeType); + request.setBody(bodyBytes, ContentType.parse(invoker.consumeType)); + } + + if (invoker.returnMode == ReturnMode.ASYNC) { + return executeAsync(request, invoker); + } + return executeSync(request, invoker); + } + + private CompletableFuture executeAsync(final SimpleHttpRequest request, + final MethodInvoker invoker) { + final CompletableFuture promise = new CompletableFuture<>(); + httpAsyncClient.execute(request, new FutureCallback() { + @Override + public void completed(final SimpleHttpResponse response) { + try { + promise.complete(processResponse(response, invoker)); + } catch (final Exception ex) { + promise.completeExceptionally(ex); + } + } + + @Override + public void failed(final Exception ex) { + promise.completeExceptionally(ex); + } + + @Override + public void cancelled() { + promise.cancel(false); + } + }); + return promise; + } + + private Object executeSync(final SimpleHttpRequest request, + final MethodInvoker invoker) throws Exception { + final Future future = httpAsyncClient.execute(request, null); + final SimpleHttpResponse response; + try { + response = future.get(); + } catch (final ExecutionException ex) { + final Throwable cause = ex.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw ex; + } + return processResponse(response, invoker); + } + + private Object processResponse(final SimpleHttpResponse response, + final MethodInvoker invoker) throws IOException { + final int status = response.getCode(); + final Class rawType = invoker.resultRawType; + final Type genericType = invoker.resultGenericType; + + if (rawType == Response.class) { + return toRestResponse(response); + } + if (status >= ERROR_STATUS_THRESHOLD) { + throw buildErrorException(response); + } + if (rawType == void.class || rawType == Void.class) { + return null; + } + + final byte[] body = response.getBodyBytes(); + if (body == null || body.length == 0) { + return null; + } + if (rawType == String.class) { + final String text = response.getBodyText(); + return text != null ? text : new String(body, StandardCharsets.UTF_8); + } + if (rawType == byte[].class) { + return body; + } + + return entityReader.read(new ByteArrayInputStream(body), rawType, genericType); + } + + private Response toRestResponse(final SimpleHttpResponse response) { + final int status = response.getCode(); + final MultivaluedMap headers = extractHeaders(response); + final byte[] body = response.getBodyBytes(); + return new MinimalResponse(status, body, headers, entityReader); + } + + private WebApplicationException buildErrorException( + final SimpleHttpResponse response) { + final int status = response.getCode(); + final MultivaluedMap headers = extractHeaders(response); + final byte[] body = response.getBodyBytes(); + final String bodyText = body != null && body.length > 0 + ? new String(body, StandardCharsets.UTF_8) : null; + final Response errorResponse = new MinimalResponse(status, body, headers, entityReader); + return new WebApplicationException( + bodyText != null ? bodyText : "HTTP " + status, errorResponse); + } + + private String buildUri(final String path, final Map> query) { + final int estimate = baseUriStr.length() + path.length() + query.size() * 32; + final StringBuilder sb = new StringBuilder(estimate); + if (baseUriStr.endsWith("/") && path.startsWith("/")) { + sb.append(baseUriStr, 0, baseUriStr.length() - 1); + } else { + sb.append(baseUriStr); + } + sb.append(path); + if (!query.isEmpty()) { + sb.append('?'); + boolean first = true; + for (final Map.Entry> entry : query.entrySet()) { + final String encodedKey = formEncode(entry.getKey()); + for (final String value : entry.getValue()) { + if (!first) { + sb.append('&'); + } + sb.append(encodedKey).append('=').append(formEncode(value)); + first = false; + } + } + } + return sb.toString(); + } + + /** + * Expands URI template variables with percent-encoded values in a single pass. The + * template must already have regex constraints stripped (done at scan time). + * + * @param template the regex-free URI template. + * @param variables the variable name-value pairs. + * @return the expanded URI path. + */ + static String expandTemplate(final String template, final Map variables) { + if (variables.isEmpty()) { + return 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(template, i, template.length()); + break; + } + final String name = template.substring(i + 1, close); + final String value = variables.get(name); + if (value != null) { + sb.append(percentEncodePath(value)); + } else { + sb.append(template, i, close + 1); + } + i = close + 1; + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + /** + * Percent-encodes a value for use in a URI path segment per RFC 3986. Characters + * outside the unreserved set (ALPHA, DIGIT, {@code -._~}) are encoded as UTF-8 + * percent-encoded octets. + * + * @param value the raw value to encode. + * @return the percent-encoded value. + */ + static String percentEncodePath(final String value) { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + final StringBuilder sb = new StringBuilder(bytes.length); + for (final byte b : bytes) { + final int ch = b & BYTE_MASK; + if (isUnreserved(ch)) { + sb.append((char) ch); + } else { + sb.append('%'); + sb.append(HEX_DIGITS[ch >> HI_NIBBLE_SHIFT]); + sb.append(HEX_DIGITS[ch & LO_NIBBLE_MASK]); + } + } + return sb.toString(); + } + + /** + * Returns {@code true} if the character is in the RFC 3986 unreserved set. + * + * @param ch the character to test. + * @return {@code true} if unreserved. + */ + private static boolean isUnreserved(final int ch) { + return ch >= 'A' && ch <= 'Z' + || ch >= 'a' && ch <= 'z' + || ch >= '0' && ch <= '9' + || ch == '-' || ch == '.' || ch == '_' || ch == '~'; + } + + /** + * Form-encodes a value for use in a query parameter. + * + * @param value the raw value to encode. + * @return the form-encoded value. + */ + private static String formEncode(final String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private Object handleObjectMethod(final Object proxy, final Method method, + final Object[] args) { + final String name = method.getName(); + if ("toString".equals(name)) { + return "RestProxy[" + baseUriStr + "]"; + } + if ("hashCode".equals(name)) { + return System.identityHashCode(proxy); + } + if ("equals".equals(name)) { + return args[0] == proxy; + } + throw new UnsupportedOperationException(name); + } + + /** + * Extracts response headers into a {@link MultivaluedMap}. + * + * @param response the HTTP response. + * @return the headers as a multivalued map. + */ + private static MultivaluedMap extractHeaders( + final SimpleHttpResponse response) { + final Header[] allHeaders = response.getHeaders(); + final MultivaluedMap headers = new MultivaluedHashMap<>(allHeaders.length); + for (final Header h : allHeaders) { + headers.add(h.getName(), h.getValue()); + } + return headers; + } + + /** + * How the proxy method's return value is delivered. + */ + enum ReturnMode { + /** Method returns {@code CompletableFuture} or {@code Future}. */ + ASYNC, + /** Method returns a plain type; block until the response arrives. */ + SYNC + } + + /** + * Pre-computed per-method invariants to avoid redundant work on every request. + */ + static final class MethodInvoker { + + /** + * The resource method descriptor. + */ + final ClientResourceMethod resourceMethod; + /** + * Pre-built Accept header value, or null. + */ + final String acceptHeader; + /** + * The first {@code @Consumes} type. + */ + final String consumeType; + /** + * Whether the caller wants a future or a blocking result. + */ + final ReturnMode returnMode; + /** + * The erased result type (unwrapped from Future if async). + */ + final Class resultRawType; + /** + * The full generic result type (unwrapped from Future if async). + */ + final Type resultGenericType; + + MethodInvoker(final ClientResourceMethod rm, final String accept, + final String consume, final ReturnMode mode, + final Class rawType, final Type genericType) { + this.resourceMethod = rm; + this.acceptHeader = accept; + this.consumeType = consume; + this.returnMode = mode; + this.resultRawType = rawType; + this.resultGenericType = genericType; + } + } + +} diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/package-info.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/package-info.java new file mode 100644 index 0000000000..27ff73f890 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/impl/package-info.java @@ -0,0 +1,31 @@ +/* + * ==================================================================== + * 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 + * . + * + */ + +/** + * Internal implementation for the Jakarta REST client proxy. + */ +package org.apache.hc.client5.http.rest.impl; 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..1c8defeebf --- /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 Apache HttpClient async transport. + */ +package org.apache.hc.client5.http.rest; diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/examples/RestClientExample.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/examples/RestClientExample.java new file mode 100644 index 0000000000..76f7ce3191 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/examples/RestClientExample.java @@ -0,0 +1,274 @@ +/* + * ==================================================================== + * 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.examples; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +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.client5.http.rest.RestClientBuilder; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpException; +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.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * This example demonstrates how to use {@link RestClientBuilder} to create a type-safe + * REST client from a Jakarta REST annotated interface. The proxy translates method calls + * into HTTP requests executed through the Apache HttpClient async transport. + */ +public class RestClientExample { + + // -- Domain object -- + + public static class User { + public int id; + public String name; + public String email; + + public User() { + } + + public User(final int id, final String name, final String email) { + this.id = id; + this.name = name; + this.email = email; + } + + @Override + public String toString() { + return "User{id=" + id + + ", name='" + name + '\'' + + ", email='" + email + '\'' + '}'; + } + } + + // -- Jakarta REST annotated interface -- + + @Path("/api/users") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public interface UserApi { + + @GET + List list(); + + @GET + @Path("/{id}") + User get(@PathParam("id") int id); + + @GET + @Path("/search") + @Produces(MediaType.APPLICATION_JSON) + List search(@QueryParam("name") String name); + + @POST + User create(User user); + + @PUT + @Path("/{id}") + User update(@PathParam("id") int id, User user); + + @DELETE + @Path("/{id}") + void delete(@PathParam("id") int id); + } + + // -- Async variant of the same interface -- + + @Path("/api/users") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public interface AsyncUserApi { + + @GET + CompletableFuture> list(); + + @GET + @Path("/{id}") + CompletableFuture get(@PathParam("id") int id); + } + + public static void main(final String[] args) throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + + // Start an embedded HTTP server for demonstration + final HttpServer server = ServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .register("/api/users", new HttpRequestHandler() { + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) + throws HttpException, IOException { + final String method = request.getMethod(); + if ("GET".equals(method)) { + final User[] users = { + new User(1, "Alice", "alice@example.com"), + new User(2, "Bob", "bob@example.com") + }; + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(users), + ContentType.APPLICATION_JSON)); + } else if ("POST".equals(method)) { + response.setCode(201); + response.setEntity(new StringEntity( + mapper.writeValueAsString( + new User(3, "Charlie", "charlie@example.com")), + ContentType.APPLICATION_JSON)); + } + } + }) + .register("/api/users/*", + new HttpRequestHandler() { + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) + throws HttpException, IOException { + final String path = request.getRequestUri(); + final String method = request.getMethod(); + + if (path.contains("/search")) { + final User[] results = { + new User(1, "Alice", "alice@example.com") + }; + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(results), + ContentType.APPLICATION_JSON)); + return; + } + + final String idStr = path.substring( + path.lastIndexOf('/') + 1); + final int id = Integer.parseInt(idStr); + + if ("GET".equals(method)) { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString( + new User(id, "Alice", "alice@example.com")), + ContentType.APPLICATION_JSON)); + } else if ("PUT".equals(method)) { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString( + new User(id, "Updated", "updated@example.com")), + ContentType.APPLICATION_JSON)); + } else if ("DELETE".equals(method)) { + response.setCode(204); + } + } + }) + .create(); + server.start(); + final int port = server.getLocalPort(); + + try (final CloseableHttpAsyncClient client = HttpAsyncClients.createDefault()) { + client.start(); + + // Build the proxy -- one line to turn an interface into a live REST client + final UserApi api = RestClientBuilder.newBuilder() + .baseUri("http://localhost:" + port) + .httpAsyncClient(client) + .build(UserApi.class); + + // List all users (returns List, blocking) + System.out.println("=== List users ==="); + final List users = api.list(); + for (final User u : users) { + System.out.println(" " + u); + } + + // Get a single user + System.out.println("=== Get user 42 ==="); + final User user42 = api.get(42); + System.out.println(" " + user42); + + // Search by name + System.out.println("=== Search 'Alice' ==="); + final List found = api.search("Alice"); + for (final User u : found) { + System.out.println(" " + u); + } + + // Create a new user + System.out.println("=== Create user ==="); + final User created = api.create(new User(0, "Charlie", "charlie@example.com")); + System.out.println(" " + created); + + // Update a user + System.out.println("=== Update user 1 ==="); + final User updated = api.update(1, + new User(0, "Alice Updated", "alice.updated@example.com")); + System.out.println(" " + updated); + + // Delete a user + System.out.println("=== Delete user 2 ==="); + api.delete(2); + System.out.println(" deleted"); + + // Async variant -- CompletableFuture returns, non-blocking + System.out.println("=== Async get ==="); + final AsyncUserApi asyncApi = RestClientBuilder.newBuilder() + .baseUri("http://localhost:" + port) + .httpAsyncClient(client) + .build(AsyncUserApi.class); + final CompletableFuture future = asyncApi.get(7); + System.out.println(" (non-blocking, future returned)"); + final User asyncUser = future.join(); + System.out.println(" " + asyncUser); + + } finally { + server.close(); + } + } + +} 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..5b9256ac3d --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestClientBuilderTest.java @@ -0,0 +1,863 @@ +/* + * ==================================================================== + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import com.fasterxml.jackson.databind.ObjectMapper; + +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.WebApplicationException; +import jakarta.ws.rs.core.GenericType; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpException; +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.HttpRequestHandler; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.http.protocol.HttpContext; +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 httpAsyncClient; + + // --- Sync test interfaces --- + + @Path("/widgets") + @Produces(MediaType.APPLICATION_JSON) + public interface WidgetApi { + + @GET + Widget[] list(); + + @GET + @Path("/{id}") + Widget get(@PathParam("id") int id); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + Widget create(Widget widget); + + @PUT + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + Widget update(@PathParam("id") int id, Widget widget); + + @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); + + @GET + @Path("/{code}/raw") + Response getStatusRaw(@PathParam("code") int code); + } + + @Path("/raw") + @Produces(MediaType.APPLICATION_JSON) + public interface RawJsonApi { + + @GET + @Path("/widget") + Response getWidget(); + + @GET + @Path("/widgets") + Response getWidgets(); + } + + @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("/negotiate") + public interface NegotiateApi { + + @GET + @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML}) + String negotiate(); + } + + @Path("/generic") + @Produces(MediaType.APPLICATION_JSON) + public interface GenericApi { + + @GET + @Path("/list") + List listWidgets(); + + @GET + @Path("/map") + Map widgetMap(); + } + + // --- Async test interfaces --- + + @Path("/widgets") + @Produces(MediaType.APPLICATION_JSON) + public interface AsyncWidgetApi { + + @GET + CompletableFuture list(); + + @GET + @Path("/{id}") + CompletableFuture get(@PathParam("id") int id); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + CompletableFuture create(Widget widget); + } + + @Path("/widgets") + @Produces(MediaType.APPLICATION_JSON) + public interface FutureWidgetApi { + + @GET + @Path("/{id}") + Future get(@PathParam("id") int id); + } + + @Path("/status") + public interface AsyncStatusApi { + + @GET + @Path("/{code}/raw") + CompletableFuture getStatusRaw(@PathParam("code") int code); + } + + @Path("/generic") + @Produces(MediaType.APPLICATION_JSON) + public interface AsyncGenericApi { + + @GET + @Path("/list") + CompletableFuture> listWidgets(); + } + + 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; + } + } + + // --- Server setup --- + + @BeforeAll + static void setUp() throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + server = ServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .register("/widgets", (request, response, context) -> { + final String method = request.getMethod(); + if ("GET".equals(method)) { + final Widget[] widgets = {new Widget(1, "A"), new Widget(2, "B")}; + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(widgets), + ContentType.APPLICATION_JSON)); + } else if ("POST".equals(method)) { + response.setCode(201); + response.setEntity(new StringEntity( + mapper.writeValueAsString(new Widget(99, "Created")), + ContentType.APPLICATION_JSON)); + } + }) + .register("/widgets/*", (request, response, context) -> { + final String path = request.getRequestUri(); + final String idStr = path.substring(path.lastIndexOf('/') + 1); + final int id = Integer.parseInt(idStr); + final String method = request.getMethod(); + if ("GET".equals(method)) { + final Widget w = new Widget(id, "W-" + id); + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(w), + ContentType.APPLICATION_JSON)); + } else if ("PUT".equals(method)) { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(new Widget(id, "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("/negotiate", + (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("/generic/list", + new HttpRequestHandler() { + @Override + public void handle( + final ClassicHttpRequest request, + final ClassicHttpResponse response, + final HttpContext context) + throws HttpException, IOException { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString( + new Widget[]{ + new Widget(1, "X"), + new Widget(2, "Y") + }), + ContentType.APPLICATION_JSON)); + } + }) + .register("/generic/map", + (request, response, context) -> { + response.setCode(200); + response.setEntity(new StringEntity( + "{\"a\":{\"id\":1,\"name\":\"A\"}," + + "\"b\":{\"id\":2,\"name\":\"B\"}}", + ContentType.APPLICATION_JSON)); + }) + .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("/raw/widget", + (request, response, context) -> { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString(new Widget(7, "Raw")), + ContentType.APPLICATION_JSON)); + }) + .register("/raw/widgets", + (request, response, context) -> { + response.setCode(200); + response.setEntity(new StringEntity( + mapper.writeValueAsString( + new Widget[]{ + new Widget(1, "P"), + new Widget(2, "Q") + }), + ContentType.APPLICATION_JSON)); + }) + .register("/status/*", (request, response, context) -> { + final String path = request.getRequestUri(); + final boolean raw = path.endsWith("/raw"); + final String cleaned = raw ? path.replace("/raw", "") : path; + final int code = Integer.parseInt( + cleaned.substring(cleaned.lastIndexOf('/') + 1)); + response.setCode(code); + response.setEntity(new StringEntity( + "status:" + code, ContentType.TEXT_PLAIN)); + }) + .create(); + server.start(); + port = server.getLocalPort(); + httpAsyncClient = HttpAsyncClients.createDefault(); + httpAsyncClient.start(); + } + + @AfterAll + static void tearDown() throws Exception { + if (httpAsyncClient != null) { + httpAsyncClient.close(); + } + if (server != null) { + server.close(); + } + } + + private T proxy(final Class iface) { + return RestClientBuilder.newBuilder() + .baseUri("http://localhost:" + port) + .httpAsyncClient(httpAsyncClient) + .build(iface); + } + + // --- Sync tests --- + + @Test + void testGetSingleWidget() { + final WidgetApi api = proxy(WidgetApi.class); + final Widget w = api.get(42); + assertEquals(42, w.id); + assertEquals("W-42", w.name); + } + + @Test + void testGetWidgetList() { + final WidgetApi api = proxy(WidgetApi.class); + final Widget[] widgets = api.list(); + assertEquals(2, widgets.length); + assertEquals("A", widgets[0].name); + } + + @Test + void testPostWidget() { + final WidgetApi api = proxy(WidgetApi.class); + final Widget created = api.create(new Widget(0, "New")); + assertEquals(99, created.id); + assertEquals("Created", created.name); + } + + @Test + void testPutWidget() { + final WidgetApi api = proxy(WidgetApi.class); + final Widget updated = api.update(7, new Widget(0, "Updated")); + assertEquals(7, updated.id); + assertEquals("Updated", updated.name); + } + + @Test + void testDeleteWidget() { + final WidgetApi api = proxy(WidgetApi.class); + api.delete(1); + } + + @Test + void testQueryParam() { + final EchoApi api = proxy(EchoApi.class); + final String result = api.echo("hello"); + assertEquals("hello", result); + } + + @Test + void testHeaderParam() { + final EchoApi api = proxy(EchoApi.class); + final String result = api.echoHeader("myTag"); + assertEquals("myTag", result); + } + + @Test + void testErrorThrowsWebApplicationException() { + final StatusApi api = proxy(StatusApi.class); + final WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> api.getStatus(404)); + assertEquals(404, ex.getResponse().getStatus()); + } + + @Test + void testRawResponse() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + assertEquals(200, resp.getStatus()); + assertTrue(resp.hasEntity()); + assertEquals("plain", resp.getMediaType().getSubtype()); + } + + @Test + void testRawResponseReadEntity() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + final String body = resp.readEntity(String.class); + assertEquals("status:200", body); + } + + @Test + void testRawResponseError() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(500); + assertEquals(500, resp.getStatus()); + } + + @Test + void testProxyToString() { + final EchoApi api = proxy(EchoApi.class); + assertTrue(api.toString().startsWith("RestProxy[")); + } + + @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 testErrorResponseCarriesStatus() { + final StatusApi api = proxy(StatusApi.class); + final WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> api.getStatus(404)); + assertEquals(404, ex.getResponse().getStatus()); + assertTrue(ex.getResponse().hasEntity()); + } + + @Test + void testGenericListReturn() { + final GenericApi api = proxy(GenericApi.class); + final List widgets = api.listWidgets(); + assertEquals(2, widgets.size()); + assertEquals("X", widgets.get(0).name); + assertEquals("Y", widgets.get(1).name); + } + + @Test + void testGenericMapReturn() { + final GenericApi api = proxy(GenericApi.class); + final Map map = api.widgetMap(); + assertEquals(2, map.size()); + assertEquals("A", map.get("a").name); + assertEquals(2, map.get("b").id); + } + + @Test + void testRejectsNullBaseUri() { + assertThrows(NullPointerException.class, () -> + RestClientBuilder.newBuilder() + .baseUri((String) null)); + } + + @Test + void testRejectsNullHttpAsyncClient() { + assertThrows(NullPointerException.class, () -> + RestClientBuilder.newBuilder() + .httpAsyncClient(null)); + } + + @Test + void testAcceptHeaderWithQValues() { + final NegotiateApi api = proxy(NegotiateApi.class); + final String accept = api.negotiate(); + assertTrue(accept.contains("application/json")); + assertTrue(accept.contains("application/xml")); + assertTrue(accept.contains(";q=")); + } + + @Test + void testAcceptHeaderUnweighted() { + final NegotiateApi api = RestClientBuilder.newBuilder() + .baseUri("http://localhost:" + port) + .httpAsyncClient(httpAsyncClient) + .acceptStrategy(AcceptStrategy.UNWEIGHTED) + .build(NegotiateApi.class); + final String accept = api.negotiate(); + assertTrue(accept.contains("application/json")); + assertTrue(accept.contains("application/xml")); + assertFalse(accept.contains(";q=")); + } + + @Test + void testProxyEqualsItself() { + final EchoApi api = proxy(EchoApi.class); + assertTrue(api.equals(api)); + } + + @Test + void testProxyHashCodeIsStable() { + final EchoApi api = proxy(EchoApi.class); + assertEquals(api.hashCode(), api.hashCode()); + } + + @Test + void testRejectsNonInterface() { + final IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + RestClientBuilder.newBuilder() + .baseUri("http://localhost") + .httpAsyncClient(httpAsyncClient) + .build(String.class)); + assertTrue(ex.getMessage().contains("not an interface")); + } + + @Test + void testRequiresBaseUri() { + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + RestClientBuilder.newBuilder() + .httpAsyncClient(httpAsyncClient) + .build(EchoApi.class)); + assertTrue(ex.getMessage().contains("baseUri")); + } + + @Test + void testRejectsMultipleBodyParams() { + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + RestClientBuilder.newBuilder() + .baseUri("http://localhost") + .httpAsyncClient(httpAsyncClient) + .build(BadMultiBodyApi.class)); + assertTrue(ex.getMessage().contains("body")); + } + + @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); + } + + @Test + void testRequiresHttpAsyncClient() { + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + RestClientBuilder.newBuilder() + .baseUri("http://localhost") + .build(EchoApi.class)); + assertTrue(ex.getMessage().contains("httpAsyncClient")); + } + + @Test + void testReadEntityFromRawResponse() { + final RawJsonApi api = proxy(RawJsonApi.class); + final Response resp = api.getWidget(); + assertEquals(200, resp.getStatus()); + final Widget w = resp.readEntity(Widget.class); + assertEquals(7, w.id); + assertEquals("Raw", w.name); + } + + @Test + void testReadEntityGenericType() { + final RawJsonApi api = proxy(RawJsonApi.class); + final Response resp = api.getWidgets(); + final List widgets = resp.readEntity( + new GenericType>() { + }); + assertEquals(2, widgets.size()); + assertEquals("P", widgets.get(0).name); + assertEquals("Q", widgets.get(1).name); + } + + @Test + void testGetMetadataIsDeepCopy() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + final MultivaluedMap meta1 = resp.getMetadata(); + final MultivaluedMap meta2 = resp.getMetadata(); + assertNotSame(meta2, meta1); + meta1.add("X-Injected", "test"); + assertFalse(resp.getMetadata().containsKey("X-Injected")); + final String ctKey = "Content-Type"; + final List ctValues = resp.getMetadata().get(ctKey); + if (ctValues != null) { + final int sizeBefore = ctValues.size(); + ctValues.add("text/html"); + assertEquals(sizeBefore, resp.getMetadata().get(ctKey).size()); + } + } + + @Test + void testReadEntityBytesFromRawResponse() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + final byte[] bytes = resp.readEntity(byte[].class); + assertEquals("status:200", new String(bytes)); + } + + @Test + void testRejectsPathParamWithNoTemplateVariable() { + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + RestClientBuilder.newBuilder() + .baseUri("http://localhost") + .httpAsyncClient(httpAsyncClient) + .build(BadPathParamApi.class)); + assertTrue(ex.getMessage().contains("@PathParam(\"userId\")")); + assertTrue(ex.getMessage().contains("no matching")); + } + + @Test + void testRejectsTemplateVariableWithNoPathParam() { + final IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + RestClientBuilder.newBuilder() + .baseUri("http://localhost") + .httpAsyncClient(httpAsyncClient) + .build(MissingPathParamApi.class)); + assertTrue(ex.getMessage().contains("{version}")); + assertTrue(ex.getMessage().contains("no matching")); + } + + @Test + void testPathParamWithSpace() { + final EchoPathApi api = proxy(EchoPathApi.class); + final String result = api.echoPath("hello world"); + assertEquals("hello%20world", result); + } + + @Test + void testPathParamWithSlash() { + final EchoPathApi api = proxy(EchoPathApi.class); + final String result = api.echoPath("a/b"); + assertEquals("a%2Fb", result); + } + + @Test + void testPathParamWithUnicode() { + final EchoPathApi api = proxy(EchoPathApi.class); + final String result = api.echoPath("\u00e9"); + assertEquals("%C3%A9", result); + } + + @Test + void testPathParamWithTilde() { + final EchoPathApi api = proxy(EchoPathApi.class); + final String result = api.echoPath("~user"); + assertEquals("~user", result); + } + + @Test + void testDefaultValueParam() { + final DefaultsApi api = proxy(DefaultsApi.class); + final String result = api.withDefault(null); + assertEquals("red", result); + } + + @Test + void testDefaultValueOverridden() { + final DefaultsApi api = proxy(DefaultsApi.class); + final String result = api.withDefault("blue"); + assertEquals("blue", result); + } + + @Test + void testRawResponseGetHeaders() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + assertNotNull(resp.getHeaderString("Content-Type")); + assertFalse(resp.getStringHeaders().isEmpty()); + } + + @Test + void testRawResponseBufferEntity() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + assertTrue(resp.bufferEntity()); + } + + @Test + void testRawResponseGetStatusInfo() { + final StatusApi api = proxy(StatusApi.class); + final Response resp = api.getStatusRaw(200); + assertEquals(200, resp.getStatusInfo().getStatusCode()); + } + + @Test + void testRawResponseNoEntity() { + final WidgetApi api = proxy(WidgetApi.class); + api.delete(1); + } + + @Test + void testErrorResponseReadEntity() { + final StatusApi api = proxy(StatusApi.class); + final WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> api.getStatus(500)); + final Response resp = ex.getResponse(); + final String body = resp.readEntity(String.class); + assertEquals("status:500", body); + } + + // --- Async (CompletableFuture) tests --- + + @Test + void testAsyncGetWidget() throws Exception { + final AsyncWidgetApi api = proxy(AsyncWidgetApi.class); + final CompletableFuture future = api.get(42); + final Widget w = future.get(); + assertEquals(42, w.id); + assertEquals("W-42", w.name); + } + + @Test + void testAsyncGetWidgetList() throws Exception { + final AsyncWidgetApi api = proxy(AsyncWidgetApi.class); + final CompletableFuture future = api.list(); + final Widget[] widgets = future.get(); + assertEquals(2, widgets.length); + assertEquals("A", widgets[0].name); + } + + @Test + void testAsyncPostWidget() throws Exception { + final AsyncWidgetApi api = proxy(AsyncWidgetApi.class); + final CompletableFuture future = api.create(new Widget(0, "New")); + final Widget created = future.get(); + assertEquals(99, created.id); + assertEquals("Created", created.name); + } + + @Test + void testAsyncRawResponse() throws Exception { + final AsyncStatusApi api = proxy(AsyncStatusApi.class); + final CompletableFuture future = api.getStatusRaw(200); + final Response resp = future.get(); + assertEquals(200, resp.getStatus()); + assertEquals("status:200", resp.readEntity(String.class)); + } + + @Test + void testAsyncGenericList() throws Exception { + final AsyncGenericApi api = proxy(AsyncGenericApi.class); + final CompletableFuture> future = api.listWidgets(); + final List widgets = future.get(); + assertEquals(2, widgets.size()); + assertEquals("X", widgets.get(0).name); + } + + @Test + void testAsyncJoin() { + final AsyncWidgetApi api = proxy(AsyncWidgetApi.class); + final Widget w = api.get(7).join(); + assertEquals(7, w.id); + } + + // --- Future (raw) tests --- + + @Test + void testFutureGetWidget() throws Exception { + final FutureWidgetApi api = proxy(FutureWidgetApi.class); + final Future future = api.get(42); + final Widget w = future.get(); + assertEquals(42, w.id); + assertEquals("W-42", w.name); + } + +} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethodTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethodTest.java new file mode 100644 index 0000000000..e0101f666b --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/ClientResourceMethodTest.java @@ -0,0 +1,135 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import 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 testCombineEmptyBase() { + assertEquals("/users", ClientResourceMethod.combinePaths("", "/users")); + } + + @Test + void testCombineNullSub() { + assertEquals("/api", ClientResourceMethod.combinePaths("/api", null)); + } + + @Test + void testCombineEmptySub() { + assertEquals("/api", ClientResourceMethod.combinePaths("/api", "")); + } + + @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+}")); + } + + @Test + void testStripRegexNoVars() { + assertEquals("/plain/path", ClientResourceMethod.stripRegex("/plain/path")); + } + + @Test + void testStripRegexUnclosedBrace() { + assertEquals("/path/{broken", + ClientResourceMethod.stripRegex("/path/{broken")); + } + + // --- 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() { + final Set vars = ClientResourceMethod.extractTemplateVariables("/plain/path"); + assertTrue(vars.isEmpty()); + } + + @Test + void testExtractUnclosedBrace() { + final Set vars = ClientResourceMethod.extractTemplateVariables("/path/{broken"); + assertTrue(vars.isEmpty()); + } + +} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandlerTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandlerTest.java new file mode 100644 index 0000000000..5b7754224e --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/impl/RestInvocationHandlerTest.java @@ -0,0 +1,170 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.hc.client5.http.rest.AcceptStrategy; +import org.junit.jupiter.api.Test; + +class RestInvocationHandlerTest { + + // --- percentEncodePath --- + + @Test + void testEncodeSpace() { + assertEquals("hello%20world", RestInvocationHandler.percentEncodePath("hello world")); + } + + @Test + void testEncodeSlash() { + assertEquals("a%2Fb", RestInvocationHandler.percentEncodePath("a/b")); + } + + @Test + void testEncodeUnicodeAccent() { + assertEquals("%C3%A9", RestInvocationHandler.percentEncodePath("\u00e9")); + } + + @Test + void testEncodeUnicodeCjk() { + assertEquals("%E4%B8%96", RestInvocationHandler.percentEncodePath("\u4e16")); + } + + @Test + void testEncodeTildeIsUnreserved() { + assertEquals("~user", RestInvocationHandler.percentEncodePath("~user")); + } + + @Test + void testEncodeHyphenDotUnderscore() { + assertEquals("a-b.c_d", RestInvocationHandler.percentEncodePath("a-b.c_d")); + } + + @Test + void testEncodeAlphaNumericPassthrough() { + assertEquals("AZaz09", RestInvocationHandler.percentEncodePath("AZaz09")); + } + + @Test + void testEncodeEmptyString() { + assertEquals("", RestInvocationHandler.percentEncodePath("")); + } + + @Test + void testEncodeAtSign() { + assertEquals("user%40host", RestInvocationHandler.percentEncodePath("user@host")); + } + + @Test + void testEncodePercent() { + assertEquals("100%25", RestInvocationHandler.percentEncodePath("100%")); + } + + // --- expandTemplate --- + + @Test + void testExpandSingleVariable() { + final Map vars = + Collections.singletonMap("id", "42"); + assertEquals("/items/42", RestInvocationHandler.expandTemplate("/items/{id}", vars)); + } + + @Test + void testExpandMultipleVariables() { + final Map vars = new LinkedHashMap<>(); + vars.put("group", "admin"); + vars.put("id", "7"); + assertEquals("/admin/users/7", RestInvocationHandler.expandTemplate("/{group}/users/{id}", vars)); + } + + @Test + void testExpandPreStrippedTemplate() { + final Map vars = + Collections.singletonMap("id", "42"); + assertEquals("/items/42", RestInvocationHandler.expandTemplate("/items/{id}", vars)); + } + + @Test + void testExpandEncodesValues() { + final Map vars = + Collections.singletonMap("name", "hello world"); + assertEquals("/items/hello%20world", RestInvocationHandler.expandTemplate("/items/{name}", vars)); + } + + @Test + void testExpandNoVariables() { + assertEquals("/plain", RestInvocationHandler.expandTemplate("/plain", Collections.emptyMap())); + } + + // --- AcceptStrategy.QUALITY_DESCENDING --- + + @Test + void testQualityDescendingSingleType() { + assertEquals("application/json", AcceptStrategy.QUALITY_DESCENDING.buildAcceptHeader(new String[]{"application/json"})); + } + + @Test + void testQualityDescendingMultipleTypes() { + final String result = + AcceptStrategy.QUALITY_DESCENDING.buildAcceptHeader( + new String[]{ + "application/json", + "application/xml", + "text/plain"}); + assertTrue(result.contains("application/json;q=1.0")); + assertTrue(result.contains("application/xml;q=0.9")); + assertTrue(result.contains("text/plain;q=0.8")); + } + + // --- AcceptStrategy.UNWEIGHTED --- + + @Test + void testUnweightedSingleType() { + assertEquals("application/json", + AcceptStrategy.UNWEIGHTED.buildAcceptHeader( + new String[]{"application/json"})); + } + + @Test + void testUnweightedMultipleTypes() { + final String result = + AcceptStrategy.UNWEIGHTED.buildAcceptHeader( + new String[]{ + "application/json", + "application/xml"}); + assertEquals("application/json, application/xml", result); + assertFalse(result.contains(";q=")); + } + +} diff --git a/pom.xml b/pom.xml index ade28c3889..ce154efd1a 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 @@ -266,6 +268,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 +287,7 @@ httpclient5-observation httpclient5-fluent httpclient5-cache + httpclient5-jakarta-rest-client httpclient5-testing