From 10888dc55fb7e08663a2a547eae291a5776eb3a3 Mon Sep 17 00:00:00 2001 From: Oleg Kalnichevski Date: Mon, 18 May 2026 09:18:07 +0200 Subject: [PATCH] REST interface scanner code improvement --- httpclient5-jakarta-rest-client/pom.xml | 5 + .../http/rest/ClientResourceMethod.java | 357 ------------------ .../hc/client5/http/rest/PathSegment.java | 93 +++++ .../http/rest/ResourceIfaceScanner.java | 227 +++++++++++ .../hc/client5/http/rest/ResourceMethod.java | 93 +++++ .../hc/client5/http/rest/ResourceParam.java | 93 +++++ .../client5/http/rest/RestClientBuilder.java | 23 +- .../http/rest/RestInvocationHandler.java | 295 +++++---------- .../http/rest/RestResourceException.java | 44 +++ .../http/rest/ClientResourceMethodTest.java | 143 ------- .../http/rest/ResourceIfaceScannerTest.java | 291 ++++++++++++++ .../http/rest/RestClientBuilderTest.java | 8 +- .../http/rest/RestInvocationHandlerTest.java | 73 ---- pom.xml | 6 + 14 files changed, 954 insertions(+), 797 deletions(-) delete mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ClientResourceMethod.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/PathSegment.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceIfaceScanner.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceMethod.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/ResourceParam.java create mode 100644 httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResourceException.java delete mode 100644 httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java create mode 100644 httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ResourceIfaceScannerTest.java 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 future = dispatchAsync(invoker, requestProducer); - if (invoker.async) { + final BasicRequestProducer requestProducer = new BasicRequestProducer(request, entityProducer); + + final boolean isAsync = isAsync(rm.getMethod()); + final Class rawType = resolveResponseType(rm.getMethod(), isAsync); + final CompletableFuture future = dispatchAsync(rawType, requestProducer); + if (isAsync) { return future; } return awaitSync(future); } - private CompletableFuture dispatchAsync(final MethodInvoker invoker, - final BasicRequestProducer requestProducer) { - final Class rawType = invoker.responseType; + 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 CompletableFuture dispatchAsync(final Class rawType, + final BasicRequestProducer requestProducer) { if (rawType == void.class || rawType == Void.class) { return submit(requestProducer, new BasicResponseConsumer<>(new DiscardingEntityConsumer<>())) .thenApply(result -> { @@ -324,80 +303,6 @@ private static RuntimeException unwrap(final Throwable cause) { return new UncheckedIOException(new IOException("Request execution failed", cause)); } - private URI buildRequestUri(final String pathTemplate, - final Map pathParams, - final Map> queryParams) { - try { - final URIBuilder uriBuilder = new URIBuilder(baseUri); - final String[] segments = expandPathSegments(pathTemplate, pathParams); - if (segments.length > 0) { - uriBuilder.appendPathSegments(segments); - } - for (final Map.Entry> entry : queryParams.entrySet()) { - for (final String value : entry.getValue()) { - uriBuilder.addParameter(entry.getKey(), value); - } - } - return uriBuilder.build(); - } catch (final URISyntaxException ex) { - throw new IllegalStateException("Invalid URI: " + ex.getMessage(), ex); - } - } - - /** - * Expands a path template by splitting it into segments, substituting template - * variables with raw values. Encoding is deferred to {@link URIBuilder}. - */ - static String[] expandPathSegments(final String template, - final Map variables) { - if (template == null || template.isEmpty() || "/".equals(template)) { - return new String[0]; - } - final String[] rawSegments = template.split("/"); - final List result = new ArrayList<>(rawSegments.length); - for (final String segment : rawSegments) { - if (segment.isEmpty()) { - continue; - } - result.add(expandSegment(segment, variables)); - } - return result.toArray(new String[0]); - } - - /** - * Expands template variables within a single path segment. - */ - static String expandSegment(final String segment, - final Map variables) { - if (segment.indexOf('{') < 0) { - return segment; - } - final StringBuilder sb = new StringBuilder(segment.length()); - int i = 0; - while (i < segment.length()) { - final char c = segment.charAt(i); - if (c == '{') { - final int close = segment.indexOf('}', i); - if (close < 0) { - sb.append(segment, i, segment.length()); - break; - } - final String name = segment.substring(i + 1, close); - final String value = variables.get(name); - if (value != null) { - sb.append(value); - } else { - sb.append(segment, i, close + 1); - } - i = close + 1; - } else { - sb.append(c); - i++; - } - } - return sb.toString(); - } - private AsyncEntityProducer createEntityProducer(final Object body, final ContentType consumeType) { if (body instanceof byte[]) { @@ -450,7 +355,6 @@ private static void throwIfError(final Message result) { * {@link Enum#name()} to ensure round-trip compatibility with {@code valueOf}. */ static String paramToString(final Object value) { - Args.notNull(value, "Parameter value"); if (value instanceof Enum) { return ((Enum) value).name(); } @@ -472,23 +376,4 @@ private Object handleObjectMethod(final Object proxy, final Method method, throw new UnsupportedOperationException(name); } - static final class MethodInvoker { - - final ClientResourceMethod resourceMethod; - final String acceptHeader; - final ContentType consumeType; - final Class responseType; - final boolean async; - - MethodInvoker(final ClientResourceMethod rm, final String accept, - final ContentType consume, final Class responseType, - final boolean async) { - this.resourceMethod = rm; - this.acceptHeader = accept; - this.consumeType = consume; - this.responseType = responseType; - this.async = async; - } - } - } \ No newline at end of file diff --git a/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResourceException.java b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResourceException.java new file mode 100644 index 0000000000..ced62f37b1 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/main/java/org/apache/hc/client5/http/rest/RestResourceException.java @@ -0,0 +1,44 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.rest; + +/** + * Signals a failure in REST client initialization. + * + * @since 5.7 + */ +public final class RestResourceException extends RuntimeException { + + public RestResourceException(final String message) { + super(message); + } + + public RestResourceException(final String message, final Throwable cause) { + super(message, cause); + } + +} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java deleted file mode 100644 index a4e257372a..0000000000 --- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ClientResourceMethodTest.java +++ /dev/null @@ -1,143 +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 static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Set; - -import org.junit.jupiter.api.Test; - -class ClientResourceMethodTest { - - // --- combinePaths --- - - @Test - void testCombineBaseAndSub() { - assertEquals("/api/users", - ClientResourceMethod.combinePaths("/api", "/users")); - } - - @Test - void testCombineTrailingSlash() { - assertEquals("/api/users", - ClientResourceMethod.combinePaths("/api/", "/users")); - } - - @Test - void testCombineSubWithoutLeadingSlash() { - assertEquals("/api/users", - ClientResourceMethod.combinePaths("/api", "users")); - } - - @Test - void testCombineBaseWithoutLeadingSlash() { - assertEquals("/widgets", - ClientResourceMethod.combinePaths("widgets", null)); - } - - @Test - void testCombineBothWithoutLeadingSlash() { - assertEquals("/widgets/items", - ClientResourceMethod.combinePaths("widgets", "items")); - } - - @Test - void testCombineBaseWithoutSlashSubWithSlash() { - assertEquals("/widgets/items", - ClientResourceMethod.combinePaths("widgets", "/items")); - } - - @Test - void testCombineEmptyBase() { - assertEquals("/users", - ClientResourceMethod.combinePaths("", "/users")); - } - - @Test - void testCombineNullSub() { - assertEquals("/api", - ClientResourceMethod.combinePaths("/api", null)); - } - - @Test - void testCombineBothEmpty() { - assertEquals("/", - ClientResourceMethod.combinePaths("", null)); - } - - // --- stripRegex --- - - @Test - void testStripRegexSimple() { - assertEquals("{id}", - ClientResourceMethod.stripRegex("{id:\\d+}")); - } - - @Test - void testStripRegexNoRegex() { - assertEquals("{id}", - ClientResourceMethod.stripRegex("{id}")); - } - - @Test - void testStripRegexMultipleVars() { - assertEquals("/{group}/{id}", - ClientResourceMethod.stripRegex("/{group:\\w+}/{id:\\d+}")); - } - - // --- extractTemplateVariables --- - - @Test - void testExtractSingleVar() { - final Set vars = - ClientResourceMethod.extractTemplateVariables("/items/{id}"); - assertEquals(Set.of("id"), vars); - } - - @Test - void testExtractMultipleVars() { - final Set vars = - ClientResourceMethod.extractTemplateVariables("/{group}/{id}"); - assertEquals(Set.of("group", "id"), vars); - } - - @Test - void testExtractVarWithRegex() { - final Set vars = - ClientResourceMethod.extractTemplateVariables("/items/{id:\\d+}"); - assertEquals(Set.of("id"), vars); - } - - @Test - void testExtractNoVars() { - assertTrue(ClientResourceMethod - .extractTemplateVariables("/plain/path").isEmpty()); - } - -} diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ResourceIfaceScannerTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ResourceIfaceScannerTest.java new file mode 100644 index 0000000000..d89f2ac506 --- /dev/null +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/ResourceIfaceScannerTest.java @@ -0,0 +1,291 @@ +/* + * ==================================================================== + * 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.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import org.apache.hc.core5.http.ContentType; +import org.assertj.core.api.Assertions; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; + +class ResourceIfaceScannerTest { + + @Test + void testParsePathSegments() { + Assertions.assertThat(ResourceIfaceScanner.parsePathSegments("//this/that/this%20and%20that/")) + .containsExactly( + PathSegment.asValue(""), + PathSegment.asValue("this"), + PathSegment.asValue("that"), + PathSegment.asValue("this and that"), + PathSegment.asValue("")); + } + + @Test + void testParsePathSegmentsWithParams() { + Assertions.assertThat(ResourceIfaceScanner.parsePathSegments("/stuff/{id}")) + .containsExactly( + PathSegment.asValue("stuff"), + PathSegment.asParam("id")); + } + + @Test + void testJointSegments() { + Assertions.assertThat(ResourceIfaceScanner.join( + null, + null)) + .isEmpty(); + Assertions.assertThat(ResourceIfaceScanner.join( + null, + Arrays.asList( + PathSegment.asValue(""), + PathSegment.asValue("this"), + PathSegment.asValue("that")))) + .containsExactly( + PathSegment.asValue(""), + PathSegment.asValue("this"), + PathSegment.asValue("that")); + Assertions.assertThat(ResourceIfaceScanner.join( + Arrays.asList( + PathSegment.asValue(""), + PathSegment.asValue("this")), + Arrays.asList( + PathSegment.asValue("that"), + PathSegment.asValue("")))) + .containsExactly( + PathSegment.asValue(""), + PathSegment.asValue("this"), + PathSegment.asValue("that"), + PathSegment.asValue("")); + Assertions.assertThat(ResourceIfaceScanner.join( + Arrays.asList( + PathSegment.asValue("this"), + PathSegment.asValue("")), + Arrays.asList( + PathSegment.asValue("that"), + PathSegment.asValue("")))) + .containsExactly( + PathSegment.asValue("this"), + PathSegment.asValue("that"), + PathSegment.asValue("")); + } + + @Test + void testParseMediaTypes() { + Assertions.assertThat( + ResourceIfaceScanner.parseMediaTypes( + "application/json", + " , ,text/plain, text/plain ; charset = UTF-8, ")) + .map(ContentType::getMimeType) + .containsExactly("application/json", "text/plain", "text/plain"); + } + + @Test + void testParseMediaTypesUnsupportedMediaType() { + Assertions.assertThatThrownBy(() -> ResourceIfaceScanner.parseMediaTypes("application/json", "application/xml")) + .isInstanceOf(RestResourceException.class) + .hasMessage("Unsupported media type: application/xml"); + } + + @Path("/this/that") + @Produces("text/plain") + interface ResourceIface1 { + + @Path("stuff/{id}") + String get(@PathParam("id") String id); + + @POST + @Path("/stuff") + @Consumes({"text/plain; charset = UTF-8", "application/octet-stream"}) + @Produces({"application/octet-stream", "text/plain"}) + String post(String content); + + } + + @Test + void testMethodPath() { + final List resourceMethods = ResourceIfaceScanner.scan(ResourceIface1.class) + .stream().sorted(Comparator.comparing(m -> m.getMethod().getName())) + .collect(Collectors.toList()); + Assertions.assertThat(resourceMethods) + .hasSize(2) + .satisfiesExactly( + item1 -> { + Assertions.assertThat(item1.getPathSegments()) + .map(PathSegment::toString) + .containsExactly("this", "that", "stuff", "{id}"); + }, + item2 -> { + Assertions.assertThat(item2.getPathSegments()) + .map(PathSegment::toString) + .containsExactly("this", "that", "stuff"); + }); + } + + @Test + void testConsumesMediaTypeValid() { + final List resourceMethods = ResourceIfaceScanner.scan(ResourceIface1.class) + .stream().sorted(Comparator.comparing(m -> m.getMethod().getName())) + .collect(Collectors.toList()); + Assertions.assertThat(resourceMethods) + .hasSize(2) + .satisfiesExactly( + item1 -> { + Assertions.assertThat(item1.getConsumesContentTypes()) + .isNull(); + }, + item2 -> { + Assertions.assertThat(item2.getConsumesContentTypes()) + .map(ContentType::getMimeType) + .containsExactly("text/plain", "application/octet-stream"); + }); + } + + @Test + void testProducesMediaTypeValid() { + final List resourceMethods = ResourceIfaceScanner.scan(ResourceIface1.class) + .stream().sorted(Comparator.comparing(m -> m.getMethod().getName())) + .collect(Collectors.toList()); + Assertions.assertThat(resourceMethods) + .hasSize(2) + .satisfiesExactly( + item1 -> { + Assertions.assertThat(item1.getProducesContentTypes()) + .map(ContentType::getMimeType) + .containsExactly("text/plain"); + }, + item2 -> { + Assertions.assertThat(item2.getProducesContentTypes()) + .map(ContentType::getMimeType) + .containsExactly("application/octet-stream", "text/plain"); + }); + } + + interface ResourceIface2 { + + @Path("stuff/{id}") + String get( + @PathParam("id") + String id, + @QueryParam("param") + @HeaderParam("x-header") + @DefaultValue("aaaaaa") + String param, + @DefaultValue("bbbbb") + @HeaderParam("x-header") + String header, + byte[] content); + + } + + @Test + void testParams() { + final List resourceMethods = ResourceIfaceScanner.scan(ResourceIface2.class); + Assertions.assertThat(resourceMethods) + .hasSize(1) + .satisfiesExactly( + item1 -> { + Assertions.assertThat(item1.getParams()) + .extracting( + ResourceParam::getName, + ResourceParam::getType, + ResourceParam::getDefaultValue) + .containsExactly( + Tuple.tuple("id", ResourceParam.Type.PATH, null), + Tuple.tuple("param", ResourceParam.Type.QUERY, "aaaaaa"), + Tuple.tuple("x-header", ResourceParam.Type.HEADER, "bbbbb"), + Tuple.tuple("body", ResourceParam.Type.BODY, null)); + }); + } + + interface ResourceIface3 { + + @Path("stuff/{id}") + String get( + @PathParam("id") + String id, + String param1, + byte[] param2); + + } + + @Test + void testMethodMultipleAnnotatedMethods() { + Assertions.assertThatThrownBy(() -> ResourceIfaceScanner.scan(ResourceIface3.class)) + .isInstanceOf(RestResourceException.class) + .hasMessage("Method 'get': there are 2 unannotated (body) parameters; at most one is allowed"); + } + + interface ResourceIface4 { + + @Path("stuff/{param1}") + String get( + @PathParam("param1") + String param1, + @PathParam("param2") + byte[] param2); + + } + + @Test + void testMethodMissingMethodPathParameter() { + Assertions.assertThatThrownBy(() -> ResourceIfaceScanner.scan(ResourceIface4.class)) + .isInstanceOf(RestResourceException.class) + .hasMessage("Method 'get': path parameter 'param2' has no matching annotated method argument"); + } + + interface ResourceIface5 { + + @Path("stuff/{param1}/{param2}/{param3}") + String get( + @PathParam("param1") + String param1, + @PathParam("param2") + byte[] param2); + + } + + @Test + void testMethodMissingMethodPathParameterValue() { + Assertions.assertThatThrownBy(() -> ResourceIfaceScanner.scan(ResourceIface5.class)) + .isInstanceOf(RestResourceException.class) + .hasMessage("Method 'get': there is no path parameter 'param3' matching annotated method argument"); + } + +} 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 index ffe79e8950..fb2a716b70 100644 --- 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 @@ -686,7 +686,7 @@ void testRequiresHttpClient() { @Test void testRejectsMultipleBodyParams() { - assertThrows(IllegalStateException.class, () -> + assertThrows(RestResourceException.class, () -> RestClientBuilder.newBuilder() .baseUri("http://localhost") .httpClient(httpClient) @@ -695,7 +695,7 @@ void testRejectsMultipleBodyParams() { @Test void testRejectsPathParamMismatch() { - assertThrows(IllegalStateException.class, () -> + assertThrows(RestResourceException.class, () -> RestClientBuilder.newBuilder() .baseUri("http://localhost") .httpClient(httpClient) @@ -704,7 +704,7 @@ void testRejectsPathParamMismatch() { @Test void testRejectsMissingPathParam() { - assertThrows(IllegalStateException.class, () -> + assertThrows(RestResourceException.class, () -> RestClientBuilder.newBuilder() .baseUri("http://localhost") .httpClient(httpClient) @@ -713,7 +713,7 @@ void testRejectsMissingPathParam() { @Test void testRejectsMultipleConsumesWithBody() { - assertThrows(IllegalStateException.class, () -> + assertThrows(RestResourceException.class, () -> RestClientBuilder.newBuilder() .baseUri("http://localhost") .httpClient(httpClient) diff --git a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java index 55d26270c2..8f2772724b 100644 --- a/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java +++ b/httpclient5-jakarta-rest-client/src/test/java/org/apache/hc/client5/http/rest/RestInvocationHandlerTest.java @@ -26,85 +26,12 @@ */ package org.apache.hc.client5.http.rest; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - import org.junit.jupiter.api.Test; class RestInvocationHandlerTest { - @Test - void testExpandSingleVariable() { - final Map vars = Collections.singletonMap("id", "42"); - assertArrayEquals( - new String[]{"items", "42"}, - RestInvocationHandler.expandPathSegments("/items/{id}", vars)); - } - - @Test - void testExpandMultipleVariables() { - final Map vars = new LinkedHashMap<>(); - vars.put("group", "admin"); - vars.put("id", "7"); - assertArrayEquals( - new String[]{"admin", "users", "7"}, - RestInvocationHandler.expandPathSegments("/{group}/users/{id}", vars)); - } - - @Test - void testExpandPreservesRawValues() { - final Map vars = Collections.singletonMap("name", "hello world"); - assertArrayEquals( - new String[]{"items", "hello world"}, - RestInvocationHandler.expandPathSegments("/items/{name}", vars)); - } - - @Test - void testExpandNoVariables() { - assertArrayEquals( - new String[]{"plain"}, - RestInvocationHandler.expandPathSegments("/plain", Collections.emptyMap())); - } - - @Test - void testExpandEmptyTemplate() { - assertArrayEquals( - new String[0], - RestInvocationHandler.expandPathSegments("/", Collections.emptyMap())); - } - - @Test - void testExpandSegmentNoVariable() { - assertEquals("plain", - RestInvocationHandler.expandSegment("plain", Collections.emptyMap())); - } - - @Test - void testExpandSegmentSingleVariable() { - final Map vars = Collections.singletonMap("id", "42"); - assertEquals("42", - RestInvocationHandler.expandSegment("{id}", vars)); - } - - @Test - void testExpandSegmentMixedContent() { - final Map vars = new LinkedHashMap<>(); - vars.put("group", "admin"); - vars.put("id", "7"); - assertEquals("admin-7", - RestInvocationHandler.expandSegment("{group}-{id}", vars)); - } - - @Test - void testExpandSegmentUnknownVariable() { - assertEquals("{unknown}", - RestInvocationHandler.expandSegment("{unknown}", Collections.emptyMap())); - } - @Test void testParamToStringBasicTypes() { assertEquals("42", RestInvocationHandler.paramToString(42)); diff --git a/pom.xml b/pom.xml index 85c0dd3636..ca3267591f 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,7 @@ 2.9.3 3.1.0 2.21.1 + 3.27.7 @@ -288,6 +289,11 @@ jackson-databind ${jackson.version} + + org.assertj + assertj-core + ${assertj.version} +