Skip to content

Commit

Permalink
Add validation visitor
Browse files Browse the repository at this point in the history
  • Loading branch information
andriy-dmytruk committed Nov 1, 2022
1 parent 2e42a5d commit 5707f21
Show file tree
Hide file tree
Showing 7 changed files with 355 additions and 5 deletions.
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ rootProject.name = 'validation-parent'

include 'validation'
include 'validation-bom'
include 'validation-visitor'

micronautBuild {
importMicronautCatalog()
Expand Down
28 changes: 28 additions & 0 deletions validation-visitor/build.gradle
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
io.micronaut.validation.visitor.ValidationVisitor
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 14 additions & 0 deletions validation-visitor/src/test/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -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>
7 changes: 2 additions & 5 deletions validation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit 5707f21

Please sign in to comment.