diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java index f885bb5f..109cd1c9 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java @@ -16,10 +16,14 @@ package org.springframework.restdocs.constraints; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.jspecify.annotations.Nullable; + /** * Provides access to descriptions of a class's constraints. * @@ -89,6 +93,56 @@ public ConstraintDescriptions(Class clazz, ConstraintResolver constraintResol */ public List descriptionsForProperty(String property) { List constraints = this.constraintResolver.resolveForProperty(property, this.clazz); + return resolveDescriptions(constraints); + } + + /** + * Returns a list of the descriptions for the constraints on the given + * {@code parameterIndex} of the given {@code method}. + * @param method the method + * @param parameterIndex the index of the parameter + * @return the list of constraint descriptions + * @since 4.0.1 + */ + public List descriptionsForMethodParameter(Method method, int parameterIndex) { + List constraints = this.constraintResolver.resolveForMethodParameter(method, parameterIndex); + return resolveDescriptions(constraints); + } + + /** + * Returns a list of the descriptions for the constraints on the given + * {@code parameterIndex} of the method with the given {@code methodName} and + * {@code parameterTypes}. + * @param methodName the name of the method + * @param parameterIndex the index of the parameter + * @param parameterTypes the types of the parameters of the method + * @return the list of constraint descriptions + * @since 4.0.1 + */ + public List descriptionsForMethodParameter(String methodName, int parameterIndex, + Class... parameterTypes) { + Method method = findMethod(this.clazz, methodName, parameterTypes); + if (method == null) { + throw new IllegalArgumentException("No method named '" + methodName + "' with parameter types " + + Arrays.toString(parameterTypes) + " found on " + this.clazz); + } + return descriptionsForMethodParameter(method, parameterIndex); + } + + @Nullable private Method findMethod(Class clazz, String name, Class[] parameterTypes) { + Class currentClass = clazz; + while (currentClass != null) { + try { + return currentClass.getDeclaredMethod(name, parameterTypes); + } + catch (NoSuchMethodException ex) { + currentClass = currentClass.getSuperclass(); + } + } + return null; + } + + private List resolveDescriptions(List constraints) { List descriptions = new ArrayList<>(); for (Constraint constraint : constraints) { descriptions.add(this.descriptionResolver.resolveDescription(constraint)); diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java index 78c76678..a608bed2 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java @@ -16,6 +16,8 @@ package org.springframework.restdocs.constraints; +import java.lang.reflect.Method; +import java.util.Collections; import java.util.List; /** @@ -34,4 +36,16 @@ public interface ConstraintResolver { */ List resolveForProperty(String property, Class clazz); + /** + * Resolves and returns the constraints for the given {@code parameterIndex} of the + * given {@code method}. If there are no constraints, an empty list is returned. + * @param method the method + * @param parameterIndex the index of the parameter + * @return the list of constraints, never {@code null} + * @since 4.0.1 + */ + default List resolveForMethodParameter(Method method, int parameterIndex) { + return Collections.emptyList(); + } + } diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java index 5c8efc6b..f9feecdf 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java @@ -16,6 +16,7 @@ package org.springframework.restdocs.constraints; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; @@ -25,6 +26,8 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.metadata.BeanDescriptor; import jakarta.validation.metadata.ConstraintDescriptor; +import jakarta.validation.metadata.MethodDescriptor; +import jakarta.validation.metadata.ParameterDescriptor; import jakarta.validation.metadata.PropertyDescriptor; /** @@ -74,4 +77,26 @@ public List resolveForProperty(String property, Class clazz) { return constraints; } + @Override + public List resolveForMethodParameter(Method method, int parameterIndex) { + List constraints = new ArrayList<>(); + if (parameterIndex < 0) { + return constraints; + } + BeanDescriptor beanDescriptor = this.validator.getConstraintsForClass(method.getDeclaringClass()); + MethodDescriptor methodDescriptor = beanDescriptor.getConstraintsForMethod(method.getName(), + method.getParameterTypes()); + if (methodDescriptor != null) { + List parameterDescriptors = methodDescriptor.getParameterDescriptors(); + if (parameterIndex < parameterDescriptors.size()) { + ParameterDescriptor parameterDescriptor = parameterDescriptors.get(parameterIndex); + for (ConstraintDescriptor constraintDescriptor : parameterDescriptor.getConstraintDescriptors()) { + constraints.add(new Constraint(constraintDescriptor.getAnnotation().annotationType().getName(), + constraintDescriptor.getAttributes())); + } + } + } + return constraints; + } + } diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java index caabd48b..0b54b38e 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java @@ -16,12 +16,14 @@ package org.springframework.restdocs.constraints; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -58,7 +60,71 @@ void emptyListOfDescriptionsWhenThereAreNoConstraints() { assertThat(this.constraintDescriptions.descriptionsForProperty("foo").size()).isEqualTo(0); } - private static final class Constrained { + @Test + void descriptionsForMethodParameterConstraints() throws NoSuchMethodException { + Method method = Constrained.class.getDeclaredMethod("foo", String.class); + Constraint constraint1 = new Constraint("constraint1", Collections.emptyMap()); + Constraint constraint2 = new Constraint("constraint2", Collections.emptyMap()); + given(this.constraintResolver.resolveForMethodParameter(method, 0)) + .willReturn(Arrays.asList(constraint1, constraint2)); + given(this.constraintDescriptionResolver.resolveDescription(constraint1)).willReturn("Bravo"); + given(this.constraintDescriptionResolver.resolveDescription(constraint2)).willReturn("Alpha"); + assertThat(this.constraintDescriptions.descriptionsForMethodParameter(method, 0)).containsExactly("Alpha", + "Bravo"); + } + + @Test + void descriptionsForMethodParameterConstraintsUsingName() throws NoSuchMethodException { + Method method = Constrained.class.getDeclaredMethod("foo", String.class); + Constraint constraint1 = new Constraint("constraint1", Collections.emptyMap()); + Constraint constraint2 = new Constraint("constraint2", Collections.emptyMap()); + given(this.constraintResolver.resolveForMethodParameter(method, 0)) + .willReturn(Arrays.asList(constraint1, constraint2)); + given(this.constraintDescriptionResolver.resolveDescription(constraint1)).willReturn("Bravo"); + given(this.constraintDescriptionResolver.resolveDescription(constraint2)).willReturn("Alpha"); + assertThat(this.constraintDescriptions.descriptionsForMethodParameter("foo", 0, String.class)) + .containsExactly("Alpha", "Bravo"); + } + + @Test + void descriptionsForMethodParameterConstraintsWithNoTypesUsingName() throws NoSuchMethodException { + Method method = Constrained.class.getDeclaredMethod("bar"); + Constraint constraint1 = new Constraint("constraint1", Collections.emptyMap()); + given(this.constraintResolver.resolveForMethodParameter(method, 0)).willReturn(Arrays.asList(constraint1)); + given(this.constraintDescriptionResolver.resolveDescription(constraint1)).willReturn("Alpha"); + assertThat(this.constraintDescriptions.descriptionsForMethodParameter("bar", 0)).containsExactly("Alpha"); + } + + @Test + void descriptionsForInheritedMethodParameterConstraintsUsingName() throws NoSuchMethodException { + Method method = Constrained.class.getDeclaredMethod("foo", String.class); + Constraint constraint1 = new Constraint("constraint1", Collections.emptyMap()); + given(this.constraintResolver.resolveForMethodParameter(method, 0)).willReturn(Arrays.asList(constraint1)); + given(this.constraintDescriptionResolver.resolveDescription(constraint1)).willReturn("Alpha"); + + ConstraintDescriptions subclassDescriptions = new ConstraintDescriptions(SubclassConstrained.class, + this.constraintResolver, this.constraintDescriptionResolver); + assertThat(subclassDescriptions.descriptionsForMethodParameter("foo", 0, String.class)) + .containsExactly("Alpha"); + } + + @Test + void descriptionsForNonExistentMethodParameter() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.constraintDescriptions.descriptionsForMethodParameter("baz", 0)); + } + + private static class Constrained { + + void foo(String foo) { + } + + void bar() { + } + + } + + private static final class SubclassConstrained extends Constrained { } diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java index e5ad3f53..36779228 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java @@ -21,6 +21,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.lang.reflect.Method; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -69,6 +70,37 @@ void noFieldConstraints() { assertThat(constraints).hasSize(0); } + @Test + void singleMethodParameterConstraint() throws NoSuchMethodException { + Method method = ConstrainedMethods.class.getDeclaredMethod("single", String.class); + List constraints = this.resolver.resolveForMethodParameter(method, 0); + assertThat(constraints).hasSize(1); + assertThat(constraints.get(0).getName()).isEqualTo(NotNull.class.getName()); + } + + @Test + void multipleMethodParameterConstraints() throws NoSuchMethodException { + Method method = ConstrainedMethods.class.getDeclaredMethod("multiple", String.class); + List constraints = this.resolver.resolveForMethodParameter(method, 0); + assertThat(constraints).hasSize(2); + assertThat(constraints.get(0)).is(constraint(NotNull.class)); + assertThat(constraints.get(1)).is(constraint(Size.class).config("min", 8).config("max", 16)); + } + + @Test + void noMethodParameterConstraints() throws NoSuchMethodException { + Method method = ConstrainedMethods.class.getDeclaredMethod("none", String.class); + List constraints = this.resolver.resolveForMethodParameter(method, 0); + assertThat(constraints).hasSize(0); + } + + @Test + void negativeMethodParameterIndexReturnsNoConstraints() throws NoSuchMethodException { + Method method = ConstrainedMethods.class.getDeclaredMethod("single", String.class); + List constraints = this.resolver.resolveForMethodParameter(method, -1); + assertThat(constraints).isEmpty(); + } + @Test void compositeConstraint() { List constraints = this.resolver.resolveForProperty("composite", ConstrainedFields.class); @@ -96,6 +128,19 @@ private static final class ConstrainedFields { } + private static final class ConstrainedMethods { + + void single(@NotNull String single) { + } + + void multiple(@NotNull @Size(min = 8, max = 16) String multiple) { + } + + void none(String none) { + } + + } + @ConstraintComposition(CompositionType.OR) @Null @NotBlank diff --git a/spring-restdocs-docs/src/docs/antora/modules/reference/pages/documenting-your-api/constraints.adoc b/spring-restdocs-docs/src/docs/antora/modules/reference/pages/documenting-your-api/constraints.adoc index edd1a51e..5980882f 100644 --- a/spring-restdocs-docs/src/docs/antora/modules/reference/pages/documenting-your-api/constraints.adoc +++ b/spring-restdocs-docs/src/docs/antora/modules/reference/pages/documenting-your-api/constraints.adoc @@ -3,13 +3,20 @@ Spring REST Docs provides a number of classes that can help you to document constraints. You can use an instance of `ConstraintDescriptions` to access descriptions of a class's constraints. -The following example shows how to do so: +The following example shows how to do so for property constraints: include-code::Constraints[] <1> Create an instance of `ConstraintDescriptions` for the `UserInput` class. <2> Get the descriptions of the `name` property's constraints. This list contains two descriptions: one for the `NotNull` constraint and one for the `Size` constraint. +The following example shows how to do so for method parameter constraints: + +include-code::MethodParameterConstraints[] +<1> Create an instance of `ConstraintDescriptions` for the `UserController` class. +<2> Get the descriptions of the first parameter's constraints of the `user` method. +This list contains two descriptions: one for the `NotNull` constraint and one for the `Min` constraint. + The {samples}/restful-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java[`ApiDocumentation`] class in the Spring HATEOAS sample shows this functionality in action. @@ -18,7 +25,7 @@ The {samples}/restful-notes-spring-hateoas/src/test/java/com/example/notes/ApiDo == Finding Constraints By default, constraints are found by using a Bean Validation `Validator`. -Currently, only property constraints are supported. +Both property and method parameter constraints are supported. You can customize the `Validator` that is used by creating `ConstraintDescriptions` with a custom `ValidatorConstraintResolver` instance. To take complete control of constraint resolution, you can use your own implementation of `ConstraintResolver`. diff --git a/spring-restdocs-docs/src/test/java/org/springframework/restdocs/docs/documentingyourapi/constraints/MethodParameterConstraints.java b/spring-restdocs-docs/src/test/java/org/springframework/restdocs/docs/documentingyourapi/constraints/MethodParameterConstraints.java new file mode 100644 index 00000000..df8663d2 --- /dev/null +++ b/spring-restdocs-docs/src/test/java/org/springframework/restdocs/docs/documentingyourapi/constraints/MethodParameterConstraints.java @@ -0,0 +1,40 @@ +/* + * Copyright 2014-present the original author or authors. + * + * Licensed 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.restdocs.docs.documentingyourapi.constraints; + +import java.util.List; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import org.springframework.restdocs.constraints.ConstraintDescriptions; + +class MethodParameterConstraints { + + List describeMethodParameterConstraints() { + ConstraintDescriptions controllerConstraints = new ConstraintDescriptions(UserController.class); // <1> + return controllerConstraints.descriptionsForMethodParameter("user", 0, Long.class); // <2> + } + + static class UserController { + + void user(@NotNull @Min(1) Long id) { + } + + } + +}