Add support for Bean Validation

This commit adds support for validating handler method inputs after
binding from the data fetching environment and before invoking the
handler method.

This support is based on Bean Validation and is enabled only if the
application context contains a `Validator` bean.

Closes gh-110
This commit is contained in:
Brian Clozel
2021-12-10 15:50:16 +01:00
parent 58f2afebb5
commit 3e29e71a6e
9 changed files with 377 additions and 8 deletions

View File

@@ -76,7 +76,9 @@ configure(moduleProjects) {
dependency "com.jayway.jsonpath:json-path:2.5.0"
dependency "org.skyscreamer:jsonassert:1.5.0"
dependency "com.h2database:h2:1.4.200"
dependency "javax.validation:validation-api:2.0.1.Final"
dependency "org.hibernate:hibernate-core:5.6.1.Final"
dependency "org.hibernate.validator:hibernate-validator:6.2.0.Final"
dependencySet(group: 'org.mongodb', version: '4.3.2') {
entry 'mongodb-driver-sync'
entry 'mongodb-driver-reactivestreams'

View File

@@ -34,6 +34,7 @@ dependencies {
compileOnly 'org.springframework:spring-webmvc'
compileOnly 'org.springframework:spring-websocket'
compileOnly 'javax.servlet:javax.servlet-api'
compileOnly 'javax.validation:validation-api'
compileOnly 'javax.websocket:javax.websocket-api'
compileOnly 'io.micrometer:micrometer-core'
@@ -62,7 +63,10 @@ dependencies {
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'io.projectreactor.netty:reactor-netty'
testImplementation 'javax.servlet:javax.servlet-api'
testImplementation 'javax.validation:validation-api'
testImplementation 'org.hibernate.validator:hibernate-validator'
testImplementation 'org.apache.tomcat.embed:tomcat-embed-core'
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el'
testImplementation 'org.apache.tomcat.embed:tomcat-embed-websocket'
testImplementation 'org.springframework.boot:spring-boot-actuator-autoconfigure'
testImplementation 'io.micrometer:micrometer-core'

View File

@@ -26,6 +26,7 @@ import org.springframework.graphql.GraphQlService;
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
import org.springframework.graphql.execution.BatchLoaderRegistry;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@@ -50,6 +51,16 @@ class GraphQlServiceAutoConfigurationTests {
});
}
@Test
void shouldConfigureValidation() {
this.contextRunner.withUserConfiguration(ValidationConfiguration.class)
.run((context) -> {
assertThat(context).hasSingleBean(AnnotatedControllerConfigurer.class);
assertThat(context.getBean(AnnotatedControllerConfigurer.class))
.extracting("validator").isNotNull();
});
}
@Configuration(proxyBeanMethods = false)
static class GraphQlSourceConfiguration {
@@ -59,4 +70,13 @@ class GraphQlServiceAutoConfigurationTests {
}
}
@Configuration(proxyBeanMethods = false)
static class ValidationConfiguration {
@Bean
public LocalValidatorFactoryBean defaultValidator() {
return new LocalValidatorFactoryBean();
}
}
}

View File

@@ -821,6 +821,52 @@ You can use `@Argument` on a `Map<String, Object>` argument, to obtain all argum
values. The name attribute on `@Argument` must not be set.
[[controllers-schema-mapping-validation]]
==== `@Argument` validation
If a {spring-framework-ref-docs}/core.html#validation-beanvalidation-overview[Bean Validation]
`Validator` (or typically, a `LocalValidatorFactoryBean`) bean is present in the application context,
the `AnnotatedControllerConfigurer` will auto-detect it and configure support for validation.
Controller arguments annotated with `@Valid` and `@Validated` are then validated before method invocation.
Bean Validation lets you declare constraints on types, as the following example shows:
[source,java,indent=0,subs="verbatim,quotes"]
----
public class BookInput {
@NotNull
private String title;
@NotNull
@Size(max=13)
private String isbn;
}
----
We can then mark our argument for validation with `@Valid`:
[source,java,indent=0,subs="verbatim,quotes"]
----
@Controller
public class BookController {
@MutationMapping
public Book addBook(@Argument @Valid BookInput bookInput) {
// ...
}
}
----
If an error occurs during validation, a `ConstraintViolationException` is thrown and can be
later <<execution-exceptions,resolved with a custom `DataFetcherExceptionResolver`>>.
[TIP]
====
Unlike Spring MVC, handler method signatures do not support the injection of `BindingResult`
for reacting to validation errors: those are globally dealt with as exceptions.
====
[[controllers-schema-mapping-projectedpayload-argument]]
==== `@ProjectPayload` Interface

View File

@@ -22,6 +22,8 @@ dependencies {
compileOnly 'org.jetbrains.kotlinx:kotlinx-coroutines-core'
compileOnly 'org.jetbrains.kotlin:kotlin-stdlib'
compileOnly 'javax.validation:validation-api'
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.mockito:mockito-core'
@@ -35,6 +37,7 @@ dependencies {
testImplementation 'org.springframework.data:spring-data-jpa'
testImplementation 'com.h2database:h2'
testImplementation 'org.hibernate:hibernate-core'
testImplementation 'org.hibernate.validator:hibernate-validator'
testImplementation 'org.springframework.data:spring-data-mongodb'
testImplementation 'org.mongodb:mongodb-driver-sync'
testImplementation 'org.mongodb:mongodb-driver-reactivestreams'
@@ -44,8 +47,10 @@ dependencies {
testImplementation 'com.querydsl:querydsl-core'
testImplementation 'com.querydsl:querydsl-collections'
testImplementation 'javax.servlet:javax.servlet-api'
testImplementation 'javax.validation:validation-api'
testImplementation 'com.jayway.jsonpath:json-path'
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:9.0.55'
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl'

View File

@@ -27,6 +27,8 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.validation.Validator;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.FieldCoordinates;
@@ -66,6 +68,7 @@ import org.springframework.util.StringUtils;
* registers them as {@link DataFetcher}s.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 1.0.0
*/
public class AnnotatedControllerConfigurer
@@ -93,6 +96,9 @@ public class AnnotatedControllerConfigurer
"org.springframework.security.core.context.SecurityContext",
AnnotatedControllerConfigurer.class.getClassLoader());
private final static boolean beanValidationPresent = ClassUtils.isPresent(
"javax.validation.executable.ExecutableValidator",
AnnotatedControllerConfigurer.class.getClassLoader());
@Nullable
private ApplicationContext applicationContext;
@@ -100,6 +106,9 @@ public class AnnotatedControllerConfigurer
@Nullable
private HandlerMethodArgumentResolverComposite argumentResolvers;
@Nullable
private HandlerMethodInputValidator validator;
@Nullable
private ConversionService conversionService;
@@ -109,6 +118,9 @@ public class AnnotatedControllerConfigurer
this.applicationContext = applicationContext;
}
/**
* Configure the {@link ConversionService} used for binding handler arguments.
*/
public void setConversionService(ConversionService conversionService) {
this.conversionService = conversionService;
}
@@ -145,6 +157,10 @@ public class AnnotatedControllerConfigurer
// This works as a fallback, after other resolvers
this.argumentResolvers.addResolver(new SourceMethodArgumentResolver());
if (beanValidationPresent) {
this.validator = HandlerMethodInputValidatorFactory.create(obtainApplicationContext());
}
}
@Override
@@ -154,7 +170,7 @@ public class AnnotatedControllerConfigurer
findHandlerMethods().forEach((info) -> {
DataFetcher<?> dataFetcher;
if (!info.isBatchMapping()) {
dataFetcher = new SchemaMappingDataFetcher(info, this.argumentResolvers);
dataFetcher = new SchemaMappingDataFetcher(info, this.argumentResolvers, this.validator);
}
else {
String dataLoaderKey = registerBatchLoader(info);
@@ -281,7 +297,7 @@ public class AnnotatedControllerConfigurer
}
Assert.hasText(typeName,
"No parentType specified, and a source/parent method argument was also not found: " +
"No parentType specified, and a source/parent method argument was also not found: " +
handlerMethod.getShortLogMessage());
return new MappingInfo(typeName, field, batchMapping, handlerMethod);
@@ -304,7 +320,7 @@ public class AnnotatedControllerConfigurer
String methodParameters = Arrays.stream(method.getGenericParameterTypes())
.map(Type::getTypeName)
.collect(Collectors.joining(",", "(", ")"));
return mappingInfo.getCoordinates() + " => " + method.getName() + methodParameters;
return mappingInfo.getCoordinates() + " => " + method.getName() + methodParameters;
})
.collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", ""));
}
@@ -378,11 +394,16 @@ public class AnnotatedControllerConfigurer
private final HandlerMethodArgumentResolverComposite argumentResolvers;
@Nullable
private final HandlerMethodInputValidator validator;
private final boolean subscription;
public SchemaMappingDataFetcher(MappingInfo info, HandlerMethodArgumentResolverComposite resolvers) {
public SchemaMappingDataFetcher(MappingInfo info, HandlerMethodArgumentResolverComposite resolvers,
@Nullable HandlerMethodInputValidator validator) {
this.info = info;
this.argumentResolvers = resolvers;
this.validator = validator;
this.subscription = this.info.getCoordinates().getTypeName().equalsIgnoreCase("Subscription");
}
@@ -404,7 +425,7 @@ public class AnnotatedControllerConfigurer
@Override
@SuppressWarnings("ConstantConditions")
public Object get(DataFetchingEnvironment environment) throws Exception {
return new DataFetcherHandlerMethod(getHandlerMethod(), this.argumentResolvers, this.subscription).invoke(environment);
return new DataFetcherHandlerMethod(getHandlerMethod(), this.argumentResolvers, this.validator, this.subscription).invoke(environment);
}
}
@@ -420,11 +441,23 @@ public class AnnotatedControllerConfigurer
@Override
public Object get(DataFetchingEnvironment env) {
DataLoader<?, ?> dataLoader = env.getDataLoaderRegistry().getDataLoader(this.dataLoaderKey);
if (dataLoader == null) {
if (dataLoader == null) {
throw new IllegalStateException("No DataLoader for key '" + this.dataLoaderKey + "'");
}
return dataLoader.load(env.getSource());
}
}
/**
* Look for a Validator bean in the context and configure validation support
*/
static class HandlerMethodInputValidatorFactory {
@Nullable
static HandlerMethodInputValidator create(ApplicationContext context) {
Validator validator = context.getBeanProvider(Validator.class).getIfAvailable();
return validator != null ? new HandlerMethodInputValidator(validator) : null;
}
}
}

