Add method validation to WebFlux

See gh-29825
This commit is contained in:
Rossen Stoyanchev
2023-06-12 11:35:52 +01:00
committed by rstoyanchev
parent bd054a4918
commit 6b89cf94a3
12 changed files with 598 additions and 18 deletions

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.
@@ -16,13 +16,17 @@
package org.springframework.web.reactive;
import java.lang.annotation.Annotation;
import java.util.Collections;
import java.util.Map;
import reactor.core.publisher.Mono;
import org.springframework.core.MethodParameter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.lang.Nullable;
import org.springframework.ui.Model;
import org.springframework.validation.DataBinder;
import org.springframework.validation.support.BindingAwareConcurrentModel;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.bind.support.WebExchangeDataBinder;
@@ -50,6 +54,8 @@ public class BindingContext {
private final Model model = new BindingAwareConcurrentModel();
private boolean methodValidationApplicable;
/**
* Create a new {@code BindingContext}.
@@ -74,6 +80,16 @@ public class BindingContext {
return this.model;
}
/**
* 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 {@link WebExchangeDataBinder} to apply data binding and
@@ -112,6 +128,24 @@ public class BindingContext {
return createDataBinder(exchange, null, name);
}
/**
* Variant of {@link #createDataBinder(ServerWebExchange, Object, String)}
* with a {@link MethodParameter} for which the {@code DataBinder} is created.
* That may provide more insight to initialize the {@link WebExchangeDataBinder}.
* <p>By default, if the parameter has {@code @Valid}, Bean Validation is
* excluded, deferring to method validation.
* @since 6.1
*/
public WebExchangeDataBinder createDataBinder(
ServerWebExchange exchange, @Nullable Object target, String name, MethodParameter parameter) {
WebExchangeDataBinder dataBinder = createDataBinder(exchange, target, name);
if (this.methodValidationApplicable) {
MethodValidationInitializer.updateBinder(dataBinder, parameter);
}
return dataBinder;
}
/**
* Extended variant of {@link WebExchangeDataBinder}, adding path variables.
@@ -130,4 +164,21 @@ public class BindingContext {
}
}
/**
* Excludes Bean Validation if the method parameter has {@code @Valid}.
*/
private static class MethodValidationInitializer {
public static void updateBinder(DataBinder binder, MethodParameter parameter) {
if (ReactiveAdapterRegistry.getSharedInstance().getAdapter(parameter.getParameterType()) == null) {
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-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.
@@ -37,6 +37,7 @@ import org.springframework.http.HttpStatusCode;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.HandlerResult;
@@ -56,6 +57,8 @@ public class InvocableHandlerMethod extends HandlerMethod {
private static final Mono<Object[]> EMPTY_ARGS = Mono.just(new Object[0]);
private static final Class<?>[] EMPTY_GROUPS = new Class<?>[0];
private static final Object NO_ARG_VALUE = new Object();
@@ -65,6 +68,9 @@ public class InvocableHandlerMethod extends HandlerMethod {
private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
@Nullable
private MethodValidator methodValidator;
/**
* Create an instance from a {@code HandlerMethod}.
@@ -121,6 +127,16 @@ public class InvocableHandlerMethod extends HandlerMethod {
this.reactiveAdapterRegistry = registry;
}
/**
* 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 for the given exchange.
@@ -134,6 +150,10 @@ public class InvocableHandlerMethod extends HandlerMethod {
ServerWebExchange exchange, BindingContext bindingContext, Object... providedArgs) {
return getMethodArgumentValues(exchange, bindingContext, providedArgs).flatMap(args -> {
Class<?>[] groups = getValidationGroups();
if (shouldValidateArguments() && this.methodValidator != null) {
this.methodValidator.validateArguments(getBean(), getBridgedMethod(), args, groups);
}
Object value;
Method method = getBridgedMethod();
boolean isSuspendingFunction = KotlinDetector.isSuspendingFunction(method);
@@ -225,6 +245,11 @@ public class InvocableHandlerMethod extends HandlerMethod {
}
}
private Class<?>[] getValidationGroups() {
return ((shouldValidateArguments() || shouldValidateReturnValue()) && this.methodValidator != null ?
this.methodValidator.determineValidationGroups(getBean(), getBridgedMethod()) : EMPTY_GROUPS);
}
private static boolean isAsyncVoidReturnType(MethodParameter returnType, @Nullable ReactiveAdapter adapter) {
if (adapter != null && adapter.supportsEmpty()) {
if (adapter.isNoValue()) {

View File

@@ -269,7 +269,7 @@ public abstract class AbstractMessageReaderArgumentResolver extends HandlerMetho
BindingContext binding, ServerWebExchange exchange) {
String name = Conventions.getVariableNameForParameter(param);
WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name);
WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name, param);
try {
LocaleContextHolder.setLocaleContext(exchange.getLocaleContext());
binder.validate(validationHints);

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.
@@ -39,6 +39,7 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils.MethodFilter;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -94,6 +95,9 @@ class ControllerMethodResolver {
private final ReactiveAdapterRegistry reactiveAdapterRegistry;
@Nullable
private final MethodValidator methodValidator;
private final Map<Class<?>, Set<Method>> initBinderMethodCache = new ConcurrentHashMap<>(64);
private final Map<Class<?>, Set<Method>> modelAttributeMethodCache = new ConcurrentHashMap<>(64);
@@ -110,8 +114,10 @@ class ControllerMethodResolver {
private final Map<Class<?>, SessionAttributesHandler> sessionAttributesHandlerCache = new ConcurrentHashMap<>(64);
ControllerMethodResolver(ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry,
ConfigurableApplicationContext context, List<HttpMessageReader<?>> readers) {
ControllerMethodResolver(
ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry,
ConfigurableApplicationContext context, List<HttpMessageReader<?>> readers,
@Nullable MethodValidator methodValidator) {
Assert.notNull(customResolvers, "ArgumentResolverConfigurer is required");
Assert.notNull(adapterRegistry, "ReactiveAdapterRegistry is required");
@@ -123,6 +129,7 @@ class ControllerMethodResolver {
this.requestMappingResolvers = requestMappingResolvers(customResolvers, adapterRegistry, context, readers);
this.exceptionHandlerResolvers = exceptionHandlerResolvers(customResolvers, adapterRegistry, context);
this.reactiveAdapterRegistry = adapterRegistry;
this.methodValidator = methodValidator;
initControllerAdviceCaches(context);
}
@@ -260,6 +267,7 @@ class ControllerMethodResolver {
InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod);
invocable.setArgumentResolvers(this.requestMappingResolvers);
invocable.setReactiveAdapterRegistry(this.reactiveAdapterRegistry);
invocable.setMethodValidator(this.methodValidator);
return invocable;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 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.
@@ -50,12 +50,14 @@ class InitBinderBindingContext extends BindingContext {
private Runnable saveModelOperation;
InitBinderBindingContext(@Nullable WebBindingInitializer initializer,
List<SyncInvocableHandlerMethod> binderMethods) {
InitBinderBindingContext(
@Nullable WebBindingInitializer initializer, List<SyncInvocableHandlerMethod> binderMethods,
boolean methodValidationApplicable) {
super(initializer);
this.binderMethods = binderMethods;
this.binderMethodContext = new BindingContext(initializer);
setMethodValidationApplicable(methodValidationApplicable);
}

View File

@@ -119,7 +119,7 @@ public class ModelAttributeMethodArgumentResolver extends HandlerMethodArgumentR
model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResultSink.asMono());
return valueMono.flatMap(value -> {
WebExchangeDataBinder binder = context.createDataBinder(exchange, value, name);
WebExchangeDataBinder binder = context.createDataBinder(exchange, value, name, parameter);
return (bindingDisabled(parameter) ? Mono.empty() : bindRequestParameters(binder, exchange))
.doOnError(bindingResultSink::tryEmitError)
.doOnSuccess(aVoid -> {

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.
@@ -33,9 +33,12 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.beanvalidation.MethodValidator;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.support.HandlerMethodValidator;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.reactive.DispatchExceptionHandler;
import org.springframework.web.reactive.HandlerAdapter;
@@ -57,6 +60,9 @@ public class RequestMappingHandlerAdapter
private static final Log logger = LogFactory.getLog(RequestMappingHandlerAdapter.class);
private final static boolean BEAN_VALIDATION_PRESENT =
ClassUtils.isPresent("jakarta.validation.Validator", HandlerMethod.class.getClassLoader());
private List<HttpMessageReader<?>> messageReaders = Collections.emptyList();
@@ -69,6 +75,9 @@ public class RequestMappingHandlerAdapter
@Nullable
private ReactiveAdapterRegistry reactiveAdapterRegistry;
@Nullable
private MethodValidator methodValidator;
@Nullable
private ConfigurableApplicationContext applicationContext;
@@ -170,9 +179,12 @@ public class RequestMappingHandlerAdapter
if (this.reactiveAdapterRegistry == null) {
this.reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
}
if (BEAN_VALIDATION_PRESENT) {
this.methodValidator = HandlerMethodValidator.from(this.webBindingInitializer, null);
}
this.methodResolver = new ControllerMethodResolver(this.argumentResolverConfigurer,
this.reactiveAdapterRegistry, this.applicationContext, this.messageReaders);
this.reactiveAdapterRegistry, this.applicationContext, this.messageReaders, this.methodValidator);
this.modelInitializer = new ModelInitializer(this.methodResolver, this.reactiveAdapterRegistry);
}
@@ -189,7 +201,8 @@ public class RequestMappingHandlerAdapter
Assert.state(this.methodResolver != null && this.modelInitializer != null, "Not initialized");
InitBinderBindingContext bindingContext = new InitBinderBindingContext(
getWebBindingInitializer(), this.methodResolver.getInitBinderMethods(handlerMethod));
this.webBindingInitializer, this.methodResolver.getInitBinderMethods(handlerMethod),
this.methodValidator != null && handlerMethod.shouldValidateArguments());
InvocableHandlerMethod invocableMethod = this.methodResolver.getRequestMappingMethod(handlerMethod);