diff --git a/validation-processor/build.gradle b/validation-processor/build.gradle index ed8e1748..4a24bd37 100644 --- a/validation-processor/build.gradle +++ b/validation-processor/build.gradle @@ -16,6 +16,7 @@ dependencies { testImplementation mn.micronaut.http.client testImplementation mn.micronaut.inject.java.test + testImplementation mn.micronaut.inject.groovy.test if (!JavaVersion.current().isJava9Compatible()) { testImplementation files(org.gradle.internal.jvm.Jvm.current().toolsJar) diff --git a/validation-processor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpecGroovy.groovy b/validation-processor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpecGroovy.groovy new file mode 100644 index 00000000..2cf52e30 --- /dev/null +++ b/validation-processor/src/test/groovy/io/micronaut/validation/visitor/ValidatedParseSpecGroovy.groovy @@ -0,0 +1,88 @@ +package io.micronaut.validation.visitor + +import io.micronaut.aop.Around +import io.micronaut.ast.transform.test.AbstractBeanDefinitionSpec +import io.micronaut.core.beans.BeanIntrospection +import io.micronaut.inject.ProxyBeanDefinition +import io.micronaut.inject.writer.BeanDefinitionVisitor +import io.micronaut.inject.writer.BeanDefinitionWriter +import io.micronaut.validation.ValidatedParseSpec + +import java.time.LocalDate + +class ValidatedParseSpecGroovy extends AbstractBeanDefinitionSpec { + void "test constraints on beans make them @Validated"() { + given: + def definition = buildBeanDefinition('validateparse1.Test',''' +package validateparse1 +import io.micronaut.context.annotation.Executable +import javax.validation.Valid +import javax.validation.constraints.NotBlank + +@jakarta.inject.Singleton +class Test { + @Executable + void setName(@NotBlank String name) {} + + @Executable + void setName2(@Valid String name) {} +} +''') + + expect: + definition.findMethod("setName", String).get().hasStereotype(ValidatedParseSpec.VALIDATED_ANN) + definition.findMethod("setName2", String).get().hasStereotype(ValidatedParseSpec.VALIDATED_ANN) + } + + void "test annotation default values on a groovy property"() { + given: + BeanIntrospection beanIntrospection = buildBeanIntrospection('validateparse2.Test',''' +package validateparse2; +import io.micronaut.core.annotation.Introspected +import javax.validation.Constraint +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +@Introspected +class Test { + + @ValidURLs + List webs +} + +@Constraint(validatedBy = []) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@interface ValidURLs { + String message() default "invalid url" +} + +''') + + expect: + beanIntrospection.getProperty("webs").isPresent() + beanIntrospection.getRequiredProperty("webs", List).annotationMetadata.getDefaultValue("validateparse2.ValidURLs", "message", String).get() == "invalid url" + } + + void "test constraints on a declarative client makes it @Validated"() { + given: + def definition = buildBeanDefinition('validateparse3.ExchangeRates' + BeanDefinitionVisitor.PROXY_SUFFIX,''' +package validateparse3 +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(ValidatedParseSpec.VALIDATED_ANN) + } +} diff --git a/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java b/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java index db8e66a3..a216c8e4 100644 --- a/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java +++ b/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java @@ -20,6 +20,7 @@ import io.micronaut.aop.MethodInterceptor; import io.micronaut.aop.MethodInvocationContext; import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.convert.ConversionService; import io.micronaut.inject.ExecutableMethod; import io.micronaut.validation.validator.ExecutableMethodValidator; import io.micronaut.validation.validator.ReactiveValidator; @@ -48,15 +49,19 @@ public class ValidatingInterceptor implements MethodInterceptor private final @Nullable ExecutableValidator executableValidator; private final @Nullable ExecutableMethodValidator micronautValidator; + private final ConversionService conversionService; /** * Creates ValidatingInterceptor from the validatorFactory. * * @param micronautValidator The micronaut validator use if no factory is available * @param validatorFactory Factory returning initialized {@code Validator} instances + * @param conversionService The conversion service */ public ValidatingInterceptor(@Nullable Validator micronautValidator, - @Nullable ValidatorFactory validatorFactory) { + @Nullable ValidatorFactory validatorFactory, + ConversionService conversionService) { + this.conversionService = conversionService; if (validatorFactory != null) { javax.validation.Validator validator = validatorFactory.getValidator(); @@ -112,7 +117,7 @@ public Object intercept(MethodInvocationContext context) { } } if (micronautValidator instanceof ReactiveValidator) { - InterceptedMethod interceptedMethod = InterceptedMethod.of(context); + InterceptedMethod interceptedMethod = InterceptedMethod.of(context, conversionService); try { switch (interceptedMethod.resultType()) { case PUBLISHER: diff --git a/validation/src/main/java/io/micronaut/validation/validator/DefaultValidator.java b/validation/src/main/java/io/micronaut/validation/validator/DefaultValidator.java index 4a38bacb..aa2bce28 100644 --- a/validation/src/main/java/io/micronaut/validation/validator/DefaultValidator.java +++ b/validation/src/main/java/io/micronaut/validation/validator/DefaultValidator.java @@ -47,6 +47,7 @@ import io.micronaut.inject.ExecutableMethod; import io.micronaut.inject.InjectionPoint; import io.micronaut.inject.MethodReference; +import io.micronaut.inject.ProxyBeanDefinition; import io.micronaut.inject.annotation.AnnotatedElementValidator; import io.micronaut.inject.validation.BeanDefinitionValidator; import io.micronaut.validation.validator.constraints.ConstraintValidator; @@ -616,17 +617,21 @@ public void validateBean( @NonNull BeanDefinition definition, @NonNull T bean ) throws BeanInstantiationException { - final BeanIntrospection introspection = (BeanIntrospection) getBeanIntrospection(bean); + Class beanType; + if (definition instanceof ProxyBeanDefinition proxyBeanDefinition) { + beanType = (Class) proxyBeanDefinition.getTargetType(); + } else { + beanType = definition.getBeanType(); + } + final BeanIntrospection introspection = (BeanIntrospection) getBeanIntrospection(bean, beanType); if (introspection != null) { Set> errors = validate(introspection, bean); - final Class beanType = bean.getClass(); failOnError(resolutionContext, errors, beanType); } else if (bean instanceof Intercepted && definition.hasStereotype(ConfigurationReader.class)) { final Collection> executableMethods = definition.getExecutableMethods(); if (CollectionUtils.isNotEmpty(executableMethods)) { Set> violations = new HashSet<>(); final DefaultConstraintValidatorContext context = new DefaultConstraintValidatorContext(bean); - final Class beanType = definition.getBeanType(); final Class[] interfaces = beanType.getInterfaces(); if (ArrayUtils.isNotEmpty(interfaces)) { context.addConstructorNode(interfaces[0].getSimpleName()); @@ -651,6 +656,8 @@ public void validateBean( failOnError(resolutionContext, violations, beanType); } + } else { + throw new BeanInstantiationException(resolutionContext, "Cannot validate bean [" + beanType.getName() + "]. No bean introspection present. Please add @Introspected."); } } diff --git a/validation/src/test/groovy/io/micronaut/validation/ValidatedSpec.groovy b/validation/src/test/groovy/io/micronaut/validation/ValidatedSpec.groovy index 62a97407..945b82b3 100644 --- a/validation/src/test/groovy/io/micronaut/validation/ValidatedSpec.groovy +++ b/validation/src/test/groovy/io/micronaut/validation/ValidatedSpec.groovy @@ -24,7 +24,7 @@ import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.ConfigurationProperties import io.micronaut.context.exceptions.BeanInstantiationException import io.micronaut.core.annotation.Nullable -import io.micronaut.core.beans.BeanIntrospection +import io.micronaut.core.convert.ConversionService import io.micronaut.core.order.OrderUtil import io.micronaut.core.type.Argument import io.micronaut.http.HttpRequest @@ -71,7 +71,7 @@ class ValidatedSpec extends Specification { Object intercept(InvocationContext context) { return null } - }, new ValidatingInterceptor(null, null)] + }, new ValidatingInterceptor(null, null, ConversionService.SHARED)] OrderUtil.sort(list) expect: diff --git a/validation/src/test/groovy/io/micronaut/validation/validator/ValidatorSpec.groovy b/validation/src/test/groovy/io/micronaut/validation/validator/ValidatorSpec.groovy index d9b77b43..4b412e89 100644 --- a/validation/src/test/groovy/io/micronaut/validation/validator/ValidatorSpec.groovy +++ b/validation/src/test/groovy/io/micronaut/validation/validator/ValidatorSpec.groovy @@ -3,7 +3,10 @@ package io.micronaut.validation.validator import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Executable import io.micronaut.context.annotation.Prototype +import io.micronaut.context.annotation.Value +import io.micronaut.context.exceptions.BeanInstantiationException import io.micronaut.core.annotation.Introspected +import io.micronaut.core.reflect.ClassUtils import io.micronaut.validation.validator.resolver.CompositeTraversableResolver import jakarta.inject.Singleton import spock.lang.AutoCleanup @@ -21,7 +24,7 @@ class ValidatorSpec extends Specification { @Shared @AutoCleanup - ApplicationContext applicationContext = ApplicationContext.run() + ApplicationContext applicationContext = ApplicationContext.run(["a.number": 40]) @Shared Validator validator = applicationContext.getBean(Validator) @@ -792,7 +795,6 @@ class ValidatorSpec extends Specification { violations[0].invalidValue == "" } - @Ignore("https://github.com/micronaut-projects/micronaut-core/issues/8301") void "test cascade to container with setter"() { given: def salad = new ValidatorSpecClasses.SaladWithSetter() @@ -806,6 +808,81 @@ class ValidatorSpec extends Specification { violations.size() == 1 violations[0].invalidValue == "" } + + void "test @Introspected is required to validate the bean"() { + when: + applicationContext.getBean(A) + then: + BeanInstantiationException e = thrown() + e.message.contains('''Cannot validate bean [io.micronaut.validation.validator.A]. No bean introspection present. Please add @Introspected.''') + and: + ClassUtils.forName('io.micronaut.validation.validator.$A$Definition', getClass().getClassLoader()).isPresent() + ClassUtils.forName('io.micronaut.validation.validator.$A$Definition$Intercepted', getClass().getClassLoader()).isEmpty() + } + + void "test @Introspected is required to validate the bean and it's intercepted if one of the methods requires validation"() { + when: + def beanB = applicationContext.getBean(B) + then: + BeanInstantiationException e = thrown() + e.message.contains('''number - must be less than or equal to 20''') + and: + ClassUtils.forName('io.micronaut.validation.validator.$B$Definition', getClass().getClassLoader()).isPresent() + ClassUtils.forName('io.micronaut.validation.validator.$B$Definition$Intercepted', getClass().getClassLoader()).isPresent() + } + + void "test @Introspected is required to validate the bean and it's intercepted if one of the methods requires validation 2"() { + when: + def beanC = applicationContext.getBean(C) + then: + beanC.number == 40 + when: + beanC.updateNumber(100) + then: + Exception e = thrown() + e.message.contains('''updateNumber.number: must be less than or equal to 50''') + beanC.number == 40 + + and: + ClassUtils.forName('io.micronaut.validation.validator.$C$Definition', getClass().getClassLoader()).isPresent() + ClassUtils.forName('io.micronaut.validation.validator.$C$Definition$Intercepted', getClass().getClassLoader()).isPresent() + } +} + +@Singleton +class A { + @Max(20l) + @NotNull + @Value('${a.number}') + Integer number +} + +@Introspected +@Singleton +class B { + @Max(20l) + @NotNull + @Value('${a.number}') + Integer number + void updateNumber(@Max(20l) + @NotNull + Integer number) { + this.number = number + } +} + +@Introspected +@Singleton +class C { + @Max(50l) + @NotNull + @Value('${a.number}') + Integer number + void updateNumber(@Max(50l) + @NotNull + Integer number) { + this.number = number + } } @Introspected