From 5707f21bf85c8f4ffe25203aecbd083687b29833 Mon Sep 17 00:00:00 2001
From: Andriy Dmytruk <andriy.dmytruk@oracle.com>
Date: Tue, 1 Nov 2022 19:14:39 -0400
Subject: [PATCH] Add validation visitor

---
 settings.gradle                               |   1 +
 validation-visitor/build.gradle               |  28 +++
 .../validation/visitor/ValidationVisitor.java | 133 +++++++++++++
 ...icronaut.inject.visitor.TypeElementVisitor |   1 +
 .../visitor/ValidatedParseSpec.groovy         | 176 ++++++++++++++++++
 .../src/test/resources/logback.xml            |  14 ++
 validation/build.gradle                       |   7 +-
 7 files changed, 355 insertions(+), 5 deletions(-)
 create mode 100644 validation-visitor/build.gradle
 create mode 100644 validation-visitor/src/main/java/io/micronaut/validation/visitor/ValidationVisitor.java
 create mode 100644 validation-visitor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor
 create mode 100644 validation-visitor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpec.groovy
 create mode 100644 validation-visitor/src/test/resources/logback.xml

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<Object, Object> {
+
+    private static final String ANN_CONSTRAINT = "javax.validation.Constraint";
+    private static final String ANN_VALID = "javax.validation.Valid";
+
+    private ClassElement classElement;
+
+    @Override
+    public Set<String> 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<String>);
+
+        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<Pojo> 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 @@
+<configuration>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <!-- encoders are assigned the type
+             ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <root level="info">
+        <appender-ref ref="STDOUT" />
+    </root>
+</configuration>
\ 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