From fb2732e055c339fbad8e99f3209fe970366759f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mathieu?= Date: Thu, 20 Jun 2024 16:36:50 +0200 Subject: [PATCH] feat(core): Schedule with seconds Fixes #3941 --- ...xpression.java => ScheduleValidation.java} | 8 +- .../validator/CronExpressionValidator.java | 38 ------- .../validator/ScheduleValidator.java | 40 ++++++++ .../kestra/plugin/core/trigger/Schedule.java | 34 +++++-- .../core/validations/CronExpressionTest.java | 49 --------- .../kestra/core/validations/ScheduleTest.java | 47 --------- .../validations/ScheduleValidationTest.java | 99 +++++++++++++++++++ .../plugin/core/trigger/ScheduleTest.java | 23 +++++ 8 files changed, 190 insertions(+), 148 deletions(-) rename core/src/main/java/io/kestra/core/validations/{CronExpression.java => ScheduleValidation.java} (65%) delete mode 100644 core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java create mode 100644 core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java delete mode 100644 core/src/test/java/io/kestra/core/validations/CronExpressionTest.java delete mode 100644 core/src/test/java/io/kestra/core/validations/ScheduleTest.java create mode 100644 core/src/test/java/io/kestra/core/validations/ScheduleValidationTest.java diff --git a/core/src/main/java/io/kestra/core/validations/CronExpression.java b/core/src/main/java/io/kestra/core/validations/ScheduleValidation.java similarity index 65% rename from core/src/main/java/io/kestra/core/validations/CronExpression.java rename to core/src/main/java/io/kestra/core/validations/ScheduleValidation.java index eac3c584283..bba77fb0b82 100644 --- a/core/src/main/java/io/kestra/core/validations/CronExpression.java +++ b/core/src/main/java/io/kestra/core/validations/ScheduleValidation.java @@ -1,15 +1,15 @@ package io.kestra.core.validations; -import io.kestra.core.validations.validator.CronExpressionValidator; +import io.kestra.core.validations.validator.ScheduleValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) -@Constraint(validatedBy = CronExpressionValidator.class) -public @interface CronExpression { - String message() default "invalid cron expression ({validatedValue})"; +@Constraint(validatedBy = ScheduleValidator.class) +public @interface ScheduleValidation { + String message() default "invalid cron expression ({validatedValue.cron})"; Class[] groups() default {}; Class[] payload() default {}; } \ No newline at end of file diff --git a/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java b/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java deleted file mode 100644 index d56a267a188..00000000000 --- a/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.kestra.core.validations.validator; - -import com.cronutils.model.Cron; -import io.kestra.core.validations.CronExpression; -import io.kestra.plugin.core.trigger.Schedule; -import io.micronaut.core.annotation.AnnotationValue; -import io.micronaut.core.annotation.Introspected; -import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.validation.validator.constraints.ConstraintValidator; -import io.micronaut.validation.validator.constraints.ConstraintValidatorContext; -import jakarta.inject.Singleton; - -@Singleton -@Introspected -public class CronExpressionValidator implements ConstraintValidator { - @Override - public boolean isValid( - @Nullable String value, - @NonNull AnnotationValue annotationMetadata, - @NonNull ConstraintValidatorContext context) { - if (value == null) { - return true; - } - - try { - Cron parse = Schedule.CRON_PARSER.parse(value); - parse.validate(); - } catch (IllegalArgumentException e) { - context.disableDefaultConstraintViolation(); - context.buildConstraintViolationWithTemplate( "invalid cron expression '({validatedValue})': " + e.getMessage()) - .addConstraintViolation(); - return false; - } - - return true; - } -} diff --git a/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java b/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java new file mode 100644 index 00000000000..8c79c16239d --- /dev/null +++ b/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java @@ -0,0 +1,40 @@ +package io.kestra.core.validations.validator; + +import com.cronutils.model.Cron; +import io.kestra.core.validations.ScheduleValidation; +import io.kestra.plugin.core.trigger.Schedule; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.validation.validator.constraints.ConstraintValidator; +import io.micronaut.validation.validator.constraints.ConstraintValidatorContext; +import jakarta.inject.Singleton; + +@Singleton +@Introspected +public class ScheduleValidator implements ConstraintValidator { + @Override + public boolean isValid( + @Nullable Schedule value, + @NonNull AnnotationValue annotationMetadata, + @NonNull ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + if (value.getCron() != null) { // if null, the standard @NotNull will do its job + try { + Cron parsed = value.parseCron(); + parsed.validate(); + } catch (IllegalArgumentException e) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate( "invalid cron expression '" + value.getCron() + "': " + e.getMessage()) + .addConstraintViolation(); + return false; + } + } + + return true; + } +} diff --git a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java index c38197bbf39..14ab67fcd66 100644 --- a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java +++ b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java @@ -22,7 +22,7 @@ import io.kestra.core.runners.RunContext; import io.kestra.core.services.ConditionService; import io.kestra.core.utils.ListUtils; -import io.kestra.core.validations.CronExpression; +import io.kestra.core.validations.ScheduleValidation; import io.kestra.core.validations.TimezoneId; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -120,10 +120,11 @@ }, aliases = "io.kestra.core.models.triggers.types.Schedule" ) +@ScheduleValidation public class Schedule extends AbstractTrigger implements PollingTriggerInterface, TriggerOutput { private static final String PLUGIN_PROPERTY_RECOVER_MISSED_SCHEDULES = "recoverMissedSchedules"; - public static final CronParser CRON_PARSER = new CronParser(CronDefinitionBuilder.defineCron() + private static final CronDefinitionBuilder CRON_DEFINITION_BUILDER = CronDefinitionBuilder.defineCron() .withMinutes().withValidRange(0, 59).withStrictRange().and() .withHours().withValidRange(0, 23).withStrictRange().and() .withDayOfMonth().withValidRange(1, 31).withStrictRange().and() @@ -135,15 +136,15 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface .withSupportedNicknameWeekly() .withSupportedNicknameDaily() .withSupportedNicknameMidnight() - .withSupportedNicknameHourly() - .instance() - ); + .withSupportedNicknameHourly(); + + private static final CronParser CRON_PARSER = new CronParser(CRON_DEFINITION_BUILDER.instance()); + private static final CronParser CRON_PARSER_WITH_SECONDS = new CronParser(CRON_DEFINITION_BUILDER.withSeconds().withValidRange(0, 59).withStrictRange().and().instance()); @NotNull - @CronExpression @Schema( title = "The cron expression.", - description = "A standard [unix cron expression](https://en.wikipedia.org/wiki/Cron) without second.\n" + + description = "A standard [unix cron expression](https://en.wikipedia.org/wiki/Cron) with 5 fields (minutes precision). Using `ẁithSeconds: true` you can switch to 6 fields and a seconds precision.\n" + "Can also be a cron extension / nickname:\n" + "* `@yearly`\n" + "* `@annually`\n" + @@ -156,6 +157,15 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface @PluginProperty private String cron; + @Schema( + title = "Whether the cron expression has seconds precision", + description = "By default, the cron expression has 5 fields, setting this property to true will allow a 6th fields for seconds precision." + ) + @NotNull + @Builder.Default + @PluginProperty + private Boolean withSeconds = false; + @TimezoneId @Schema( title = "The [time zone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (i.e. the second column in [the Wikipedia table](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)) to use for evaluating the cron expression. Default value is the server default zone ID." @@ -427,6 +437,11 @@ public RecoverMissedSchedules defaultRecoverMissedSchedules(RunContext runContex .orElse(RecoverMissedSchedules.ALL); } + public Cron parseCron() { + CronParser parser = Boolean.TRUE.equals(withSeconds) ? CRON_PARSER_WITH_SECONDS : CRON_PARSER; + return parser.parse(this.cron); + } + private List