Adapt ConstraintViolation's from method validation

See gh-29825
This commit is contained in:
Rossen Stoyanchev 2023-06-06 15:10:21 +01:00 committed by rstoyanchev
parent 38abee00e2
commit 425d5a94cb
8 changed files with 1237 additions and 169 deletions

View File

@ -0,0 +1,459 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ElementKind;
import jakarta.validation.Path;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import jakarta.validation.executable.ExecutableValidator;
import jakarta.validation.metadata.ConstraintDescriptor;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.core.BridgeMethodResolver;
import org.springframework.core.Conventions;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.GenericTypeResolver;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
import org.springframework.util.function.SingletonSupplier;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.Errors;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.annotation.Validated;
/**
* Assist with applying method-level validation via
* {@link jakarta.validation.Validator}, adapt each resulting
* {@link ConstraintViolation} to {@link ParameterValidationResult}, and
* raise {@link MethodValidationException}.
*
* <p>Used by {@link MethodValidationInterceptor}.
*
* @author Rossen Stoyanchev
* @since 6.1
*/
public class MethodValidationAdapter {
private static final Comparator<ParameterValidationResult> RESULT_COMPARATOR = new ResultComparator();
private final Supplier<Validator> validator;
private final Supplier<SpringValidatorAdapter> validatorAdapter;
private MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();
private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
/**
* Create an instance using a default JSR-303 validator underneath.
*/
@SuppressWarnings("DataFlowIssue")
public MethodValidationAdapter() {
this.validator = SingletonSupplier.of(() -> Validation.buildDefaultValidatorFactory().getValidator());
this.validatorAdapter = SingletonSupplier.of(() -> new SpringValidatorAdapter(this.validator.get()));
}
/**
* Create an instance using the given JSR-303 ValidatorFactory.
* @param validatorFactory the JSR-303 ValidatorFactory to use
*/
@SuppressWarnings("DataFlowIssue")
public MethodValidationAdapter(ValidatorFactory validatorFactory) {
this.validator = SingletonSupplier.of(validatorFactory::getValidator);
this.validatorAdapter = SingletonSupplier.of(() -> new SpringValidatorAdapter(this.validator.get()));
}
/**
* Create an instance using the given JSR-303 Validator.
* @param validator the JSR-303 Validator to use
*/
public MethodValidationAdapter(Validator validator) {
this.validator = () -> validator;
this.validatorAdapter = () -> new SpringValidatorAdapter(validator);
}
/**
* Create an instance for the supplied (potentially lazily initialized) Validator.
* @param validator a Supplier for the Validator to use
*/
public MethodValidationAdapter(Supplier<Validator> validator) {
this.validator = validator;
this.validatorAdapter = () -> new SpringValidatorAdapter(this.validator.get());
}
/**
* Set the strategy to use to determine message codes for violations.
* <p>Default is a DefaultMessageCodesResolver.
*/
public void setMessageCodesResolver(MessageCodesResolver messageCodesResolver) {
this.messageCodesResolver = messageCodesResolver;
}
/**
* Return the {@link #setMessageCodesResolver(MessageCodesResolver) configured}
* {@code MessageCodesResolver}.
*/
public MessageCodesResolver getMessageCodesResolver() {
return this.messageCodesResolver;
}
/**
* Set the ParameterNameDiscoverer to use to resolve method parameter names
* that is in turn used to create error codes for {@link MessageSourceResolvable}.
* <p>Default is {@link org.springframework.core.DefaultParameterNameDiscoverer}.
*/
public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) {
this.parameterNameDiscoverer = parameterNameDiscoverer;
}
/**
* Return the {@link #setParameterNameDiscoverer(ParameterNameDiscoverer) configured}
* {@code ParameterNameDiscoverer}.
*/
public ParameterNameDiscoverer getParameterNameDiscoverer() {
return this.parameterNameDiscoverer;
}
/**
* Use this method determine the validation groups to pass into
* {@link #validateMethodArguments(Object, Method, Object[], Class[])} and
* {@link #validateMethodReturnValue(Object, Method, Object, Class[])}.
* <p>Default are the validation groups as specified in the {@link Validated}
* annotation on the method, or on the containing target class of the method,
* or for an AOP proxy without a target (with all behavior in advisors), also
* check on proxied interfaces.
* @param target the target Object
* @param method the target method
* @return the applicable validation groups as a {@code Class} array
*/
public static Class<?>[] determineValidationGroups(Object target, Method method) {
Validated validatedAnn = AnnotationUtils.findAnnotation(method, Validated.class);
if (validatedAnn == null) {
if (AopUtils.isAopProxy(target)) {
for (Class<?> type : AopProxyUtils.proxiedUserInterfaces(target)) {
validatedAnn = AnnotationUtils.findAnnotation(type, Validated.class);
if (validatedAnn != null) {
break;
}
}
}
else {
validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class);
}
}
return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
}
/**
* Validate the given method arguments and raise {@link ConstraintViolation}
* in case of any errors.
* @param target the target Object
* @param method the target method
* @param arguments candidate arguments for a method invocation
* @param groups groups for validation determined via
* {@link #determineValidationGroups(Object, Method)}
*/
public void validateMethodArguments(Object target, Method method, Object[] arguments, Class<?>[] groups) {
ExecutableValidator execVal = this.validator.get().forExecutables();
Set<ConstraintViolation<Object>> result;
try {
result = execVal.validateParameters(target, method, arguments, groups);
}
catch (IllegalArgumentException ex) {
// Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
// Let's try to find the bridged method on the implementation class...
Method mostSpecificMethod = ClassUtils.getMostSpecificMethod(method, target.getClass());
Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(mostSpecificMethod);
result = execVal.validateParameters(target, bridgedMethod, arguments, groups);
}
if (!result.isEmpty()) {
throw createException(target, method, result, i -> arguments[i]);
}
}
/**
* Validate the given return value and raise {@link ConstraintViolation}
* in case of any errors.
* @param target the target Object
* @param method the target method
* @param returnValue value returned from invoking the target method
* @param groups groups for validation determined via
* {@link #determineValidationGroups(Object, Method)}
*/
public void validateMethodReturnValue(
Object target, Method method, @Nullable Object returnValue, Class<?>[] groups) {
ExecutableValidator execVal = this.validator.get().forExecutables();
Set<ConstraintViolation<Object>> result = execVal.validateReturnValue(target, method, returnValue, groups);
if (!result.isEmpty()) {
throw createException(target, method, result, i -> returnValue);
}
}
private MethodValidationException createException(
Object target, Method method, Set<ConstraintViolation<Object>> violations,
Function<Integer, Object> argumentFunction) {
Map<MethodParameter, ValueResultBuilder> parameterViolations = new LinkedHashMap<>();
Map<Path.Node, BeanResultBuilder> cascadedViolations = new LinkedHashMap<>();
for (ConstraintViolation<Object> violation : violations) {
Iterator<Path.Node> itr = violation.getPropertyPath().iterator();
while (itr.hasNext()) {
Path.Node node = itr.next();
MethodParameter parameter;
if (node.getKind().equals(ElementKind.PARAMETER)) {
parameter = new MethodParameter(method, node.as(Path.ParameterNode.class).getParameterIndex());
}
else if (node.getKind().equals(ElementKind.RETURN_VALUE)) {
parameter = new MethodParameter(method, -1);
}
else {
continue;
}
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
Object argument = argumentFunction.apply(parameter.getParameterIndex());
if (!itr.hasNext()) {
parameterViolations
.computeIfAbsent(parameter, p -> new ValueResultBuilder(target, parameter, argument))
.addViolation(violation);
}
else {
cascadedViolations
.computeIfAbsent(node, n -> new BeanResultBuilder(parameter, argument, itr.next()))
.addViolation(violation);
}
break;
}
}
List<ParameterValidationResult> validatonResultList = new ArrayList<>();
parameterViolations.forEach((parameter, builder) -> validatonResultList.add(builder.build()));
cascadedViolations.forEach((node, builder) -> validatonResultList.add(builder.build()));
validatonResultList.sort(RESULT_COMPARATOR);
return new MethodValidationException(target, method, validatonResultList, violations);
}
/**
* Create a {@link MessageSourceResolvable} for the given violation.
* @param target target of the method invocation to which validation was applied
* @param parameter the method parameter associated with the violation
* @param violation the violation
* @return the created {@code MessageSourceResolvable}
*/
private MessageSourceResolvable createMessageSourceResolvable(
Object target, MethodParameter parameter, ConstraintViolation<Object> violation) {
String objectName = Conventions.getVariableName(target) + "#" + parameter.getExecutable().getName();
String paramName = (parameter.getParameterName() != null ? parameter.getParameterName() : "");
Class<?> parameterType = parameter.getParameterType();
ConstraintDescriptor<?> descriptor = violation.getConstraintDescriptor();
String code = descriptor.getAnnotation().annotationType().getSimpleName();
String[] codes = this.messageCodesResolver.resolveMessageCodes(code, objectName, paramName, parameterType);
Object[] arguments = this.validatorAdapter.get().getArgumentsForConstraint(objectName, paramName, descriptor);
return new DefaultMessageSourceResolvable(codes, arguments, violation.getMessage());
}
/**
* Select an object name and create a {@link BindingResult} for the argument.
* <p>By default, the name is based on the parameter name, or for a return type on
* {@link Conventions#getVariableNameForReturnType(Method, Class, Object)}.
* <p>If a name cannot be determined for any reason, e.g. a return value with
* insufficient type information, then {@code "{methodName}.arg{index}"} is used.
* @param parameter the method parameter
* @param argument the argument value
* @return the determined name
*/
private BindingResult createBindingResult(MethodParameter parameter, @Nullable Object argument) {
// TODO: allow external customization via Function (e.g. from @ModelAttribute + Conventions based on type)
String objectName = parameter.getParameterName();
int index = parameter.getParameterIndex();
if (index == -1) {
try {
Method method = parameter.getMethod();
if (method != null) {
Class<?> resolvedType = GenericTypeResolver.resolveReturnType(method, parameter.getContainingClass());
objectName = Conventions.getVariableNameForReturnType(method, resolvedType, argument);
}
}
catch (IllegalArgumentException ex) {
// insufficient type information
}
}
if (objectName == null) {
objectName = (parameter.getExecutable().getName() + (index != -1 ? ".arg" + index : ""));
}
BeanPropertyBindingResult result = new BeanPropertyBindingResult(argument, objectName);
result.setMessageCodesResolver(this.messageCodesResolver);
return result;
}
/**
* Builds a validation result for a value method parameter with constraints
* declared directly on it.
*/
private final class ValueResultBuilder {
private final Object target;
private final MethodParameter parameter;
@Nullable
private final Object argument;
private final List<MessageSourceResolvable> resolvableErrors = new ArrayList<>();
private final Set<ConstraintViolation<Object>> violations = new LinkedHashSet<>();
public ValueResultBuilder(Object target, MethodParameter parameter, @Nullable Object argument) {
this.target = target;
this.parameter = parameter;
this.argument = argument;
}
public void addViolation(ConstraintViolation<Object> violation) {
this.violations.add(violation);
this.resolvableErrors.add(createMessageSourceResolvable(this.target, this.parameter, violation));
}
public ParameterValidationResult build() {
return new ParameterValidationResult(
this.parameter, this.argument, this.resolvableErrors, this.violations);
}
}
/**
* Builds a validation result for an {@link jakarta.validation.Valid @Valid}
* annotated bean method parameter with cascaded constraints.
*/
private final class BeanResultBuilder {
private final MethodParameter parameter;
@Nullable
private final Object argument;
@Nullable
private final Object container;
@Nullable
private final Integer containerIndex;
@Nullable
private final Object containerKey;
private final Errors errors;
private final Set<ConstraintViolation<Object>> violations = new LinkedHashSet<>();
public BeanResultBuilder(MethodParameter parameter, @Nullable Object argument, Path.Node node) {
this.parameter = parameter;
this.containerIndex = node.getIndex();
this.containerKey = node.getKey();
if (argument instanceof List<?> list && this.containerIndex != null) {
this.container = list;
argument = list.get(this.containerIndex);
}
else if (argument instanceof Map<?, ?> map && this.containerKey != null) {
this.container = map;
argument = map.get(this.containerKey);
}
else {
this.container = null;
}
this.argument = argument;
this.errors = createBindingResult(parameter, argument);
}
public void addViolation(ConstraintViolation<Object> violation) {
this.violations.add(violation);
}
public ParameterErrors build() {
validatorAdapter.get().processConstraintViolations(this.violations, this.errors);
return new ParameterErrors(
this.parameter, this.argument, this.errors, this.violations,
this.container, this.containerIndex, this.containerKey);
}
}
/**
* Comparator for validation results, sorted by method parameter index first,
* also falling back on container indexes if necessary for cascaded
* constraints on a List container.
*/
private final static class ResultComparator implements Comparator<ParameterValidationResult> {
@Override
public int compare(ParameterValidationResult result1, ParameterValidationResult result2) {
int index1 = result1.getMethodParameter().getParameterIndex();
int index2 = result2.getMethodParameter().getParameterIndex();
int i = Integer.compare(index1, index2);
if (i != 0) {
return i;
}
if (result1 instanceof ParameterErrors errors1 && result2 instanceof ParameterErrors errors2) {
Integer containerIndex1 = errors1.getContainerIndex();
Integer containerIndex2 = errors2.getContainerIndex();
if (containerIndex1 != null && containerIndex2 != null) {
i = Integer.compare(containerIndex1, containerIndex2);
return i;
}
}
return 0;
}
}
}

