From bd85a263b78a5c1796cbb223ca00973e3c376e08 Mon Sep 17 00:00:00 2001 From: kjg Date: Wed, 1 Apr 2026 22:17:44 +0900 Subject: [PATCH] Support extracting Bean Validation constraints from method parameters Add an explicit method-parameter constraint resolution path so ConstraintDescriptions can describe validation declared on controller method parameters as well as bean properties. ValidatorConstraintResolver now uses Bean Validation method metadata to extract parameter constraints. ConstraintDescriptions provides explicit method-based overloads and can resolve inherited methods when looking up constraints by name. The documentation also includes a source-backed example for method parameter constraints, and the tests cover positive, empty, inherited, and invalid-index scenarios. Fixes gh-1026 Signed-off-by: kjg --- .../constraints/ConstraintDescriptions.java | 54 +++++++++++++++ .../constraints/ConstraintResolver.java | 14 ++++ .../ValidatorConstraintResolver.java | 25 +++++++ .../ConstraintDescriptionsTests.java | 68 ++++++++++++++++++- .../ValidatorConstraintResolverTests.java | 45 ++++++++++++ .../documenting-your-api/constraints.adoc | 11 ++- .../MethodParameterConstraints.java | 40 +++++++++++ 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 spring-restdocs-docs/src/test/java/org/springframework/restdocs/docs/documentingyourapi/constraints/MethodParameterConstraints.java 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) { + } + + } + +}