From e49bace80f5ae41e0bf5cc4990f3e6950e999973 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Tue, 17 Sep 2024 12:58:29 +0300 Subject: [PATCH] Add support for Bean Validation's constraint groups gh-887 --- .../restdocs/constraints/Constraint.java | 26 ++++ .../GroupConstraintDescriptions.java | 111 ++++++++++++++++++ .../ValidatorConstraintResolver.java | 2 +- .../GroupConstraintDescriptionsTests.java | 76 ++++++++++++ .../ValidatorConstraintResolverTests.java | 28 ++++- 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/GroupConstraintDescriptions.java create mode 100644 spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/GroupConstraintDescriptionsTests.java diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/Constraint.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/Constraint.java index 3ad467432..a5575df95 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/Constraint.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/Constraint.java @@ -16,7 +16,9 @@ package org.springframework.restdocs.constraints; +import java.util.Collections; import java.util.Map; +import java.util.Set; /** * A constraint. @@ -29,6 +31,8 @@ public class Constraint { private final Map configuration; + private final Set> groups; + /** * Creates a new {@code Constraint} with the given {@code name} and * {@code configuration}. @@ -38,6 +42,20 @@ public class Constraint { public Constraint(String name, Map configuration) { this.name = name; this.configuration = configuration; + this.groups = Collections.emptySet(); + } + + /** + * Creates a new {@code Constraint} with the given {@code name} and + * {@code configuration}. + * @param name the name + * @param configuration the configuration + * @param groups the groups + */ + public Constraint(String name, Map configuration, Set> groups) { + this.name = name; + this.configuration = configuration; + this.groups = groups; } /** @@ -56,4 +74,12 @@ public Map getConfiguration() { return this.configuration; } + /** + * Returns the groups of the constraint. + * @return the groups + */ + public Set> getGroups() { + return this.groups; + } + } diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/GroupConstraintDescriptions.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/GroupConstraintDescriptions.java new file mode 100644 index 000000000..e35dacd2c --- /dev/null +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/constraints/GroupConstraintDescriptions.java @@ -0,0 +1,111 @@ +/* + * Copyright 2014-2024 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.constraints; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +/** + * Provides access to descriptions of a class's constraints. + * + * @author Dmytro Nosan + */ +public class GroupConstraintDescriptions { + + private final Class clazz; + + private final ConstraintResolver constraintResolver; + + private final ConstraintDescriptionResolver descriptionResolver; + + /** + * Create a new {@code GroupConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using a {@link ValidatorConstraintResolver} and + * descriptions will be resolved using a + * {@link ResourceBundleConstraintDescriptionResolver}. + * @param clazz the class + */ + public GroupConstraintDescriptions(Class clazz) { + this(clazz, new ValidatorConstraintResolver(), new ResourceBundleConstraintDescriptionResolver()); + } + + /** + * Create a new {@code GroupConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using the given {@code constraintResolver} and + * descriptions will be resolved using a + * {@link ResourceBundleConstraintDescriptionResolver}. + * @param clazz the class + * @param constraintResolver the constraint resolver + */ + public GroupConstraintDescriptions(Class clazz, ConstraintResolver constraintResolver) { + this(clazz, constraintResolver, new ResourceBundleConstraintDescriptionResolver()); + } + + /** + * Create a new {@code GroupConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using a {@link ValidatorConstraintResolver} and + * descriptions will be resolved using the given {@code descriptionResolver}. + * @param clazz the class + * @param descriptionResolver the description resolver + */ + public GroupConstraintDescriptions(Class clazz, ConstraintDescriptionResolver descriptionResolver) { + this(clazz, new ValidatorConstraintResolver(), descriptionResolver); + } + + /** + * Create a new {@code GroupConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using the given {@code constraintResolver} and + * descriptions will be resolved using the given {@code descriptionResolver}. + * @param clazz the class + * @param constraintResolver the constraint resolver + * @param descriptionResolver the description resolver + */ + public GroupConstraintDescriptions(Class clazz, ConstraintResolver constraintResolver, + ConstraintDescriptionResolver descriptionResolver) { + this.clazz = clazz; + this.constraintResolver = constraintResolver; + this.descriptionResolver = descriptionResolver; + } + + /** + * Returns a list of the descriptions for the constraints on the given property. + * @param property the property + * @param groups list of groups targeted for constraints + * @return the list of constraint descriptions + */ + public List descriptionsForProperty(String property, Class... groups) { + List constraints = this.constraintResolver.resolveForProperty(property, this.clazz); + List descriptions = new ArrayList<>(); + for (Constraint constraint : constraints) { + if (includes(constraint, groups)) { + descriptions.add(this.descriptionResolver.resolveDescription(constraint)); + } + } + Collections.sort(descriptions); + return descriptions; + } + + private boolean includes(Constraint constraint, Class[] groups) { + if (groups.length == 0 && constraint.getGroups().isEmpty()) { + return true; + } + return Stream.of(groups).anyMatch((clazz) -> constraint.getGroups().contains(clazz)); + } + +} 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 28910a938..2e40ef42f 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 @@ -68,7 +68,7 @@ public List resolveForProperty(String property, Class clazz) { if (propertyDescriptor != null) { for (ConstraintDescriptor constraintDescriptor : propertyDescriptor.getConstraintDescriptors()) { constraints.add(new Constraint(constraintDescriptor.getAnnotation().annotationType().getName(), - constraintDescriptor.getAttributes())); + constraintDescriptor.getAttributes(), constraintDescriptor.getGroups())); } } return constraints; diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/GroupConstraintDescriptionsTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/GroupConstraintDescriptionsTests.java new file mode 100644 index 000000000..29828629b --- /dev/null +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/constraints/GroupConstraintDescriptionsTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2014-2024 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.constraints; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GroupConstraintDescriptions}. + * + * @author Dmytro Nosan + * + */ +public class GroupConstraintDescriptionsTests { + + private final ConstraintResolver constraintResolver = mock(ConstraintResolver.class); + + private final ConstraintDescriptionResolver constraintDescriptionResolver = mock( + ConstraintDescriptionResolver.class); + + private final GroupConstraintDescriptions constraintDescriptions = new GroupConstraintDescriptions( + Constrained.class, this.constraintResolver, this.constraintDescriptionResolver); + + @Test + public void descriptionsForConstraints() { + Constraint constraint1 = new Constraint("constraint1", Collections.emptyMap(), Set.of(Cloneable.class)); + Constraint constraint2 = new Constraint("constraint2", Collections.emptyMap()); + Constraint constraint3 = new Constraint("constraint3", Collections.emptyMap(), + Set.of(Cloneable.class, Serializable.class)); + + given(this.constraintResolver.resolveForProperty("foo", Constrained.class)) + .willReturn(Arrays.asList(constraint1, constraint2, constraint3)); + given(this.constraintDescriptionResolver.resolveDescription(constraint1)).willReturn("Bravo"); + given(this.constraintDescriptionResolver.resolveDescription(constraint2)).willReturn("Alpha"); + given(this.constraintDescriptionResolver.resolveDescription(constraint3)).willReturn("Delta"); + + assertThat(this.constraintDescriptions.descriptionsForProperty("foo", Cloneable.class)).containsExactly("Bravo", + "Delta"); + assertThat(this.constraintDescriptions.descriptionsForProperty("foo", Serializable.class)) + .containsExactly("Delta"); + assertThat(this.constraintDescriptions.descriptionsForProperty("foo")).containsExactly("Alpha"); + } + + @Test + public void emptyListOfDescriptionsWhenThereAreNoConstraints() { + given(this.constraintResolver.resolveForProperty("foo", Constrained.class)).willReturn(Collections.emptyList()); + assertThat(this.constraintDescriptions.descriptionsForProperty("foo").size()).isEqualTo(0); + } + + private static final class 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 3d900b3c6..cb5ebe97a 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 @@ -16,15 +16,18 @@ package org.springframework.restdocs.constraints; +import java.io.Serializable; import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import jakarta.validation.Payload; import jakarta.validation.constraints.NotBlank; @@ -55,6 +58,13 @@ public void singleFieldConstraint() { assertThat(constraints.get(0).getName()).isEqualTo(NotNull.class.getName()); } + @Test + public void singleGroupedFieldConstraint() { + List constraints = this.resolver.resolveForProperty("singleGrouped", ConstrainedFields.class); + assertThat(constraints).hasSize(1); + assertThat(constraints.get(0)).is(constraint(NotNull.class).groups(Serializable.class)); + } + @Test public void multipleFieldConstraints() { List constraints = this.resolver.resolveForProperty("multiple", ConstrainedFields.class); @@ -84,6 +94,9 @@ private static final class ConstrainedFields { @NotNull private String single; + @NotNull(groups = Serializable.class) + private String singleGrouped; + @NotNull @Size(min = 8, max = 16) private String multiple; @@ -118,9 +131,12 @@ private static final class ConstraintCondition extends Condition { private final Map configuration = new HashMap<>(); + private final Set> groups = new HashSet<>(); + private ConstraintCondition(Class annotation) { this.annotation = annotation; - as(new TextDescription("Constraint named %s with configuration %s", this.annotation, this.configuration)); + as(new TextDescription("Constraint named %s with configuration %s and groups %s", this.annotation, + this.configuration, this.groups)); } private ConstraintCondition config(String key, Object value) { @@ -128,6 +144,11 @@ private ConstraintCondition config(String key, Object value) { return this; } + private ConstraintCondition groups(Class... groups) { + this.groups.addAll(List.of(groups)); + return this; + } + @Override public boolean matches(Constraint constraint) { if (!constraint.getName().equals(this.annotation.getName())) { @@ -138,6 +159,11 @@ public boolean matches(Constraint constraint) { return false; } } + for (Class group : this.groups) { + if (!constraint.getGroups().contains(group)) { + return false; + } + } return true; }