View File

@ -1,161 +0,0 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.function.Supplier;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import jakarta.validation.executable.ExecutableValidator;
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.BridgeMethodResolver;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
import org.springframework.util.function.SingletonSupplier;
import org.springframework.validation.annotation.Validated;
/**
* Helper class to apply method-level validation on annotated methods via
* {@link jakarta.validation.Valid}.
*
* <p>Used by {@link MethodValidationInterceptor}.
*
* @author Rossen Stoyanchev
* @since 6.1.0
*/
public class MethodValidationDelegate {
private final Supplier<Validator> validator;
/**
* Create an instance using a default JSR-303 validator underneath.
*/
public MethodValidationDelegate() {
this.validator = SingletonSupplier.of(() -> Validation.buildDefaultValidatorFactory().getValidator());
}
/**
* Create an instance using the given JSR-303 ValidatorFactory.
* @param validatorFactory the JSR-303 ValidatorFactory to use
*/
public MethodValidationDelegate(ValidatorFactory validatorFactory) {
this.validator = SingletonSupplier.of(validatorFactory::getValidator);
}
/**
* Create an instance using the given JSR-303 Validator.
* @param validator the JSR-303 Validator to use
*/
public MethodValidationDelegate(Validator validator) {
this.validator = () -> validator;
}
/**
* Create an instance for the supplied (potentially lazily initialized) Validator.
* @param validator a Supplier for the Validator to use
*/
public MethodValidationDelegate(Supplier<Validator> validator) {
this.validator = validator;
}
/**
* Use this method determine the validation groups to pass into
* {@link #validateMethodArguments(Object, Method, Object[], Class[])} and
* {@link #validateMethodReturnValue(Object, Method, Object, Class[])}.
* <p>Default are the validation groups as specified in the {@link Validated}
* annotation on the method, or on the containing target class of the method,
* or for an AOP proxy without a target (with all behavior in advisors), also
* check on proxied interfaces.
* @param target the target Object
* @param method the target method
* @return the applicable validation groups as a {@code Class} array
*/
public Class<?>[] determineValidationGroups(Object target, Method method) {
Validated validatedAnn = AnnotationUtils.findAnnotation(method, Validated.class);
if (validatedAnn == null) {
if (AopUtils.isAopProxy(target)) {
for (Class<?> type : AopProxyUtils.proxiedUserInterfaces(target)) {
validatedAnn = AnnotationUtils.findAnnotation(type, Validated.class);
if (validatedAnn != null) {
break;
}
}
}
else {
validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class);
}
}
return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
}
/**
* Validate the given method arguments and raise {@link ConstraintViolation}
* in case of any errors.
* @param target the target Object
* @param method the target method
* @param arguments candidate arguments for a method invocation
* @param groups groups for validation determined via
* {@link #determineValidationGroups(Object, Method)}
*/
public void validateMethodArguments(Object target, Method method, Object[] arguments, Class<?>[] groups) {
ExecutableValidator execVal = this.validator.get().forExecutables();
Set<ConstraintViolation<Object>> result;
try {
result = execVal.validateParameters(target, method, arguments, groups);
}
catch (IllegalArgumentException ex) {
// Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
// Let's try to find the bridged method on the implementation class...
Method mostSpecificMethod = ClassUtils.getMostSpecificMethod(method, target.getClass());
Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(mostSpecificMethod);
result = execVal.validateParameters(target, bridgedMethod, arguments, groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
}
/**
* Validate the given return value and raise {@link ConstraintViolation}
* in case of any errors.
* @param target the target Object
* @param method the target method
* @param returnValue value returned from invoking the target method
* @param groups groups for validation determined via
* {@link #determineValidationGroups(Object, Method)}
*/
public void validateMethodReturnValue(
Object target, Method method, @Nullable Object returnValue, Class<?>[] groups) {
ExecutableValidator execVal = this.validator.get().forExecutables();
Set<ConstraintViolation<Object>> result = execVal.validateReturnValue(target, method, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
}
}

View File

@ -0,0 +1,118 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Set;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
/**
* Extension of {@link ConstraintViolationException} that exposes an additional
* list of {@link ParameterValidationResult} with violations adapted to
* {@link org.springframework.context.MessageSourceResolvable} and grouped by
* method parameter.
*
* <p>For {@link jakarta.validation.Valid @Valid}-annotated, Object method
* parameters or return types with cascaded violations, the {@link ParameterErrors}
* subclass of {@link ParameterValidationResult} implements
* {@link org.springframework.validation.Errors} and exposes
* {@link org.springframework.validation.FieldError field errors}.
*
* @author Rossen Stoyanchev
* @since 6.1
* @see ParameterValidationResult
* @see ParameterErrors
* @see MethodValidationAdapter
*/
@SuppressWarnings("serial")
public class MethodValidationException extends ConstraintViolationException {
private final Object target;
private final Method method;
private final List<ParameterValidationResult> allValidationResults;
public MethodValidationException(
Object target, Method method,
List<ParameterValidationResult> validationResults,
Set<? extends ConstraintViolation<?>> violations) {
super(violations);
this.target = target;
this.method = method;
this.allValidationResults = validationResults;
}
/**
* Return the target of the method invocation to which validation was applied.
*/
public Object getTarget() {
return this.target;
}
/**
* Return the method to which validation was applied.
*/
public Method getMethod() {
return this.method;
}
/**
* Return all validation results. This includes method parameters with
* constraints declared on them, as well as
* {@link jakarta.validation.Valid @Valid} method parameters with
* cascaded constraints.
* @see #getValueResults()
* @see #getBeanResults()
*/
public List<ParameterValidationResult> getAllValidationResults() {
return this.allValidationResults;
}
/**
* Return only validation results for method parameters with constraints
* declared directly on them. This excludes
* {@link jakarta.validation.Valid @Valid} method parameters with cascaded
* constraints.
* @see #getAllValidationResults()
*/
public List<ParameterValidationResult> getValueResults() {
return this.allValidationResults.stream()
.filter(result -> !(result instanceof ParameterErrors))
.toList();
}
/**
* Return only validation results for {@link jakarta.validation.Valid @Valid}
* method parameters with cascaded constraints. This excludes method
* parameters with constraints declared directly on them.
* @see #getAllValidationResults()
*/
public List<ParameterErrors> getBeanResults() {
return this.allValidationResults.stream()
.filter(result -> result instanceof ParameterErrors)
.map(result -> (ParameterErrors) result)
.toList();
}
}

View File

@ -55,14 +55,14 @@ import org.springframework.validation.annotation.Validated;
*/ */
public class MethodValidationInterceptor implements MethodInterceptor { public class MethodValidationInterceptor implements MethodInterceptor {
private final MethodValidationDelegate delegate; private final MethodValidationAdapter delegate;
/** /**
* Create a new MethodValidationInterceptor using a default JSR-303 validator underneath. * Create a new MethodValidationInterceptor using a default JSR-303 validator underneath.
*/ */
public MethodValidationInterceptor() { public MethodValidationInterceptor() {
this.delegate = new MethodValidationDelegate(); this.delegate = new MethodValidationAdapter();
} }
/** /**
@ -70,7 +70,7 @@ public class MethodValidationInterceptor implements MethodInterceptor {
* @param validatorFactory the JSR-303 ValidatorFactory to use * @param validatorFactory the JSR-303 ValidatorFactory to use
*/ */
public MethodValidationInterceptor(ValidatorFactory validatorFactory) { public MethodValidationInterceptor(ValidatorFactory validatorFactory) {
this.delegate = new MethodValidationDelegate(validatorFactory); this.delegate = new MethodValidationAdapter(validatorFactory);
} }
/** /**
@ -78,7 +78,7 @@ public class MethodValidationInterceptor implements MethodInterceptor {
* @param validator the JSR-303 Validator to use * @param validator the JSR-303 Validator to use
*/ */
public MethodValidationInterceptor(Validator validator) { public MethodValidationInterceptor(Validator validator) {
this.delegate = new MethodValidationDelegate(validator); this.delegate = new MethodValidationAdapter(validator);
} }
/** /**
@ -88,7 +88,7 @@ public class MethodValidationInterceptor implements MethodInterceptor {
* @since 6.0 * @since 6.0
*/ */
public MethodValidationInterceptor(Supplier<Validator> validator) { public MethodValidationInterceptor(Supplier<Validator> validator) {
this.delegate = new MethodValidationDelegate(validator); this.delegate = new MethodValidationAdapter(validator);
} }
@ -153,7 +153,7 @@ public class MethodValidationInterceptor implements MethodInterceptor {
protected Class<?>[] determineValidationGroups(MethodInvocation invocation) { protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
Object target = getTarget(invocation); Object target = getTarget(invocation);
Method method = invocation.getMethod(); Method method = invocation.getMethod();
return this.delegate.determineValidationGroups(target, method); return MethodValidationAdapter.determineValidationGroups(target, method);
} }
} }

View File

@ -0,0 +1,263 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import jakarta.validation.ConstraintViolation;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.validation.Errors;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
/**
* Extension of {@link ParameterValidationResult} that's created for Object
* method arguments or return values with cascaded violations on their properties.
* Such method parameters are annotated with {@link jakarta.validation.Valid @Valid},
* or in the case of return values, the annotation is on the method.
*
* <p>In addition to the (generic) {@link #getResolvableErrors()
* MessageSourceResolvable errors} from the base class, this subclass implements
* {@link Errors} to expose convenient access to the same as {@link FieldError}s.
*
* <p>When {@code @Valid} is declared on a {@link List} or {@link java.util.Map}
* parameter, a separate {@link ParameterErrors} is created for each list or map
* value for which there are constraint violations. In such cases, the
* {@link #getContainer()} is the list or map, while {@link #getContainerIndex()}
* and {@link #getContainerKey()} reflect the index or key of the value.
*
* @author Rossen Stoyanchev
* @since 6.1
*/
public class ParameterErrors extends ParameterValidationResult implements Errors {
private final Errors errors;
@Nullable
private final Object container;
@Nullable
private final Integer containerIndex;
@Nullable
private final Object containerKey;
/**
* Create a {@code ParameterErrors}.
*/
public ParameterErrors(
MethodParameter parameter, @Nullable Object argument, Errors errors,
Set<ConstraintViolation<Object>> violations,
@Nullable Object container, @Nullable Integer index, @Nullable Object key) {
super(parameter, argument, new ArrayList<>(errors.getAllErrors()), violations);
this.errors = errors;
this.container = container;
this.containerIndex = index;
this.containerKey = key;
}
/**
* When {@code @Valid} is declared on a {@link List} or {@link java.util.Map}
* method parameter, this method returns the list or map that contained the
* validated object {@link #getArgument() argument}, while
* {@link #getContainerIndex()} and {@link #getContainerKey()} returns the
* respective index or key.
*/
@Nullable
public Object getContainer() {
return this.container;
}
/**
* When {@code @Valid} is declared on a {@link List}, this method returns
* the index under which the validated object {@link #getArgument() argument}
* is stored in the list {@link #getContainer() container}.
*/
@Nullable
public Integer getContainerIndex() {
return this.containerIndex;
}
/**
* When {@code @Valid} is declared on a {@link java.util.Map}, this method
* returns the key under which the validated object {@link #getArgument()
* argument} is stored in the map {@link #getContainer()}.
*/
@Nullable
public Object getContainerKey() {
return this.containerKey;
}
// Errors implementation
@Override
public String getObjectName() {
return this.errors.getObjectName();
}
@Override
public void setNestedPath(String nestedPath) {
this.errors.setNestedPath(nestedPath);
}
@Override
public String getNestedPath() {
return this.errors.getNestedPath();
}
@Override
public void pushNestedPath(String subPath) {
this.errors.pushNestedPath(subPath);
}
@Override
public void popNestedPath() throws IllegalStateException {
this.errors.popNestedPath();
}
@Override
public void reject(String errorCode) {
this.errors.reject(errorCode);
}
@Override
public void reject(String errorCode, String defaultMessage) {
this.errors.reject(errorCode, defaultMessage);
}
@Override
public void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage) {
this.errors.reject(errorCode, errorArgs, defaultMessage);
}
@Override
public void rejectValue(@Nullable String field, String errorCode) {
this.errors.rejectValue(field, errorCode);
}
@Override
public void rejectValue(@Nullable String field, String errorCode, String defaultMessage) {
this.errors.rejectValue(field, errorCode, defaultMessage);
}
@Override
public void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage) {
this.errors.rejectValue(field, errorCode, errorArgs, defaultMessage);
}
@Override
public void addAllErrors(Errors errors) {
this.errors.addAllErrors(errors);
}
@Override
public boolean hasErrors() {
return this.errors.hasErrors();
}
@Override
public int getErrorCount() {
return this.errors.getErrorCount();
}
@Override
public List<ObjectError> getAllErrors() {
return this.errors.getAllErrors();
}
@Override
public boolean hasGlobalErrors() {
return this.errors.hasGlobalErrors();
}
@Override
public int getGlobalErrorCount() {
return this.errors.getGlobalErrorCount();
}
@Override
public List<ObjectError> getGlobalErrors() {
return this.errors.getGlobalErrors();
}
@Override
public ObjectError getGlobalError() {
return this.errors.getGlobalError();
}
@Override
public boolean hasFieldErrors() {
return this.errors.hasFieldErrors();
}
@Override
public int getFieldErrorCount() {
return this.errors.getFieldErrorCount();
}
@Override
public List<FieldError> getFieldErrors() {
return this.errors.getFieldErrors();
}
@Override
public FieldError getFieldError() {
return this.errors.getFieldError();
}
@Override
public boolean hasFieldErrors(String field) {
return this.errors.hasFieldErrors(field);
}
@Override
public int getFieldErrorCount(String field) {
return this.errors.getFieldErrorCount(field);
}
@Override
public List<FieldError> getFieldErrors(String field) {
return this.errors.getFieldErrors(field);
}
@Override
public FieldError getFieldError(String field) {
return this.errors.getFieldError(field);
}
@Override
public Object getFieldValue(String field) {
return this.errors.getFieldError(field);
}
@Override
public Class<?> getFieldType(String field) {
return this.errors.getFieldType(field);
}
}