View File

@@ -48,17 +48,29 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport {
private final HandlerMethodArgumentResolverComposite resolvers;
@Nullable
private final HandlerMethodInputValidator validator;
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
private final boolean subscription;
public DataFetcherHandlerMethod(
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers, boolean subscription) {
/**
* Constructor with a parent handler method.
* @param handlerMethod the handler method
* @param resolvers the argument resolvers
* @param validator the input validator
* @param subscription whether the field being fetched is of subscription type
*/
public DataFetcherHandlerMethod(HandlerMethod handlerMethod,
HandlerMethodArgumentResolverComposite resolvers, @Nullable HandlerMethodInputValidator validator,
boolean subscription) {
super(handlerMethod);
Assert.isTrue(!resolvers.getResolvers().isEmpty(), "No argument resolvers");
this.resolvers = resolvers;
this.validator = validator;
this.subscription = subscription;
}
@@ -70,6 +82,13 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport {
return this.resolvers;
}
/**
* Return the configured input validator.
*/
@Nullable
public HandlerMethodInputValidator getValidator() {
return this.validator;
}
/**
* Invoke the method after resolving its argument values in the context of
@@ -93,6 +112,9 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport {
Object[] args;
try {
args = getMethodArgumentValues(environment);
if (this.validator != null) {
this.validator.validate(this, args);
}
}
catch (Throwable ex) {
return Mono.error(ex);

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2020-2021 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.graphql.data.method.annotation.support;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.Validation;
import javax.validation.Validator;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.SpringValidatorAdapter;
/**
* Strategy for validating a {@link HandlerMethod} input before invocation, based on JSR-303.
* This is called after all {@link HandlerMethodArgumentResolver} have been involved.
*
* @author Brian Clozel
*/
class HandlerMethodInputValidator {
private final Validator validator;
/**
* Create the input validator backed by a JSR-303 Validator instance.
*/
public HandlerMethodInputValidator(Validator validator) {
Assert.notNull(validator, "validator should not be null");
if (validator instanceof LocalValidatorFactoryBean) {
this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
}
else if (validator instanceof SpringValidatorAdapter) {
this.validator = validator.unwrap(Validator.class);
}
else {
this.validator = validator;
}
}
/**
* Create the input validator backed by a default
* {@link Validation#buildDefaultValidatorFactory() factory instance}.
*/
public HandlerMethodInputValidator() {
this(Validation.buildDefaultValidatorFactory().getValidator());
}
/**
* Validate the {@link HandlerMethod} input before invocation, throwing
* an {@link ConstraintViolationException} if validation fails.
*
* @param handlerMethod the handler method for the current query
* @param arguments the resolved arguments for the method invocation
*/
public void validate(HandlerMethod handlerMethod, Object[] arguments) {
Class<?>[] validationGroups = determineValidationGroups(handlerMethod);
Set<ConstraintViolation<Object>> result = this.validator.forExecutables()
.validateParameters(handlerMethod.getBean(), handlerMethod.getMethod(), arguments, validationGroups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
}
/**
* Determine the validation groups to validate against for the given handler method.
* <p>Default are the validation groups as specified in the {@link Validated} annotation
* on the containing target class of the method.
* @param method the current HandlerMethod
* @return the applicable validation groups as a Class array
*/
private Class<?>[] determineValidationGroups(HandlerMethod method) {
Validated validatedAnn = AnnotationUtils.findAnnotation(method.getMethod(), Validated.class);
if (validatedAnn == null) {
validatedAnn = AnnotationUtils.findAnnotation(method.getBeanType(), Validated.class);
}
return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2020-2021 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.graphql.data.method.annotation.support;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Method;
import java.util.Arrays;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotNull;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.IterableAssert;
import org.assertj.core.api.ThrowableAssert;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanUtils;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.validation.annotation.Validated;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Tests for {@link HandlerMethodInputValidator}
* @author Brian Clozel
*/
class HandlerMethodInputValidatorTests {
private final HandlerMethodInputValidator validator = new HandlerMethodInputValidator();
@Test
void shouldFailWithNullValidator() {
assertThatThrownBy(() -> new HandlerMethodInputValidator(null)).isInstanceOf(IllegalArgumentException.class);
}
@Test
void shouldIgnoreMethodsWithoutAnnotations() throws Exception {
HandlerMethod method = findHandlerMethod(MyValidBean.class, "notValidatedMethod");
assertThatNoException().isThrownBy(() -> validator.validate(method, new Object[] {"test", 12}));
}
@Test
void shouldRaiseValidationErrorForAnnotatedParams() throws Exception {
HandlerMethod method = findHandlerMethod(MyValidBean.class, "myValidMethod");
assertViolations(() -> validator.validate(method, new Object[] {null, 2}))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("myValidMethod.arg0"));
assertViolations(() -> validator.validate(method, new Object[] {"test", 12}))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("myValidMethod.arg1"));
}
@Test
void shouldRaiseValidationErrorForAnnotatedParamsWithGroups() throws Exception {
HandlerMethod myValidMethodWithGroup = findHandlerMethod(MyValidBeanWithGroup.class, "myValidMethodWithGroup");
assertViolations(() -> validator.validate(myValidMethodWithGroup, new Object[] {null}))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("myValidMethodWithGroup.arg0"));
HandlerMethod myValidMethodWithGroupOnType = findHandlerMethod(MyValidBeanWithGroup.class, "myValidMethodWithGroupOnType");
assertViolations(() -> validator.validate(myValidMethodWithGroupOnType, new Object[] {null}))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("myValidMethodWithGroupOnType.arg0"));
}
private HandlerMethod findHandlerMethod(Class<?> handlerType, String methodName) {
Object handler = BeanUtils.instantiateClass(handlerType);
Method method = Arrays.stream(handlerType.getMethods())
.filter(m -> m.getName().equals(methodName))
.findAny()
.orElseThrow(() -> new IllegalArgumentException("Invalid method name"));
return new HandlerMethod(handler, method);
}
private IterableAssert<ConstraintViolation> assertViolations(ThrowableAssert.ThrowingCallable callable) {
return assertThatThrownBy(callable)
.isInstanceOf(ConstraintViolationException.class)
.extracting("constraintViolations")
.asInstanceOf(InstanceOfAssertFactories.iterable(ConstraintViolation.class));
}
public static class MyValidBean {
public String notValidatedMethod(String arg0, int arg1) {
return "";
}
public Object myValidMethod(@NotNull String arg0, @Max(10) int arg1) {
return null;
}
}
public interface FirstGroup {
}
public interface SecondGroup {
}
@Validated(FirstGroup.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface GroupOnParam {
}
@Validated(SecondGroup.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface GroupOnType {
}
@GroupOnType
public static class MyValidBeanWithGroup {
@GroupOnParam
public Object myValidMethodWithGroup(@NotNull(groups = {FirstGroup.class}) String arg0) {
return null;
}
public Object myValidMethodWithGroupOnType(@NotNull(groups = {SecondGroup.class}) String arg0) {
return null;
}
}
}