diff --git a/build.gradle b/build.gradle index 44d0f1b9..fdf567ab 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/graphql-spring-boot-starter/build.gradle b/graphql-spring-boot-starter/build.gradle index 95443ffe..a8eae817 100644 --- a/graphql-spring-boot-starter/build.gradle +++ b/graphql-spring-boot-starter/build.gradle @@ -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' diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlServiceAutoConfigurationTests.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlServiceAutoConfigurationTests.java index 55210606..46c1f98e 100644 --- a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlServiceAutoConfigurationTests.java +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/GraphQlServiceAutoConfigurationTests.java @@ -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(); + } + } + } \ No newline at end of file diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index fd12569b..f5da2a73 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -821,6 +821,52 @@ You can use `@Argument` on a `Map` 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 <>. + +[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 diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 7fc42d69..6686c461 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -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' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index 610e971b..1fc23f17 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -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; + } + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java index e3457934..3195c979 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java @@ -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); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidator.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidator.java new file mode 100644 index 00000000..226df823 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidator.java @@ -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> 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. + *

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]); + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidatorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidatorTests.java new file mode 100644 index 00000000..b7c6955e --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/HandlerMethodInputValidatorTests.java @@ -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 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; + } + + } + +} \ No newline at end of file