View File

@ -0,0 +1,150 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.util.List;
import java.util.Set;
import jakarta.validation.ConstraintViolation;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* Store and expose the results of method validation via
* {@link jakarta.validation.Validator} for a specific method parameter.
* <ul>
* <li>For a constraints directly on a method parameter, each
* {@link ConstraintViolation} is adapted to {@link MessageSourceResolvable}.
* <li>For cascaded constraints via {@link jakarta.validation.Validator @Valid}
* on a bean method parameter, {@link SpringValidatorAdapter} is used to initialize
* an {@link org.springframework.validation.Errors} with field errors, and create
* the {@link ParameterErrors} sub-class.
* </ul>
*
* @author Rossen Stoyanchev
* @since 6.1
*/
public class ParameterValidationResult {
private final MethodParameter methodParameter;
@Nullable
private final Object argument;
private final List<MessageSourceResolvable> resolvableErrors;
private final Set<ConstraintViolation<Object>> violations;
/**
* Create a {@code ParameterValidationResult}.
*/
public ParameterValidationResult(
MethodParameter methodParameter, @Nullable Object argument, List<MessageSourceResolvable> errors,
Set<ConstraintViolation<Object>> violations) {
Assert.notNull(methodParameter, "`MethodParameter` is required");
Assert.notEmpty(errors, "`resolvableErrors` must not be empty");
Assert.notEmpty(violations, "'violations' must not be empty");
this.methodParameter = methodParameter;
this.argument = argument;
this.resolvableErrors = List.copyOf(errors);
this.violations = violations;
}
/**
* The method parameter the validation results are for.
*/
public MethodParameter getMethodParameter() {
return this.methodParameter;
}
/**
* The method argument value that was validated.
*/
@Nullable
public Object getArgument() {
return this.argument;
}
/**
* List of {@link MessageSourceResolvable} representations adapted from the
* underlying {@link #getViolations() violations}.
* <ul>
* <li>For a constraints directly on a method parameter, error codes are
* based on the names of the constraint annotation, the object, the method,
* the parameter, and parameter type, e.g.
* {@code ["Max.myObject#myMethod.myParameter", "Max.myParameter", "Max.int", "Max"]}.
* Arguments include the parameter itself as a {@link MessageSourceResolvable}, e.g.
* {@code ["myObject#myMethod.myParameter", "myParameter"]}, followed by actual
* constraint annotation attributes (i.e. excluding "message", "groups" and
* "payload") in alphabetical order of attribute names.
* <li>For cascaded constraints via {@link jakarta.validation.Validator @Valid}
* on a bean method parameter, this method returns
* {@link org.springframework.validation.FieldError field errors} that you
* can also access more conveniently through methods of the
* {@link ParameterErrors} sub-class.
* </ul>
*/
public List<MessageSourceResolvable> getResolvableErrors() {
return this.resolvableErrors;
}
/**
* The violations associated with the method parameter.
*/
public Set<ConstraintViolation<Object>> getViolations() {
return this.violations;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!super.equals(other)) {
return false;
}
ParameterValidationResult otherResult = (ParameterValidationResult) other;
return (getMethodParameter().equals(otherResult.getMethodParameter()) &&
ObjectUtils.nullSafeEquals(getArgument(), otherResult.getArgument()) &&
getViolations().equals(otherResult.getViolations()));
}
@Override
public int hashCode() {
int hashCode = super.hashCode();
hashCode = 29 * hashCode + getMethodParameter().hashCode();
hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(getArgument());
hashCode = 29 * hashCode + (getViolations().hashCode());
return hashCode;
}
@Override
public String toString() {
return "Validation results for method parameter '" + this.methodParameter +
"': argument [" + ObjectUtils.nullSafeConciseToString(this.argument) + "]; " +
getResolvableErrors();
}
}

