diff --git a/httpclient5-jakarta-rest-client/pom.xml b/httpclient5-jakarta-rest-client/pom.xml
index 23b0e4b56f..98cd630d01 100644
--- a/httpclient5-jakarta-rest-client/pom.xml
+++ b/httpclient5-jakarta-rest-client/pom.xml
@@ -83,6 +83,11 @@
log4j-core
test
+
+ org.assertj
+ assertj-core
+ test
+
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java
deleted file mode 100644
index 4e328ed83e..0000000000
--- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java
+++ /dev/null
@@ -1,357 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.rest;
-
-import java.lang.annotation.Annotation;
-import java.lang.reflect.Method;
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
-
-import jakarta.ws.rs.Consumes;
-import jakarta.ws.rs.DefaultValue;
-import jakarta.ws.rs.HeaderParam;
-import jakarta.ws.rs.HttpMethod;
-import jakarta.ws.rs.Path;
-import jakarta.ws.rs.PathParam;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.QueryParam;
-
-/**
- * Describes a single method on a Jakarta REST annotated client interface together with its
- * HTTP method, URI template, content types and parameter extraction rules.
- */
-final class ClientResourceMethod {
-
- enum ParamSource {
- PATH,
- QUERY,
- HEADER,
- BODY
- }
-
- static final class ParamInfo {
-
- private final ParamSource source;
- private final String name;
- private final String defaultValue;
-
- ParamInfo(final ParamSource paramSource, final String paramName,
- final String defValue) {
- this.source = paramSource;
- this.name = paramName;
- this.defaultValue = defValue;
- }
-
- ParamSource getSource() {
- return source;
- }
-
- String getName() {
- return name;
- }
-
- String getDefaultValue() {
- return defaultValue;
- }
- }
-
- private final Method method;
- private final String httpMethod;
- private final String pathTemplate;
- private final String[] produces;
- private final String[] consumes;
- private final ParamInfo[] parameters;
- private final int pathParamCount;
- private final int queryParamCount;
- private final int headerParamCount;
-
- ClientResourceMethod(final Method m, final String verb, final String path,
- final String[] prod, final String[] cons,
- final ParamInfo[] params) {
- this.method = m;
- this.httpMethod = verb;
- this.pathTemplate = path;
- this.produces = prod;
- this.consumes = cons;
- this.parameters = params;
- int pathCount = 0;
- int queryCount = 0;
- int headerCount = 0;
- for (final ParamInfo pi : params) {
- switch (pi.getSource()) {
- case PATH:
- pathCount++;
- break;
- case QUERY:
- queryCount++;
- break;
- case HEADER:
- headerCount++;
- break;
- default:
- break;
- }
- }
- this.pathParamCount = pathCount;
- this.queryParamCount = queryCount;
- this.headerParamCount = headerCount;
- }
-
- Method getMethod() {
- return method;
- }
-
- String getHttpMethod() {
- return httpMethod;
- }
-
- String getPathTemplate() {
- return pathTemplate;
- }
-
- String[] getProduces() {
- return produces;
- }
-
- String[] getConsumes() {
- return consumes;
- }
-
- ParamInfo[] getParameters() {
- return parameters;
- }
-
- int getPathParamCount() {
- return pathParamCount;
- }
-
- int getQueryParamCount() {
- return queryParamCount;
- }
-
- int getHeaderParamCount() {
- return headerParamCount;
- }
-
- static List scan(final Class> iface) {
- final Path classPath = iface.getAnnotation(Path.class);
- final String basePath = classPath != null ? classPath.value() : "";
- final Produces classProduces = iface.getAnnotation(Produces.class);
- final Consumes classConsumes = iface.getAnnotation(Consumes.class);
-
- final List result = new ArrayList<>();
- for (final Method m : iface.getMethods()) {
- final String verb = resolveHttpMethod(m);
- if (verb == null) {
- continue;
- }
- final Path methodPath = m.getAnnotation(Path.class);
- final String combinedPath = combinePaths(basePath,
- methodPath != null ? methodPath.value() : null);
-
- final Produces mp = m.getAnnotation(Produces.class);
- final String[] prod = mp != null ? mp.value()
- : classProduces != null
- ? classProduces.value()
- : new String[0];
-
- final Consumes mc = m.getAnnotation(Consumes.class);
- final String[] cons = mc != null
- ? mc.value()
- : classConsumes != null
- ? classConsumes.value()
- : new String[0];
-
- final ParamInfo[] params = scanParameters(m);
- validatePathParams(m, combinedPath, params);
- validateConsumes(m, cons, params);
- final String strippedPath = stripRegex(combinedPath);
- result.add(new ClientResourceMethod(m, verb, strippedPath, prod, cons, params));
- }
- return result;
- }
-
- private static String resolveHttpMethod(final Method m) {
- for (final Annotation a : m.getAnnotations()) {
- final HttpMethod hm = a.annotationType().getAnnotation(HttpMethod.class);
- if (hm != null) {
- return hm.value();
- }
- }
- return null;
- }
-
- private static ParamInfo[] scanParameters(final Method m) {
- final Annotation[][] annotations = m.getParameterAnnotations();
- final ParamInfo[] result = new ParamInfo[annotations.length];
- int bodyCount = 0;
- for (int i = 0; i < annotations.length; i++) {
- result[i] = resolveParam(annotations[i]);
- if (result[i].getSource() == ParamSource.BODY) {
- bodyCount++;
- }
- }
- if (bodyCount > 1) {
- throw new IllegalStateException("Method " + m.getName()
- + " has " + bodyCount + " unannotated (body) parameters;"
- + " at most one is allowed");
- }
- return result;
- }
-
- private static ParamInfo resolveParam(final Annotation[] annotations) {
- String defVal = null;
- for (final Annotation a : annotations) {
- if (a instanceof DefaultValue) {
- defVal = ((DefaultValue) a).value();
- }
- }
- for (final Annotation a : annotations) {
- if (a instanceof PathParam) {
- return new ParamInfo(ParamSource.PATH, ((PathParam) a).value(), defVal);
- }
- if (a instanceof QueryParam) {
- return new ParamInfo(ParamSource.QUERY, ((QueryParam) a).value(), defVal);
- }
- if (a instanceof HeaderParam) {
- return new ParamInfo(ParamSource.HEADER, ((HeaderParam) a).value(), defVal);
- }
- }
- return new ParamInfo(ParamSource.BODY, null, null);
- }
-
- private static void validatePathParams(final Method m, final String path,
- final ParamInfo[] params) {
- final Set templateVars = extractTemplateVariables(path);
- final Set paramNames = new LinkedHashSet<>();
- for (final ParamInfo pi : params) {
- if (pi.getSource() == ParamSource.PATH) {
- paramNames.add(pi.getName());
- }
- }
- for (final String name : paramNames) {
- if (!templateVars.contains(name)) {
- throw new IllegalStateException("Method " + m.getName()
- + ": @PathParam(\"" + name + "\") has no matching {"
- + name + "} in path \"" + path + "\"");
- }
- }
- for (final String name : templateVars) {
- if (!paramNames.contains(name)) {
- throw new IllegalStateException("Method " + m.getName()
- + ": path variable {" + name + "} has no matching"
- + " @PathParam in path \"" + path + "\"");
- }
- }
- }
-
- private static void validateConsumes(final Method m,
- final String[] consumes,
- final ParamInfo[] params) {
- if (consumes.length <= 1) {
- return;
- }
- for (final ParamInfo pi : params) {
- if (pi.getSource() == ParamSource.BODY) {
- throw new IllegalStateException("Method " + m.getName()
- + " has a request body and multiple @Consumes"
- + " values; exactly one is required");
- }
- }
- }
-
- static Set extractTemplateVariables(final String template) {
- final Set vars = new LinkedHashSet<>();
- int i = 0;
- while (i < template.length()) {
- if (template.charAt(i) == '{') {
- final int close = template.indexOf('}', i);
- if (close < 0) {
- break;
- }
- final String content = template.substring(i + 1, close);
- final int colon = content.indexOf(':');
- final String name = colon >= 0
- ? content.substring(0, colon).trim() : content.trim();
- if (!name.isEmpty()) {
- vars.add(name);
- }
- i = close + 1;
- } else {
- i++;
- }
- }
- return vars;
- }
-
- static String stripRegex(final String template) {
- final StringBuilder sb = new StringBuilder(template.length());
- int i = 0;
- while (i < template.length()) {
- final char c = template.charAt(i);
- if (c == '{') {
- final int close = template.indexOf('}', i);
- if (close < 0) {
- sb.append(c);
- i++;
- continue;
- }
- final String content = template.substring(i + 1, close);
- final int colon = content.indexOf(':');
- if (colon >= 0) {
- sb.append('{');
- sb.append(content, 0, colon);
- sb.append('}');
- } else {
- sb.append(template, i, close + 1);
- }
- i = close + 1;
- } else {
- sb.append(c);
- i++;
- }
- }
- return sb.toString();
- }
-
- static String combinePaths(final String base, final String sub) {
- if (sub == null || sub.isEmpty()) {
- if (base.isEmpty()) {
- return "/";
- }
- return base.startsWith("/") ? base : "/" + base;
- }
- final String left = base.endsWith("/")
- ? base.substring(0, base.length() - 1) : base;
- final String right = sub.startsWith("/") ? sub : "/" + sub;
- final String combined = left + right;
- return combined.startsWith("/") ? combined : "/" + combined;
- }
-
-}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/PathSegment.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/PathSegment.java
new file mode 100644
index 0000000000..773e5e595c
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/PathSegment.java
@@ -0,0 +1,93 @@
+/*
+ * ====================================================================
+ * 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 org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.LangUtils;
+
+final class PathSegment {
+
+ enum Type {
+ VALUE,
+ PARAMETER
+ }
+
+ static PathSegment asValue(final String value) {
+ return new PathSegment(value, Type.VALUE);
+ }
+
+ static PathSegment asParam(final String param) {
+ return new PathSegment(param, Type.PARAMETER);
+ }
+
+ private final String segment;
+ private final Type type;
+
+ PathSegment(final String segment, final Type type) {
+ this.segment = Args.notNull(segment, "Path segment");
+ this.type = Args.notNull(type, "Path segment type");
+ }
+
+ public String getSegment() {
+ return segment;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof PathSegment) {
+ final PathSegment that = (PathSegment) obj;
+ return this.segment.equals(that.segment) &&
+ this.type.equals(that.type);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = LangUtils.HASH_SEED;
+ hash = LangUtils.hashCode(hash, this.segment);
+ hash = LangUtils.hashCode(hash, this.type);
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ if (type == Type.PARAMETER) {
+ return "{" + segment + "}";
+ } else {
+ return segment;
+ }
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java
new file mode 100644
index 0000000000..21d26f1663
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java
@@ -0,0 +1,227 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.rest;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+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 org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.message.MessageSupport;
+import org.apache.hc.core5.http.message.ParserCursor;
+import org.apache.hc.core5.net.URLEncodedUtils;
+import org.apache.hc.core5.util.Args;
+
+final class ResourceIfaceScanner {
+
+ static List scan(final Class> iface) {
+ final Path classPath = iface.getAnnotation(Path.class);
+ final List classPathSegments = parsePathSegments(classPath != null ? classPath.value() : null);
+ final Produces classProduces = iface.getAnnotation(Produces.class);
+ final List classProducesTypes = parseMediaTypes(classProduces != null ? classProduces.value() : null);
+ final Consumes classConsumes = iface.getAnnotation(Consumes.class);
+ final List classConsumesTypes = parseMediaTypes(classConsumes != null ? classConsumes.value() : null);
+ final Method[] methods = iface.getMethods();
+ final List definitions = new ArrayList<>(methods.length);
+ for (final Method m : methods) {
+ final Path methodPath = m.getAnnotation(Path.class);
+ final HttpMethod httpMethod = resolveHttpMethod(m);
+ if (httpMethod == null && methodPath == null) {
+ continue;
+ }
+ final List methodPathSegments = parsePathSegments(methodPath != null ? methodPath.value() : null);
+ final Produces methodProduces = m.getAnnotation(Produces.class);
+ final List methodProducesTypes = parseMediaTypes(methodProduces != null ? methodProduces.value() : null);
+ final Consumes methodConsumes = m.getAnnotation(Consumes.class);
+ final List methodConsumesTypes = parseMediaTypes(methodConsumes != null ? methodConsumes.value() : null);
+
+ final List fullPathSegments = join(classPathSegments, methodPathSegments);
+
+ final Annotation[][] annotations = m.getParameterAnnotations();
+ final ResourceParam[] params = new ResourceParam[annotations.length];
+ for (int i = 0; i < annotations.length; i++) {
+ params[i] = resolveParam(annotations[i]);
+ }
+ validateParams(m, params, fullPathSegments);
+ final ResourceMethod definition = new ResourceMethod(
+ m,
+ httpMethod != null ? httpMethod.value() : "GET",
+ fullPathSegments,
+ methodProducesTypes != null ? methodProducesTypes : classProducesTypes,
+ methodConsumesTypes != null ? methodConsumesTypes : classConsumesTypes,
+ params
+ );
+ definitions.add(definition);
+ }
+ return definitions;
+ }
+
+ private static HttpMethod resolveHttpMethod(final Method m) {
+ for (final Annotation a : m.getAnnotations()) {
+ final HttpMethod hm = a.annotationType().getAnnotation(HttpMethod.class);
+ if (hm != null) {
+ return hm;
+ }
+ }
+ return null;
+ }
+
+ // URLEncodedUtils to be replaced by a utility class from core
+ @SuppressWarnings("deprecated")
+ static List parsePathSegments(final String value) {
+ if (value == null) {
+ return Collections.emptyList();
+ }
+ return URLEncodedUtils.parsePathSegments(value).stream()
+ .map(e -> {
+ if (e.startsWith("{") && e.endsWith("}")) {
+ final String param = e.substring(1, e.length() - 1);
+ if (param.contains(":")) {
+ throw new RestResourceException("Path parameters with regex not supported");
+ }
+ return new PathSegment(param, PathSegment.Type.PARAMETER);
+ } else {
+ return new PathSegment(e, PathSegment.Type.VALUE);
+ }
+ }).collect(Collectors.toList());
+ }
+
+ static List parseMediaTypes(final String... mediaTypes) {
+ if (mediaTypes == null || mediaTypes.length == 0) {
+ return null;
+ }
+ final List contentTypes = new ArrayList<>(mediaTypes.length);
+ for (final String s : mediaTypes) {
+ final ParserCursor cursor = new ParserCursor(0, s.length());
+ MessageSupport.parseElements(s, cursor, elem -> {
+ final String mimeType = elem.getName();
+ if (!Args.isEmpty(mimeType)) {
+ final ContentType contentType = ContentType.create(mimeType, elem.getParameters());
+ if (!contentType.isSameMimeType(ContentType.APPLICATION_JSON) &&
+ !contentType.isSameMimeType(ContentType.TEXT_PLAIN) &&
+ !contentType.isSameMimeType(ContentType.APPLICATION_OCTET_STREAM)) {
+ throw new RestResourceException("Unsupported media type: " + contentType);
+ }
+ contentTypes.add(contentType);
+ }
+ });
+ }
+ return contentTypes;
+ }
+
+ static List join(
+ final List list1,
+ final List list2) {
+ final LinkedList joint = new LinkedList<>();
+ if (list1 != null && !list1.isEmpty()) {
+ joint.addAll(list1);
+ }
+ if (list2 != null && !list2.isEmpty()) {
+ final PathSegment lastSegment = joint.peekLast();
+ if (lastSegment != null && lastSegment.getSegment().isEmpty()) {
+ joint.removeLast();
+ }
+ joint.addAll(list2);
+ }
+ return joint;
+ }
+
+ private static ResourceParam resolveParam(final Annotation[] annotations) {
+ String defaultValue = null;
+ for (final Annotation a : annotations) {
+ if (a instanceof DefaultValue) {
+ defaultValue = ((DefaultValue) a).value();
+ }
+ }
+ for (final Annotation a : annotations) {
+ if (a instanceof PathParam) {
+ return new ResourceParam(((PathParam) a).value(), ResourceParam.Type.PATH, defaultValue);
+ }
+ if (a instanceof QueryParam) {
+ return new ResourceParam(((QueryParam) a).value(), ResourceParam.Type.QUERY, defaultValue);
+ }
+ if (a instanceof HeaderParam) {
+ return new ResourceParam(((HeaderParam) a).value(), ResourceParam.Type.HEADER, defaultValue);
+ }
+ }
+ return new ResourceParam("body", ResourceParam.Type.BODY, null);
+ }
+
+ private static void validateParams(
+ final Method m,
+ final ResourceParam[] params,
+ final List pathSegments) {
+ int bodyCount = 0;
+ final Set pathParams = new HashSet<>(params.length);
+ for (final ResourceParam param : params) {
+ if (param.getType() == ResourceParam.Type.BODY) {
+ bodyCount++;
+ }
+ if (param.getType() == ResourceParam.Type.PATH) {
+ pathParams.add(param.getName());
+ }
+ }
+ if (bodyCount > 1) {
+ throw new RestResourceException("Method '" + m.getName()
+ + "': there are " + bodyCount + " unannotated (body) parameters;"
+ + " at most one is allowed");
+ }
+ final Set templateVars = new HashSet<>(params.length);
+ for (final PathSegment pathSegment : pathSegments) {
+ if (pathSegment.getType() == PathSegment.Type.PARAMETER) {
+ templateVars.add(pathSegment.getSegment());
+ }
+ }
+ for (final String pathParam : pathParams) {
+ if (!templateVars.contains(pathParam)) {
+ throw new RestResourceException("Method '" + m.getName()
+ + "': path parameter '" + pathParam + "' has no matching annotated method argument");
+ }
+ }
+ for (final String templateVar : templateVars) {
+ if (!pathParams.contains(templateVar)) {
+ throw new RestResourceException("Method '" + m.getName()
+ + "': there is no path parameter '" + templateVar + "' matching annotated method argument");
+ }
+ }
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceMethod.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceMethod.java
new file mode 100644
index 0000000000..885c9d915f
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceMethod.java
@@ -0,0 +1,93 @@
+/*
+ * ====================================================================
+ * 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.util.Arrays;
+import java.util.List;
+
+import org.apache.hc.core5.http.ContentType;
+
+final class ResourceMethod {
+
+ private final Method method;
+ private final String httpMethod;
+ private final List pathSegments;
+ private final List producesContentTypes;
+ private final List consumesContentTypes;
+ private final ResourceParam[] params;
+
+ ResourceMethod(final Method method,
+ final String httpMethod,
+ final List pathSegments,
+ final List producesContentTypes,
+ final List consumesContentTypes,
+ final ResourceParam[] params) {
+ this.method = method;
+ this.httpMethod = httpMethod;
+ this.pathSegments = pathSegments;
+ this.producesContentTypes = producesContentTypes;
+ this.consumesContentTypes = consumesContentTypes;
+ this.params = params;
+ }
+
+ public Method getMethod() {
+ return method;
+ }
+
+ public String getHttpMethod() {
+ return httpMethod;
+ }
+
+ public List getPathSegments() {
+ return pathSegments;
+ }
+
+ public List getProducesContentTypes() {
+ return producesContentTypes;
+ }
+
+ public List getConsumesContentTypes() {
+ return consumesContentTypes;
+ }
+
+ public ResourceParam[] getParams() {
+ return params;
+ }
+
+ @Override
+ public String toString() {
+ return "ResourceMethod{" +
+ "method=" + method +
+ ", pathSegments=" + pathSegments +
+ ", producesContentTypes=" + producesContentTypes +
+ ", consumesContentTypes=" + consumesContentTypes +
+ ", params=" + Arrays.toString(params) +
+ '}';
+ }
+
+}
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceParam.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceParam.java
new file mode 100644
index 0000000000..97bd591739
--- /dev/null
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceParam.java
@@ -0,0 +1,93 @@
+/*
+ * ====================================================================
+ * 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 org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.LangUtils;
+
+final class ResourceParam {
+
+ enum Type {
+ PATH,
+ QUERY,
+ HEADER,
+ BODY
+ }
+
+ private final String name;
+ private final Type type;
+ private final String defaultValue;
+
+ ResourceParam(final String name, final Type type, final String defaultValue) {
+ this.name = Args.notEmpty(name, "Param name");
+ this.type = Args.notNull(type, "Param type");
+ this.defaultValue = defaultValue;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDefaultValue() {
+ return defaultValue;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj instanceof ResourceParam) {
+ final ResourceParam that = (ResourceParam) obj;
+ return this.name.equals(that.name) &&
+ this.type.equals(that.type);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = LangUtils.HASH_SEED;
+ hash = LangUtils.hashCode(hash, this.name);
+ hash = LangUtils.hashCode(hash, this.type);
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return "ResourceParam{" +
+ "name='" + name + '\'' +
+ ", type=" + type +
+ ", defaultValue='" + defaultValue + '\'' +
+ '}';
+ }
+
+}
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
index 2a9d3a8583..f72a738b7b 100644
--- 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
@@ -29,9 +29,9 @@
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 java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -156,33 +156,26 @@ public RestClientBuilder objectMapper(final ObjectMapper mapper) {
* @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.
+ * @throws RestResourceException if the interface violates Jakarta REST contract.
*/
@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");
- }
+ Args.check(iface.isInterface(), "%s is not an interface", iface.getName());
if (baseUri == null) {
throw new IllegalStateException("baseUri is required");
}
if (httpClient == null) {
throw new IllegalStateException("httpClient is required");
}
-
- final List methods = ClientResourceMethod.scan(iface);
+ final List methods = ResourceIfaceScanner.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);
+ throw new RestResourceException("No Jakarta REST methods found on interface '" + iface.getName() + "'");
}
+ final Map methodMap = methods.stream()
+ .collect(Collectors.toMap(ResourceMethod::getMethod, e -> e));
- final ObjectMapper mapper = objectMapper != null
- ? objectMapper : new ObjectMapper();
+ final ObjectMapper mapper = objectMapper != null ? objectMapper : new ObjectMapper();
return (T) Proxy.newProxyInstance(
iface.getClassLoader(),
diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
index e2d01772d9..5858f4a650 100644
--- a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
+++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestInvocationHandler.java
@@ -36,27 +36,30 @@
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.HashMap;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.Response;
-
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
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;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.Message;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.http.message.MessageSupport;
import org.apache.hc.core5.http.nio.AsyncEntityProducer;
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
@@ -83,68 +86,15 @@ final class RestInvocationHandler implements InvocationHandler {
private final CloseableHttpAsyncClient httpClient;
private final URI baseUri;
private final ObjectMapper objectMapper;
- private final Map invokerMap;
+ private final Map methodMap;
RestInvocationHandler(final CloseableHttpAsyncClient client, final URI base,
- final Map methods,
+ final Map methodMap,
final ObjectMapper mapper) {
this.httpClient = client;
this.baseUri = base;
this.objectMapper = mapper;
- this.invokerMap = buildInvokers(methods);
- }
-
- private static Map buildInvokers(
- final Map methods) {
- final Map result = new HashMap<>(methods.size());
- for (final Map.Entry entry : methods.entrySet()) {
- final ClientResourceMethod rm = entry.getValue();
- final String acceptHeader = rm.getProduces().length > 0 ? joinMediaTypes(rm.getProduces()) : null;
- final ContentType consumeType = rm.getConsumes().length > 0 ? ContentType.parse(rm.getConsumes()[0]) : null;
- final boolean async = isAsync(rm.getMethod());
- final Class> responseType = resolveResponseType(rm.getMethod(), async);
- result.put(entry.getKey(), new MethodInvoker(rm, acceptHeader, consumeType, responseType, async));
- }
- return result;
- }
-
- private static boolean isAsync(final Method method) {
- final Class> rt = method.getReturnType();
- return rt == CompletionStage.class || rt == CompletableFuture.class;
- }
-
- private static Class> resolveResponseType(final Method method, final boolean async) {
- if (!async) {
- return method.getReturnType();
- }
- final Type generic = method.getGenericReturnType();
- if (generic instanceof ParameterizedType) {
- final Type inner = ((ParameterizedType) generic).getActualTypeArguments()[0];
- if (inner instanceof Class) {
- return (Class>) inner;
- }
- if (inner instanceof ParameterizedType) {
- final Type raw = ((ParameterizedType) inner).getRawType();
- if (raw instanceof Class) {
- return (Class>) raw;
- }
- }
- }
- return Object.class;
- }
-
- private static String joinMediaTypes(final String[] types) {
- if (types.length == 1) {
- return types[0];
- }
- final StringBuilder sb = new StringBuilder();
- for (final String type : types) {
- if (sb.length() > 0) {
- sb.append(", ");
- }
- sb.append(type);
- }
- return sb.toString();
+ this.methodMap = methodMap;
}
@Override
@@ -153,91 +103,120 @@ public Object invoke(final Object proxy, final Method method,
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());
+ final ResourceMethod resourceMethod = methodMap.get(method);
+ if (resourceMethod == null) {
+ throw new RestResourceException("No Jakarta REST mapping for " + method.getName());
}
- return executeRequest(invoker, args);
+ return executeRequest(resourceMethod, args);
}
- private Object executeRequest(final MethodInvoker invoker,
+ private Object executeRequest(final ResourceMethod rm,
final Object[] args) {
- final ClientResourceMethod rm = invoker.resourceMethod;
- final ClientResourceMethod.ParamInfo[] params = rm.getParameters();
- final Map pathParams = rm.getPathParamCount() > 0
- ? new LinkedHashMap<>(rm.getPathParamCount()) : Collections.emptyMap();
- final Map> queryParams = rm.getQueryParamCount() > 0
- ? new LinkedHashMap<>(rm.getQueryParamCount()) : Collections.emptyMap();
- final Map headerParams = rm.getHeaderParamCount() > 0
- ? new LinkedHashMap<>(rm.getHeaderParamCount()) : Collections.emptyMap();
+ final ResourceParam[] params = rm.getParams();
+ final Map pathParams = new HashMap<>(params.length);
+ final List queryParams = new ArrayList<>(params.length);
+ final List headers = new ArrayList<>(params.length);
Object bodyParam = null;
if (args != null) {
for (int i = 0; i < params.length; i++) {
- final ClientResourceMethod.ParamInfo pi = params[i];
- final Object val = args[i];
- final String strVal = val != null ? paramToString(val) : pi.getDefaultValue();
- switch (pi.getSource()) {
+ final ResourceParam param = params[i];
+ final String paramName = param.getName();
+ final Object arg = args[i];
+ final String paramValue = arg != null ? paramToString(arg) : param.getDefaultValue();
+ switch (param.getType()) {
case PATH:
- if (strVal == null) {
- throw new IllegalArgumentException(
- "Path parameter \"" + pi.getName()
- + "\" must not be null");
- }
- pathParams.put(pi.getName(), strVal);
+ Args.check(paramValue != null, "Path parameter '%s' must not be null", param.getName());
+ pathParams.put(paramName, paramValue);
break;
case QUERY:
- if (strVal != null) {
- queryParams.computeIfAbsent(pi.getName(),
- k -> new ArrayList<>()).add(strVal);
- }
+ queryParams.add(new BasicNameValuePair(paramName, paramValue));
break;
case HEADER:
- if (strVal != null) {
- headerParams.put(pi.getName(), strVal);
- }
+ headers.add(new BasicHeader(paramName, paramValue));
break;
case BODY:
- bodyParam = val;
- break;
- default:
+ bodyParam = arg;
break;
}
}
}
-
- final URI requestUri = buildRequestUri(rm.getPathTemplate(), pathParams, queryParams);
- final BasicHttpRequest request =
- new BasicHttpRequest(rm.getHttpMethod(), requestUri);
-
- if (invoker.acceptHeader != null) {
- request.addHeader(HttpHeaders.ACCEPT, invoker.acceptHeader);
+ final URI requestUri;
+ try {
+ final URIBuilder uriBuilder = new URIBuilder(baseUri);
+ final List pathTemplates = rm.getPathSegments();
+ final List pathSegments = new ArrayList<>(pathTemplates.size());
+ for (final PathSegment pathTemplate : pathTemplates) {
+ if (pathTemplate.getType() == PathSegment.Type.PARAMETER) {
+ pathSegments.add(pathParams.get(pathTemplate.getSegment()));
+ } else {
+ pathSegments.add(pathTemplate.getSegment());
+ }
+ }
+ uriBuilder.appendPathSegments(pathSegments);
+ uriBuilder.addParameters(queryParams);
+ requestUri = uriBuilder.build();
+ } catch (final URISyntaxException ex) {
+ throw new RestResourceException("Invalid request URI", ex);
}
- for (final Map.Entry entry : headerParams.entrySet()) {
- request.addHeader(entry.getKey(), entry.getValue());
+ final BasicHttpRequest request = new BasicHttpRequest(rm.getHttpMethod(), requestUri);
+ for (final Header header : headers) {
+ request.addHeader(header);
+ }
+ final List consumesContentTypes = rm.getConsumesContentTypes();
+ if (consumesContentTypes != null && !consumesContentTypes.isEmpty()) {
+ request.setHeader(MessageSupport.headerOfTokens(
+ HttpHeaders.ACCEPT,
+ consumesContentTypes.stream()
+ .map(ContentType::getMimeType)
+ .collect(Collectors.toList())));
}
-
final AsyncEntityProducer entityProducer;
if (bodyParam != null) {
- entityProducer = createEntityProducer(bodyParam, invoker.consumeType);
+ entityProducer = createEntityProducer(bodyParam,
+ consumesContentTypes != null && !consumesContentTypes.isEmpty() ? consumesContentTypes.get(0) : null);
} else {
entityProducer = null;
}
- final BasicRequestProducer requestProducer =
- new BasicRequestProducer(request, entityProducer);
- final CompletableFuture