diff --git a/settings.gradle.kts b/settings.gradle.kts index 55974c2..cd46f2f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,3 +16,4 @@ include("tweed5-utils") include("tweed5-weaver-pojo") include("tweed5-weaver-pojo-attributes-extension") include("tweed5-weaver-pojo-serde-extension") +include("tweed5-weaver-pojo-validation-extension") diff --git a/tweed5-weaver-pojo-validation-extension/build.gradle.kts b/tweed5-weaver-pojo-validation-extension/build.gradle.kts new file mode 100644 index 0000000..c13cb2c --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("de.siphalor.tweed5.base-module") +} + +dependencies { + api(project(":tweed5-construct")) + api(project(":tweed5-default-extensions")) + api(project(":tweed5-weaver-pojo")) +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validator.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validator.java new file mode 100644 index 0000000..30c7d35 --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validator.java @@ -0,0 +1,13 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api; + +import de.siphalor.tweed5.weaver.pojoext.validation.api.validators.WeavableConfigEntryValidator; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.TYPE_USE, ElementType.ANNOTATION_TYPE}) +@Repeatable(Validators.class) +public @interface Validator { + Class value(); + String config() default ""; +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validators.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validators.java new file mode 100644 index 0000000..cb98f4d --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/Validators.java @@ -0,0 +1,12 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.TYPE_USE, ElementType.ANNOTATION_TYPE}) +public @interface Validators { + Validator[] value(); +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesor.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesor.java new file mode 100644 index 0000000..c2d8aac --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesor.java @@ -0,0 +1,50 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api; + +import de.siphalor.tweed5.core.api.container.ConfigContainer; +import de.siphalor.tweed5.core.api.entry.ConfigEntry; +import de.siphalor.tweed5.defaultextensions.validation.api.ConfigEntryValidator; +import de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension; +import de.siphalor.tweed5.typeutils.api.type.ActualType; +import de.siphalor.tweed5.weaver.pojo.api.weaving.TweedPojoWeavingExtension; +import de.siphalor.tweed5.weaver.pojo.api.weaving.WeavingContext; +import de.siphalor.tweed5.weaver.pojoext.validation.api.validators.WeavableConfigEntryValidator; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Arrays; + +public class ValidatorsPojoWeavingProcesor implements TweedPojoWeavingExtension { + private final ValidationExtension validationExtension; + + @ApiStatus.Internal + public ValidatorsPojoWeavingProcesor(ConfigContainer configContainer) { + validationExtension = configContainer.extension(ValidationExtension.class) + .orElseThrow(() -> new IllegalStateException( + "You must register a " + ValidationExtension.class.getSimpleName() + + " to use " + getClass().getSimpleName() + )); + } + + @Override + public void setup(SetupContext context) { + + } + + @Override + public void afterWeaveEntry(ActualType valueType, ConfigEntry configEntry, WeavingContext context) { + Validator[] validatorAnnotations = context.annotations().getAnnotationsByType(Validator.class); + ConfigEntryValidator[] validators = Arrays.stream(validatorAnnotations) + .map(validatorAnnotation -> createValidatorFromAnnotation(configEntry, validatorAnnotation)) + .toArray(ConfigEntryValidator[]::new); + validationExtension.addValidators(configEntry, validators); + } + + private ConfigEntryValidator createValidatorFromAnnotation( + ConfigEntry configEntry, + Validator validatorAnnotation + ) { + return WeavableConfigEntryValidator.FACTORY.construct(validatorAnnotation.value()) + .typedArg(ConfigEntry.class, configEntry) + .namedArg("config", validatorAnnotation.config()) + .finish(); + } +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/package-info.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/package-info.java new file mode 100644 index 0000000..bb05dfa --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package de.siphalor.tweed5.weaver.pojoext.validation.api; + +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableConfigEntryValidator.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableConfigEntryValidator.java new file mode 100644 index 0000000..e1fcca4 --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableConfigEntryValidator.java @@ -0,0 +1,13 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api.validators; + +import de.siphalor.tweed5.construct.api.TweedConstructFactory; +import de.siphalor.tweed5.core.api.entry.ConfigEntry; +import de.siphalor.tweed5.defaultextensions.validation.api.ConfigEntryValidator; + +public interface WeavableConfigEntryValidator extends ConfigEntryValidator { + TweedConstructFactory FACTORY = + TweedConstructFactory.builder(WeavableConfigEntryValidator.class) + .typedArg(ConfigEntry.class) + .namedArg("config", String.class) + .build(); +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidator.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidator.java new file mode 100644 index 0000000..9bcd34e --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidator.java @@ -0,0 +1,115 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api.validators; + +import de.siphalor.tweed5.construct.api.ConstructParameter; +import de.siphalor.tweed5.construct.api.TweedConstruct; +import de.siphalor.tweed5.core.api.entry.ConfigEntry; +import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationResult; +import de.siphalor.tweed5.defaultextensions.validation.api.validators.NumberRangeValidator; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.var; +import org.jetbrains.annotations.ApiStatus; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Value +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class WeavableNumberRangeValidator implements WeavableConfigEntryValidator { + private static final String NUMBER_PATTERN = "[+-]?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"; + private static final Pattern CONFIG_PATTERN = + Pattern.compile("^(" + NUMBER_PATTERN + "=?)?\\.\\.(=?" + NUMBER_PATTERN + ")?$"); + + NumberRangeValidator validator; + + @ApiStatus.Internal + @TweedConstruct(WeavableConfigEntryValidator.class) + public static WeavableNumberRangeValidator construct( + ConfigEntry configEntry, + @ConstructParameter(name = "config") String config + ) { + if (config.isEmpty()) { + throw new IllegalArgumentException("Config is required for number range validator"); + } + + Matcher matcher = CONFIG_PATTERN.matcher(config); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "Invalid config: " + config + "; expected format: [[=]]..[[=]]" + ); + } + + //noinspection unchecked + Class numberClass = boxClass((Class) configEntry.valueClass()); + //noinspection unchecked + var builder = NumberRangeValidator.builder((Class) numberClass); + + String minGroup = matcher.group(1); + if (minGroup != null) { + if (minGroup.endsWith("=")) { + minGroup = minGroup.substring(0, minGroup.length() - 1); + builder.greaterThanOrEqualTo(parseNumber(minGroup, numberClass)); + } else { + builder.greaterThan(parseNumber(minGroup, numberClass)); + } + } + + String maxGroup = matcher.group(2); + if (maxGroup != null) { + if (maxGroup.startsWith("=")) { + maxGroup = maxGroup.substring(1); + builder.lessThanOrEqualTo(parseNumber(maxGroup, numberClass)); + } else { + builder.lessThan(parseNumber(maxGroup, numberClass)); + } + } + + return new WeavableNumberRangeValidator(builder.build()); + } + + private static Class boxClass(Class numberClass) { + if (numberClass == byte.class) { + return Byte.class; + } else if (numberClass == short.class) { + return Short.class; + } else if (numberClass == int.class) { + return Integer.class; + } else if (numberClass == long.class) { + return Long.class; + } else if (numberClass == float.class) { + return Float.class; + } else if (numberClass == double.class) { + return Double.class; + } + return numberClass; + } + + private static Number parseNumber(String number, Class numberClass) { + if (numberClass == Byte.class) { + return Byte.valueOf(number); + } else if (numberClass == Short.class) { + return Short.valueOf(number); + } else if (numberClass == Integer.class) { + return Integer.valueOf(number); + } else if (numberClass == Long.class) { + return Long.valueOf(number); + } else if (numberClass == Float.class) { + return Float.valueOf(number); + } else if (numberClass == Double.class) { + return Double.valueOf(number); + } else { + throw new IllegalArgumentException("Unsupported number class: " + numberClass.getName()); + } + } + + @Override + public ValidationResult validate(ConfigEntry configEntry, T value) { + return validator.validate(configEntry, value); + } + + @Override + public String description(ConfigEntry configEntry) { + return validator.description(configEntry); + } +} diff --git a/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/package-info.java b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/package-info.java new file mode 100644 index 0000000..fe361fa --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/main/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package de.siphalor.tweed5.weaver.pojoext.validation.api.validators; + +import org.jspecify.annotations.NullMarked; diff --git a/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesorTest.java b/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesorTest.java new file mode 100644 index 0000000..7dfe04a --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/ValidatorsPojoWeavingProcesorTest.java @@ -0,0 +1,53 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api; + +import de.siphalor.tweed5.core.api.container.ConfigContainer; +import de.siphalor.tweed5.defaultextensions.validation.api.ValidationExtension; +import de.siphalor.tweed5.defaultextensions.validation.api.result.ValidationIssues; +import de.siphalor.tweed5.weaver.pojo.api.annotation.CompoundWeaving; +import de.siphalor.tweed5.weaver.pojo.api.annotation.DefaultWeavingExtensions; +import de.siphalor.tweed5.weaver.pojo.api.annotation.PojoWeaving; +import de.siphalor.tweed5.weaver.pojo.api.annotation.PojoWeavingExtension; +import de.siphalor.tweed5.weaver.pojo.impl.weaving.TweedPojoWeaverBootstrapper; +import de.siphalor.tweed5.weaver.pojoext.validation.api.validators.WeavableNumberRangeValidator; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class ValidatorsPojoWeavingProcesorTest { + + @ParameterizedTest + @CsvSource({"-1,true", "0,false", "50,false", "99,false", "100,true", "101,true"}) + void test(int value, boolean issuesExpected) { + var bootstrapper = TweedPojoWeaverBootstrapper.create(Config.class); + ConfigContainer configContainer = bootstrapper.weave(); + configContainer.initialize(); + + var validationExtension = configContainer.extension(ValidationExtension.class).orElseThrow(); + + ValidationIssues issues = validationExtension.validate(configContainer.rootEntry(), new Config(value)); + if (issuesExpected) { + assertThat(issues.issuesByPath()).as("Issues should be present").isNotEmpty(); + } else { + assertThat(issues.issuesByPath()).as("No issues should be present").isEmpty(); + } + } + + @PojoWeaving(extensions = {ValidationExtension.class}) + @DefaultWeavingExtensions + @PojoWeavingExtension(ValidatorsPojoWeavingProcesor.class) + @CompoundWeaving + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Config { + @Validator( + value = WeavableNumberRangeValidator.class, + config = "0=..100" + ) + private int value; + } +} diff --git a/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidatorTest.java b/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidatorTest.java new file mode 100644 index 0000000..72d9799 --- /dev/null +++ b/tweed5-weaver-pojo-validation-extension/src/test/java/de/siphalor/tweed5/weaver/pojoext/validation/api/validators/WeavableNumberRangeValidatorTest.java @@ -0,0 +1,100 @@ +package de.siphalor.tweed5.weaver.pojoext.validation.api.validators; + +import de.siphalor.tweed5.core.api.entry.ConfigEntry; +import de.siphalor.tweed5.core.impl.DefaultConfigContainer; +import de.siphalor.tweed5.core.impl.entry.SimpleConfigEntryImpl; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; + +class WeavableNumberRangeValidatorTest { + + @ParameterizedTest + @MethodSource("constructParams") + void construct(Class numberClass, String config, List works, List fails) { + DefaultConfigContainer configContainer = new DefaultConfigContainer<>(); + configContainer.finishExtensionSetup(); + + //noinspection unchecked + var configEntry = new SimpleConfigEntryImpl<>(configContainer, (Class) numberClass); + + var validator = WeavableConfigEntryValidator.FACTORY.construct(WeavableNumberRangeValidator.class) + .typedArg(ConfigEntry.class, configEntry) + .namedArg("config", config) + .finish(); + + assertAll(Stream.concat( + works.stream().map( + number -> () -> assertThat(validator.validate(configEntry, number).issues()) + .as("Number %s should not have validation issues", number) + .isEmpty() + ), + fails.stream().map( + number -> () -> assertThat(validator.validate(configEntry, number).issues()) + .as("Number %s should have validation issues", number) + .isNotEmpty() + ) + )); + } + + static Stream constructParams() { + return Stream.of( + argumentSet( + "integer, no range", + Integer.class, + "..", + List.of(Integer.MIN_VALUE, -1234, 0, 1234, Integer.MAX_VALUE), + List.of() + ), + argumentSet( + "integer, greater than", + Integer.class, + "12..", + List.of(13, 25, Integer.MAX_VALUE), + List.of(Integer.MIN_VALUE, -1234, 0, 12) + ), + argumentSet( + "integer, greater than or equal", + Integer.class, + "12=..", + List.of(12, 13, 24, Integer.MAX_VALUE), + List.of(Integer.MIN_VALUE, -1234, 0, 11) + ), + argumentSet( + "integer, less than", + Integer.class, + "..-12", + List.of(Integer.MIN_VALUE, -1234, -13), + List.of(-12, 0, 12, 1234, Integer.MAX_VALUE) + ), + argumentSet( + "integer, less than or equal", + Integer.class, + "..=-12", + List.of(Integer.MIN_VALUE, -1234, -13, -12), + List.of(-11, 0, 12, 1234, Integer.MAX_VALUE) + ), + argumentSet( + "integer, range", + Integer.class, + "0=..100", + List.of(0, 50, 99), + List.of(Integer.MIN_VALUE, -1, 100, Integer.MAX_VALUE) + ), + argumentSet( + "double, range", + Double.class, + "1.5=..2.5", + List.of(1.5, 2.0, 2.1, 2.49999), + List.of(Integer.MIN_VALUE, 0, 1.49999, 2.5, 3.0, Integer.MAX_VALUE) + ) + ); + } +}