View File

@ -38,6 +38,7 @@ import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.validation.Errors; import org.springframework.validation.Errors;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
@ -201,7 +202,7 @@ public class SpringValidatorAdapter implements SmartValidator, jakarta.validatio
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
boolean first = true; boolean first = true;
for (Path.Node node : path) { for (Path.Node node : path) {
if (node.isInIterable()) { if (node.isInIterable() && !first) {
sb.append('['); sb.append('[');
Object index = node.getIndex(); Object index = node.getIndex();
if (index == null) { if (index == null) {
@ -286,7 +287,9 @@ public class SpringValidatorAdapter implements SmartValidator, jakarta.validatio
* @see #getArgumentsForConstraint * @see #getArgumentsForConstraint
*/ */
protected MessageSourceResolvable getResolvableField(String objectName, String field) { protected MessageSourceResolvable getResolvableField(String objectName, String field) {
String[] codes = new String[] {objectName + Errors.NESTED_PATH_SEPARATOR + field, field}; String[] codes = (StringUtils.hasText(field) ?
new String[] {objectName + Errors.NESTED_PATH_SEPARATOR + field, field} :
new String[] {objectName});
return new DefaultMessageSourceResolvable(codes, field); return new DefaultMessageSourceResolvable(codes, field);
} }

View File

@ -0,0 +1,236 @@
/*
* Copyright 2002-2023 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.validation.beanvalidation;
import java.lang.reflect.Method;
import java.util.List;
import java.util.function.Consumer;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.junit.jupiter.api.Test;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
import org.springframework.validation.FieldError;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Unit tests for {@link MethodValidationAdapter}.
* @author Rossen Stoyanchev
*/
public class MethodValidationAdapterTests {
private static final Person faustino1234 = new Person("Faustino1234");
private static final Person cayetana6789 = new Person("Cayetana6789");
@Test
void validateArguments() {
MyService target = new MyService();
Method method = getMethod(target, "addStudent");
validateArguments(target, method, new Object[] {faustino1234, cayetana6789, 3}, ex -> {
assertThat(ex.getConstraintViolations()).hasSize(3);
assertThat(ex.getAllValidationResults()).hasSize(3);
assertBeanResult(ex.getBeanResults().get(0), 0, "student", faustino1234, List.of(
"""
Field error in object 'student' on field 'name': rejected value [Faustino1234]; \
codes [Size.student.name,Size.name,Size.java.lang.String,Size]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [student.name,name]; arguments []; default message [name],10,1]; \
default message [size must be between 1 and 10]"""));
assertBeanResult(ex.getBeanResults().get(1), 1, "guardian", cayetana6789, List.of(
"""
Field error in object 'guardian' on field 'name': rejected value [Cayetana6789]; \
codes [Size.guardian.name,Size.name,Size.java.lang.String,Size]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [guardian.name,name]; arguments []; default message [name],10,1]; \
default message [size must be between 1 and 10]"""));
assertValueResult(ex.getValueResults().get(0), 2, 3, List.of(
"""
org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [Max.myService#addStudent.degrees,Max.degrees,Max.int,Max]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [myService#addStudent.degrees,degrees]; arguments []; default message [degrees],2]; \
default message [must be less than or equal to 2]"""
));
});
}
@Test
void validateReturnValue() {
MyService target = new MyService();
validateReturnValue(target, getMethod(target, "getIntValue"), 4, ex -> {
assertThat(ex.getConstraintViolations()).hasSize(1);
assertThat(ex.getAllValidationResults()).hasSize(1);
assertValueResult(ex.getValueResults().get(0), -1, 4, List.of(
"""
org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [Min.myService#getIntValue,Min,Min.int]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [myService#getIntValue]; arguments []; default message [],5]; \
default message [must be greater than or equal to 5]"""
));
});
}
@Test
void validateReturnValueBean() {
MyService target = new MyService();
validateReturnValue(target, getMethod(target, "getPerson"), faustino1234, ex -> {
assertThat(ex.getConstraintViolations()).hasSize(1);
assertThat(ex.getAllValidationResults()).hasSize(1);
assertBeanResult(ex.getBeanResults().get(0), -1, "person", faustino1234, List.of(
"""
Field error in object 'person' on field 'name': rejected value [Faustino1234]; \
codes [Size.person.name,Size.name,Size.java.lang.String,Size]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [person.name,name]; arguments []; default message [name],10,1]; \
default message [size must be between 1 and 10]"""));
});
}
@Test
void validateListArgument() {
MyService target = new MyService();
Method method = getMethod(target, "addPeople");
validateArguments(target, method, new Object[] {List.of(faustino1234, cayetana6789)}, ex -> {
assertThat(ex.getConstraintViolations()).hasSize(2);
assertThat(ex.getAllValidationResults()).hasSize(2);
int paramIndex = 0;
String objectName = "people";
List<ParameterErrors> results = ex.getBeanResults();
assertBeanResult(results.get(0), paramIndex, objectName, faustino1234, List.of(
"""
Field error in object 'people' on field 'name': rejected value [Faustino1234]; \
codes [Size.people.name,Size.name,Size.java.lang.String,Size]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [people.name,name]; arguments []; default message [name],10,1]; \
default message [size must be between 1 and 10]"""));
assertBeanResult(results.get(1), paramIndex, objectName, cayetana6789, List.of(
"""
Field error in object 'people' on field 'name': rejected value [Cayetana6789]; \
codes [Size.people.name,Size.name,Size.java.lang.String,Size]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \
codes [people.name,name]; arguments []; default message [name],10,1]; \
default message [size must be between 1 and 10]"""));
});
}
private void validateArguments(
Object target, Method method, Object[] arguments, Consumer<MethodValidationException> assertions) {
MethodValidationAdapter adapter = new MethodValidationAdapter();
assertThatExceptionOfType(MethodValidationException.class)
.isThrownBy(() -> adapter.validateMethodArguments(target, method, arguments, new Class<?>[0]))
.satisfies(assertions);
}
private void validateReturnValue(
Object target, Method method, @Nullable Object returnValue, Consumer<MethodValidationException> assertions) {
MethodValidationAdapter adapter = new MethodValidationAdapter();
assertThatExceptionOfType(MethodValidationException.class)
.isThrownBy(() -> adapter.validateMethodReturnValue(target, method, returnValue, new Class<?>[0]))
.satisfies(assertions);
}
private static void assertBeanResult(
ParameterErrors errors, int parameterIndex, String objectName, Object argument,
List<String> fieldErrors) {
assertThat(errors.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex);
assertThat(errors.getObjectName()).isEqualTo(objectName);
assertThat(errors.getArgument()).isSameAs(argument);
assertThat(errors.getFieldErrors())
.extracting(FieldError::toString)
.containsExactlyInAnyOrderElementsOf(fieldErrors);
}
private static void assertValueResult(
ParameterValidationResult result, int parameterIndex, Object argument, List<String> errors) {
assertThat(result.getMethodParameter().getParameterIndex()).isEqualTo(parameterIndex);
assertThat(result.getArgument()).isEqualTo(argument);
assertThat(result.getResolvableErrors())
.extracting(MessageSourceResolvable::toString)
.containsExactlyInAnyOrderElementsOf(errors);
}
private static Method getMethod(Object target, String methodName) {
return ClassUtils.getMethod(target.getClass(), methodName, (Class<?>[]) null);
}
@SuppressWarnings("unused")
private static class MyService {
public void addStudent(@Valid Person student, @Valid Person guardian, @Max(2) int degrees) {
}
@Min(5)
public int getIntValue() {
throw new UnsupportedOperationException();
}
@Valid
public Person getPerson() {
throw new UnsupportedOperationException();
}
public void addPeople(@Valid List<Person> people) {
}
}
@SuppressWarnings("unused")
private record Person(@Size(min = 1, max = 10) String name) {
@Override
public String name() {
return this.name;
}
}
}