Add method validation to Spring MVC

See gh-29825
This commit is contained in:
Rossen Stoyanchev
2023-06-08 15:39:22 +01:00
committed by rstoyanchev
parent cb04c3b335
commit bd054a4918
15 changed files with 1000 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* 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.
@@ -16,7 +16,11 @@
package org.springframework.web.bind.support;
import java.lang.annotation.Annotation;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.validation.DataBinder;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.NativeWebRequest;
@@ -32,6 +36,8 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory {
@Nullable
private final WebBindingInitializer initializer;
private boolean methodValidationApplicable;
/**
* Create a new {@code DefaultDataBinderFactory} instance.
@@ -43,6 +49,17 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory {
}
/**
* Configure flag to signal whether validation will be applied to handler
* method arguments, which is the case if Bean Validation is enabled in
* Spring MVC, and method parameters have {@code @Constraint} annotations.
* @since 6.1
*/
public void setMethodValidationApplicable(boolean methodValidationApplicable) {
this.methodValidationApplicable = methodValidationApplicable;
}
/**
* Create a new {@link WebDataBinder} for the given target object and
* initialize it through a {@link WebBindingInitializer}.
@@ -87,4 +104,36 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory {
}
/**
* {@inheritDoc}.
* <p>By default, if the parameter has {@code @Valid}, Bean Validation is
* excluded, deferring to method validation.
*/
@Override
public WebDataBinder createBinder(
NativeWebRequest webRequest, @Nullable Object target, String objectName,
MethodParameter parameter) throws Exception {
WebDataBinder dataBinder = createBinder(webRequest, target, objectName);
if (this.methodValidationApplicable) {
MethodValidationInitializer.updateBinder(dataBinder, parameter);
}
return dataBinder;
}
/**
* Excludes Bean Validation if the method parameter has {@code @Valid}.
*/
private static class MethodValidationInitializer {
public static void updateBinder(DataBinder binder, MethodParameter parameter) {
for (Annotation annotation : parameter.getParameterAnnotations()) {
if (annotation.annotationType().getName().equals("jakarta.validation.Valid")) {
binder.setExcludedValidators(validator -> validator instanceof jakarta.validation.Validator);
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* 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.
@@ -16,6 +16,7 @@
package org.springframework.web.bind.support;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.NativeWebRequest;
@@ -24,6 +25,7 @@ import org.springframework.web.context.request.NativeWebRequest;
* A factory for creating a {@link WebDataBinder} instance for a named target object.
*
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @since 3.1
*/
public interface WebDataBinderFactory {
@@ -40,4 +42,18 @@ public interface WebDataBinderFactory {
WebDataBinder createBinder(NativeWebRequest webRequest, @Nullable Object target, String objectName)
throws Exception;
/**
* Variant of {@link #createBinder(NativeWebRequest, Object, String)} with a
* {@link MethodParameter} for which the {@code DataBinder} is created. This
* may provide more insight to initialize the {@link WebDataBinder}.
* @since 6.1
*/
default WebDataBinder createBinder(
NativeWebRequest webRequest, @Nullable Object target, String objectName,
MethodParameter parameter) throws Exception {
return createBinder(webRequest, target, objectName);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* 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.
@@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -35,6 +36,10 @@ import org.springframework.core.BridgeMethodResolver;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotationPredicates;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.SynthesizingMethodParameter;
import org.springframework.http.HttpStatusCode;
import org.springframework.lang.NonNull;
@@ -44,6 +49,7 @@ import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
@@ -84,6 +90,10 @@ public class HandlerMethod {
private final MethodParameter[] parameters;
private final boolean validateArguments;
private final boolean validateReturnValue;
@Nullable
private HttpStatusCode responseStatus;
@@ -122,6 +132,8 @@ public class HandlerMethod {
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters);
this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod);
evaluateResponseStatus();
this.description = initDescription(this.beanType, this.method);
}
@@ -141,6 +153,8 @@ public class HandlerMethod {
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(this.method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters);
this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod);
evaluateResponseStatus();
this.description = initDescription(this.beanType, this.method);
}
@@ -177,6 +191,8 @@ public class HandlerMethod {
this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method);
ReflectionUtils.makeAccessible(this.bridgedMethod);
this.parameters = initMethodParameters();
this.validateArguments = MethodValidationInitializer.checkArguments(this.beanType, this.parameters);
this.validateReturnValue = MethodValidationInitializer.checkReturnValue(this.beanType, this.bridgedMethod);
evaluateResponseStatus();
this.description = initDescription(this.beanType, this.method);
}
@@ -193,6 +209,8 @@ public class HandlerMethod {
this.method = handlerMethod.method;
this.bridgedMethod = handlerMethod.bridgedMethod;
this.parameters = handlerMethod.parameters;
this.validateArguments = handlerMethod.validateArguments;
this.validateReturnValue = handlerMethod.validateReturnValue;
this.responseStatus = handlerMethod.responseStatus;
this.responseStatusReason = handlerMethod.responseStatusReason;
this.description = handlerMethod.description;
@@ -212,6 +230,8 @@ public class HandlerMethod {
this.method = handlerMethod.method;
this.bridgedMethod = handlerMethod.bridgedMethod;
this.parameters = handlerMethod.parameters;
this.validateArguments = handlerMethod.validateArguments;
this.validateReturnValue = handlerMethod.validateReturnValue;
this.responseStatus = handlerMethod.responseStatus;
this.responseStatusReason = handlerMethod.responseStatusReason;
this.resolvedFromHandlerMethod = handlerMethod;
@@ -290,6 +310,33 @@ public class HandlerMethod {
return this.parameters;
}
/**
* Whether the method arguments are a candidate for method validation, which
* is the case when there are parameter {@code jakarta.validation.Constraint}
* annotations.
* <p>The presence of {@code jakarta.validation.Valid} by itself does not
* trigger method validation since such parameters are already validated at
* the level of argument resolvers.
* <p><strong>Note:</strong> if the class is annotated with {@link Validated},
* this method returns false, deferring to method validation via AOP proxy.
* @since 6.1
*/
public boolean shouldValidateArguments() {
return this.validateArguments;
}
/**
* Whether the method return value is a candidate for method validation, which
* is the case when there are method {@code jakarta.validation.Constraint}
* or {@code jakarta.validation.Valid} annotations.
* <p><strong>Note:</strong> if the class is annotated with {@link Validated},
* this method returns false, deferring to method validation via AOP proxy.
* @since 6.1
*/
public boolean shouldValidateReturnValue() {
return this.validateReturnValue;
}
/**
* Return the specified response status, if any.
* @since 4.3.8
@@ -603,4 +650,38 @@ public class HandlerMethod {
}
}
/**
* Checks for the presence of {@code @Constraint} and {@code @Valid}
* annotations on the method and method parameters.
*/
private static class MethodValidationInitializer {
private static final Predicate<MergedAnnotation<? extends Annotation>> INPUT_PREDICATE =
MergedAnnotationPredicates.typeIn("jakarta.validation.Constraint");
private static final Predicate<MergedAnnotation<? extends Annotation>> OUTPUT_PREDICATE =
MergedAnnotationPredicates.typeIn("jakarta.validation.Valid", "jakarta.validation.Constraint");
public static boolean checkArguments(Class<?> beanType, MethodParameter[] parameters) {
if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) {
for (MethodParameter parameter : parameters) {
MergedAnnotations merged = MergedAnnotations.from(parameter.getParameterAnnotations());
if (merged.stream().anyMatch(INPUT_PREDICATE)) {
return true;
}
}
}
return false;
}
public static boolean checkReturnValue(Class<?> beanType, Method method) {
if (AnnotationUtils.findAnnotation(beanType, Validated.class) == null) {
MergedAnnotations merged = MergedAnnotations.from(method, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY);
return merged.stream().anyMatch(OUTPUT_PREDICATE);
}
return false;
}
}
}

View File

@@ -164,7 +164,7 @@ public class ModelAttributeMethodProcessor implements HandlerMethodArgumentResol
if (bindingResult == null) {
// Bean property binding and validation;
// skipped in case of binding failure on construction.
WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name, parameter);
if (binder.getTarget() != null) {
if (!mavContainer.isBindingDisabled(name)) {
bindRequestParameters(binder, webRequest);
@@ -251,7 +251,7 @@ public class ModelAttributeMethodProcessor implements HandlerMethodArgumentResol
String[] paramNames = BeanUtils.getParameterNames(ctor);
Class<?>[] paramTypes = ctor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName);
WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName, parameter);
String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
boolean bindingFailure = false;

View File

@@ -0,0 +1,124 @@
/*
* 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.web.method.support;
import java.lang.reflect.Method;
import jakarta.validation.Validator;
import org.springframework.core.Conventions;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.lang.Nullable;
import org.springframework.validation.BindingResult;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.beanvalidation.DefaultMethodValidator;
import org.springframework.validation.beanvalidation.MethodValidationAdapter;
import org.springframework.validation.beanvalidation.MethodValidationResult;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.validation.beanvalidation.ParameterErrors;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.method.annotation.ModelFactory;
/**
* {@link org.springframework.validation.beanvalidation.MethodValidator} for
* use with {@code @RequestMapping} methods. Helps to determine object names
* and populates {@link BindingResult} method arguments with errors from
* {@link MethodValidationResult#getBeanResults() beanResults}.
*
* @author Rossen Stoyanchev
* @since 6.1
*/
public class HandlerMethodValidator extends DefaultMethodValidator {
public HandlerMethodValidator(MethodValidationAdapter adapter) {
super(adapter);
adapter.setBindingResultNameResolver(this::determineObjectName);
}
private String determineObjectName(MethodParameter param, @Nullable Object argument) {
if (param.hasParameterAnnotation(RequestBody.class) || param.hasParameterAnnotation(RequestPart.class)) {
return Conventions.getVariableNameForParameter(param);
}
else {
return ((param.getParameterIndex() != -1) ?
ModelFactory.getNameForParameter(param) :
ModelFactory.getNameForReturnValue(argument, param));
}
}
@Override
protected void handleArgumentsResult(
Object bean, Method method, Object[] arguments, Class<?>[] groups, MethodValidationResult result) {
if (result.getConstraintViolations().isEmpty()) {
return;
}
if (!result.getBeanResults().isEmpty()) {
int bindingResultCount = 0;
for (ParameterErrors errors : result.getBeanResults()) {
for (Object arg : arguments) {
if (arg instanceof BindingResult bindingResult) {
if (bindingResult.getObjectName().equals(errors.getObjectName())) {
bindingResult.addAllErrors(errors);
bindingResultCount++;
break;
}
}
}
}
if (result.getAllValidationResults().size() == bindingResultCount) {
return;
}
}
result.throwIfViolationsPresent();
}
/**
* Create a {@link MethodValidator} if Bean Validation is enabled in Spring MVC or WebFlux.
* @param bindingInitializer for the configured Validator and MessageCodesResolver
* @param parameterNameDiscoverer the {@code ParameterNameDiscoverer} to use
* for {@link MethodValidationAdapter#setParameterNameDiscoverer}
*/
@Nullable
public static MethodValidator from(
@Nullable WebBindingInitializer bindingInitializer,
@Nullable ParameterNameDiscoverer parameterNameDiscoverer) {
if (bindingInitializer instanceof ConfigurableWebBindingInitializer configurableInitializer) {
if (configurableInitializer.getValidator() instanceof Validator validator) {
MethodValidationAdapter validationAdapter = new MethodValidationAdapter(validator);
if (parameterNameDiscoverer != null) {
validationAdapter.setParameterNameDiscoverer(parameterNameDiscoverer);
}
MessageCodesResolver codesResolver = configurableInitializer.getMessageCodesResolver();
if (codesResolver != null) {
validationAdapter.setMessageCodesResolver(codesResolver);
}
return new HandlerMethodValidator(validationAdapter);
}
}
return null;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* 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.
@@ -30,6 +30,7 @@ import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.web.bind.support.WebDataBinderFactory;
@@ -50,6 +51,8 @@ public class InvocableHandlerMethod extends HandlerMethod {
private static final Object[] EMPTY_ARGS = new Object[0];
private static final Class<?>[] EMPTY_GROUPS = new Class<?>[0];
private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite();
@@ -58,6 +61,9 @@ public class InvocableHandlerMethod extends HandlerMethod {
@Nullable
private WebDataBinderFactory dataBinderFactory;
@Nullable
private MethodValidator methodValidator;
/**
* Create an instance from a {@code HandlerMethod}.
@@ -121,6 +127,16 @@ public class InvocableHandlerMethod extends HandlerMethod {
this.dataBinderFactory = dataBinderFactory;
}
/**
* Set the {@link MethodValidator} to perform method validation with if the
* controller method {@link #shouldValidateArguments()} or
* {@link #shouldValidateReturnValue()}.
* @since 6.1
*/
public void setMethodValidator(@Nullable MethodValidator methodValidator) {
this.methodValidator = methodValidator;
}
/**
* Invoke the method after resolving its argument values in the context of the given request.
@@ -149,7 +165,19 @@ public class InvocableHandlerMethod extends HandlerMethod {
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
Class<?>[] groups = getValidationGroups();
if (shouldValidateArguments() && this.methodValidator != null) {
this.methodValidator.validateArguments(getBean(), getBridgedMethod(), args, groups);
}
Object returnValue = doInvoke(args);
if (shouldValidateReturnValue() && this.methodValidator != null) {
this.methodValidator.validateReturnValue(getBean(), getBridgedMethod(), returnValue, groups);
}
return returnValue;
}
/**
@@ -194,6 +222,11 @@ public class InvocableHandlerMethod extends HandlerMethod {
return args;
}
private Class<?>[] getValidationGroups() {
return ((shouldValidateArguments() || shouldValidateReturnValue()) && this.methodValidator != null ?
this.methodValidator.determineValidationGroups(getBean(), getBridgedMethod()) : EMPTY_GROUPS);
}
/**
* Invoke the handler method with the given argument values.
*/