diff --git a/settings.gradle b/settings.gradle index 5dd2b21e..eaa29100 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ rootProject.name = 'validation-parent' include 'validation' include 'validation-bom' +include 'validation-visitor' micronautBuild { importMicronautCatalog() diff --git a/validation-visitor/build.gradle b/validation-visitor/build.gradle new file mode 100644 index 00000000..c070063e --- /dev/null +++ b/validation-visitor/build.gradle @@ -0,0 +1,28 @@ +plugins { + id "io.micronaut.build.internal.validation-module" +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + implementation mn.micronaut.inject + + testImplementation mn.micronaut.core.reactive + testImplementation libs.managed.validation + testCompileOnly mn.micronaut.http + + testImplementation libs.managed.spotbugs + testCompileOnly mn.micronaut.inject.groovy + testImplementation mn.micronaut.inject + testImplementation libs.managed.validation + + testImplementation mn.micronaut.http.client + testImplementation mn.micronaut.inject.java.test + + if (!JavaVersion.current().isJava9Compatible()) { + testImplementation files(org.gradle.internal.jvm.Jvm.current().toolsJar) + } +} diff --git a/validation-visitor/src/main/java/io/micronaut/validation/visitor/ValidationVisitor.java b/validation-visitor/src/main/java/io/micronaut/validation/visitor/ValidationVisitor.java new file mode 100644 index 00000000..de2e44af --- /dev/null +++ b/validation-visitor/src/main/java/io/micronaut/validation/visitor/ValidationVisitor.java @@ -0,0 +1,133 @@ +/* + * Copyright 2017-2020 original 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 io.micronaut.validation.visitor; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.ConstructorElement; +import io.micronaut.inject.ast.FieldElement; +import io.micronaut.inject.ast.MethodElement; +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.processing.ProcessingException; +import io.micronaut.inject.validation.RequiresValidation; +import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.visitor.VisitorContext; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * The visitor creates annotations utilized by the Validator. + * + * It adds @RequiresValidation annotation to fields if they require validation, and to methods + * if one of the parameters or return value require validation. + * + * @author Denis Stepanov + * @since 4.0.0 + */ +@Internal +public class ValidationVisitor implements TypeElementVisitor { + + private static final String ANN_CONSTRAINT = "javax.validation.Constraint"; + private static final String ANN_VALID = "javax.validation.Valid"; + + private ClassElement classElement; + + @Override + public Set getSupportedAnnotationNames() { + return new HashSet<>(Arrays.asList(ANN_CONSTRAINT, ANN_VALID)); + } + + @Override + public int getOrder() { + return 10; // Should run before ConfigurationReaderVisitor + } + + @NonNull + @Override + public VisitorKind getVisitorKind() { + return VisitorKind.ISOLATING; + } + + @Override + public void visitClass(ClassElement element, VisitorContext context) { + classElement = element; + } + + @Override + public void visitConstructor(ConstructorElement element, VisitorContext context) { + if (classElement == null) { + return; + } + if ( + requiresValidation(element.getReturnType(), true) || + parametersRequireValidation(element, true) + ) { + element.annotate(RequiresValidation.class); + classElement.annotate(RequiresValidation.class); + } + } + + @Override + public void visitMethod(MethodElement element, VisitorContext context) { + if (classElement == null) { + return; + } + boolean isPrivate = element.isPrivate(); + boolean isAbstract = element.getOwningType().isInterface() || element.getOwningType().isAbstract(); + boolean requireOnConstraint = isAbstract || !isPrivate; + + if ( + requiresValidation(element.getReturnType(), requireOnConstraint) || + parametersRequireValidation(element, requireOnConstraint) + ) { + if (isPrivate) { + throw new ProcessingException(element, "Method annotated for validation but is declared private. Change the method to be non-private in order for AOP advice to be applied."); + } + element.annotate(RequiresValidation.class); + classElement.annotate(RequiresValidation.class); + } + } + + @Override + public void visitField(FieldElement element, VisitorContext context) { + if (classElement == null) { + return; + } + if (requiresValidation(element, true)) { + element.annotate(RequiresValidation.class); + classElement.annotate(RequiresValidation.class); + } + } + + private boolean parametersRequireValidation(MethodElement element, boolean requireOnConstraint) { + return Arrays.stream(element.getParameters()) + .anyMatch(param -> requiresValidation(param, requireOnConstraint)); + } + + private boolean requiresValidation(TypedElement e, boolean requireOnConstraint) { + return (requireOnConstraint && e.hasStereotype(ANN_CONSTRAINT)) || + e.hasStereotype(ANN_VALID) || + typeArgumentsRequireValidation(e, requireOnConstraint); + } + + private boolean typeArgumentsRequireValidation(TypedElement e, boolean requireOnConstraint) { + return e.getGenericType().getTypeArguments().values().stream() + .anyMatch(classElement -> requiresValidation(classElement, requireOnConstraint)); + } +} diff --git a/validation-visitor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor b/validation-visitor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor new file mode 100644 index 00000000..cc114917 --- /dev/null +++ b/validation-visitor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor @@ -0,0 +1 @@ +io.micronaut.validation.visitor.ValidationVisitor diff --git a/validation-visitor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpec.groovy b/validation-visitor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpec.groovy new file mode 100644 index 00000000..39ca2c4e --- /dev/null +++ b/validation-visitor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpec.groovy @@ -0,0 +1,176 @@ +package io.micronaut.validation + +import io.micronaut.annotation.processing.test.AbstractTypeElementSpec +import io.micronaut.inject.writer.BeanDefinitionVisitor +import java.time.LocalDate + +class ValidatedParseSpec extends AbstractTypeElementSpec { + final static String VALIDATED_ANN = "io.micronaut.validation.Validated"; + + void "test constraints on beans make them @Validated"() { + given: + def definition = buildBeanDefinition('test.Test',''' +package test; + +@jakarta.inject.Singleton +class Test { + + @io.micronaut.context.annotation.Executable + public void setName(@javax.validation.constraints.NotBlank String name) { + + } + + @io.micronaut.context.annotation.Executable + public void setName2(@javax.validation.Valid String name) { + + } +} +''') + + expect: + definition != null + definition.findMethod("setName", String).get().hasStereotype(VALIDATED_ANN) + } + + void "test constraints on a declarative client makes it @Validated"() { + given: + def definition = buildBeanDefinition('test.ExchangeRates' + BeanDefinitionVisitor.PROXY_SUFFIX,''' +package test; + +import io.micronaut.http.annotation.Get; +import io.micronaut.http.client.annotation.Client; + +import javax.validation.constraints.PastOrPresent; +import java.time.LocalDate; + +@Client("https://exchangeratesapi.io") +interface ExchangeRates { + + @Get("{date}") + String rate(@PastOrPresent LocalDate date); +} +''') + + expect: + definition.findMethod("rate", LocalDate).get().hasStereotype(VALIDATED_ANN) + } + + void "test constraints on generic parameters make method @Validated"() { + given: + def definition = buildBeanDefinition('test.Test',''' +package test; + +import java.util.List; +import javax.validation.constraints.NotBlank; + +@jakarta.inject.Singleton +class Test { + @io.micronaut.context.annotation.Executable + public void setList(List<@NotBlank String> list) { + + } +} +''') + when: + def method = definition.findMethod("setList", List); + + then: + method.isPresent() + method.get().hasStereotype(VALIDATED_ANN) + method.get().getArguments().size() == 1 + } + + void "test constraints on a controller operation make method @Validated"() { + given: + def definition = buildBeanDefinition('test.Test', ''' +package test; + +import java.util.List; +import javax.validation.Valid; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; + +@Controller() +class Test { + @Post("/pojos") + public List pojos(@Body List<@Valid Pojo> pojos) { + return pojos; + } + + @Introspected + public record Pojo() {} +} +''') + var method = definition.findPossibleMethods("pojos").findFirst() + + expect: + method.isPresent() + method.get().hasStereotype(VALIDATED_ANN) + } + + void "test constraints on return value generic parameters make method @Validated"() { + given: + def definition = buildBeanDefinition('test.Test',''' +package test; + +import java.util.List; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import io.micronaut.context.annotation.Executable; + +@jakarta.inject.Singleton +class Test { + + @Executable + public @Min(value=10) Integer getValue() { + return 1; + } + + @Executable + public List<@NotNull String> getStrings() { + return null; + } +} +''') + var method = definition.findMethod("getValue") + + expect: + method.isPresent() + method.get().hasStereotype(VALIDATED_ANN) + + when: + var method2 = definition.findMethod("getStrings") + + then: + method2.isPresent() + method2.get().hasStereotype(VALIDATED_ANN) + } + + void "test constraints on reactive return value make method @Validated"() { + given: + def definition = buildBeanDefinition('test.Test',''' +package test; + +import javax.validation.constraints.NotBlank; +import io.micronaut.context.annotation.Executable; +import reactor.core.publisher.Mono; + +@jakarta.inject.Singleton +class Test { + + @Executable + public Mono<@NotBlank String> getMono() { + return Mono.fromCallable(() -> ""); + } +} +''') + var method = definition.findMethod("getMono") + + expect: + method.isPresent() + method.get().hasStereotype(VALIDATED_ANN) + } +} diff --git a/validation-visitor/src/test/resources/logback.xml b/validation-visitor/src/test/resources/logback.xml new file mode 100644 index 00000000..afaebf8e --- /dev/null +++ b/validation-visitor/src/test/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/validation/build.gradle b/validation/build.gradle index a6262bd4..b1a643a3 100644 --- a/validation/build.gradle +++ b/validation/build.gradle @@ -12,21 +12,18 @@ dependencies { api mn.micronaut.inject api mn.micronaut.core.reactive - api mn.micronaut.context api libs.managed.validation compileOnly(libs.managed.gorm) { exclude(module: 'groovy') } - compileOnly libs.managed.gorm - compileOnly mn.micronaut.http compileOnly mn.micronaut.http.server - compileOnly mn.micronaut.aop implementation libs.managed.reactor - testImplementation libs.managed.spotbugs + testAnnotationProcessor project(":validation-visitor") testAnnotationProcessor mn.micronaut.inject.java + testImplementation libs.managed.spotbugs testCompileOnly mn.micronaut.inject.groovy testImplementation mn.micronaut.inject