diff --git a/core/src/main/java/io/kestra/core/models/flows/Input.java b/core/src/main/java/io/kestra/core/models/flows/Input.java index 78728c03d1a..47e7e18256e 100644 --- a/core/src/main/java/io/kestra/core/models/flows/Input.java +++ b/core/src/main/java/io/kestra/core/models/flows/Input.java @@ -1,12 +1,16 @@ package io.kestra.core.models.flows; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.kestra.core.models.flows.input.*; import io.micronaut.core.annotation.Introspected; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; +import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @@ -17,7 +21,21 @@ @NoArgsConstructor @Introspected @JsonInclude(JsonInclude.Include.NON_DEFAULT) -public class Input { +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY) +@JsonSubTypes({ + @JsonSubTypes.Type(value = BooleanInput.class, name = "BOOLEAN"), + @JsonSubTypes.Type(value = DateInput.class, name = "DATE"), + @JsonSubTypes.Type(value = DateTimeInput.class, name = "DATETIME"), + @JsonSubTypes.Type(value = DurationInput.class, name = "DURATION"), + @JsonSubTypes.Type(value = FileInput.class, name = "FILE"), + @JsonSubTypes.Type(value = FloatInput.class, name = "FLOAT"), + @JsonSubTypes.Type(value = IntInput.class, name = "INT"), + @JsonSubTypes.Type(value = JsonInput.class, name = "JSON"), + @JsonSubTypes.Type(value = StringInput.class, name = "STRING"), + @JsonSubTypes.Type(value = TimeInput.class, name = "TIME"), + @JsonSubTypes.Type(value = URIInput.class, name = "URI") +}) +public abstract class Input { @NotNull @NotBlank @Pattern(regexp="[.a-zA-Z0-9_-]+") @@ -35,6 +53,8 @@ public class Input { String defaults; + public abstract void validate(T input) throws ConstraintViolationException; + @Introspected public enum Type { STRING, @@ -47,6 +67,6 @@ public enum Type { DURATION, FILE, JSON, - URI, + URI; } } diff --git a/core/src/main/java/io/kestra/core/models/flows/input/BooleanInput.java b/core/src/main/java/io/kestra/core/models/flows/input/BooleanInput.java new file mode 100644 index 00000000000..5f23e513d33 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/BooleanInput.java @@ -0,0 +1,18 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class BooleanInput extends Input { + @Override + public void validate(Boolean input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/DateInput.java b/core/src/main/java/io/kestra/core/models/flows/input/DateInput.java new file mode 100644 index 00000000000..063119fa431 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/DateInput.java @@ -0,0 +1,19 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDate; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class DateInput extends Input { + @Override + public void validate(LocalDate input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/DateTimeInput.java b/core/src/main/java/io/kestra/core/models/flows/input/DateTimeInput.java new file mode 100644 index 00000000000..df8575d1bdd --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/DateTimeInput.java @@ -0,0 +1,19 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.Instant; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class DateTimeInput extends Input { + @Override + public void validate(Instant input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/DurationInput.java b/core/src/main/java/io/kestra/core/models/flows/input/DurationInput.java new file mode 100644 index 00000000000..10456b5905b --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/DurationInput.java @@ -0,0 +1,19 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.Duration; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class DurationInput extends Input { + @Override + public void validate(Duration input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/FileInput.java b/core/src/main/java/io/kestra/core/models/flows/input/FileInput.java new file mode 100644 index 00000000000..a3e8e5a4a87 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/FileInput.java @@ -0,0 +1,19 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.net.URI; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class FileInput extends Input { + @Override + public void validate(URI input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/FloatInput.java b/core/src/main/java/io/kestra/core/models/flows/input/FloatInput.java new file mode 100644 index 00000000000..c43861c867e --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/FloatInput.java @@ -0,0 +1,18 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class FloatInput extends Input { + @Override + public void validate(Float input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/IntInput.java b/core/src/main/java/io/kestra/core/models/flows/input/IntInput.java new file mode 100644 index 00000000000..e38f9f0d272 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/IntInput.java @@ -0,0 +1,49 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import io.kestra.core.models.validations.ManualConstraintViolation; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.Set; +import java.util.regex.Pattern; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class IntInput extends Input { + + @Schema(title = "Minimal value.") + Integer min; + + @Schema(title = "Maximal value.") + Integer max; + + @Override + public void validate(Integer input) throws ConstraintViolationException { + if (min != null && input.compareTo(min) < 0) { + throw new ConstraintViolationException("Invalid input '" + input + "', it must be more than '" + min + "'", + Set.of(ManualConstraintViolation.of( + "Invalid input", + this, + IntInput.class, + getName(), + input + ))); + } + + if (max != null && input.compareTo(max) > 0) { + throw new ConstraintViolationException("Invalid input '" + input + "', it must be less than '" + max + "'", + Set.of(ManualConstraintViolation.of( + "Invalid input", + this, + IntInput.class, + getName(), + input + ))); + } + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/JsonInput.java b/core/src/main/java/io/kestra/core/models/flows/input/JsonInput.java new file mode 100644 index 00000000000..38303512279 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/JsonInput.java @@ -0,0 +1,18 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class JsonInput extends Input { + @Override + public void validate(Object input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/StringInput.java b/core/src/main/java/io/kestra/core/models/flows/input/StringInput.java new file mode 100644 index 00000000000..378cf45835c --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/StringInput.java @@ -0,0 +1,38 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import io.kestra.core.models.validations.ManualConstraintViolation; +import io.kestra.core.validations.Regex; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.Set; +import java.util.regex.Pattern; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class StringInput extends Input { + @Schema( + title = "Regular expression validating the value." + ) + @Regex + String validator; + + @Override + public void validate(String input) throws ConstraintViolationException { + if (validator != null && ! Pattern.matches(validator, input)) { + throw new ConstraintViolationException("Invalid input '" + input + "', it must match the pattern '" + validator + "'", + Set.of(ManualConstraintViolation.of( + "Invalid input", + this, + StringInput.class, + getName(), + input + ))); + } + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/TimeInput.java b/core/src/main/java/io/kestra/core/models/flows/input/TimeInput.java new file mode 100644 index 00000000000..93b41b10cbe --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/TimeInput.java @@ -0,0 +1,19 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalTime; +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class TimeInput extends Input { + @Override + public void validate(LocalTime input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/models/flows/input/URIInput.java b/core/src/main/java/io/kestra/core/models/flows/input/URIInput.java new file mode 100644 index 00000000000..469a6cd9614 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/flows/input/URIInput.java @@ -0,0 +1,18 @@ +package io.kestra.core.models.flows.input; + +import io.kestra.core.models.flows.Input; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import javax.validation.ConstraintViolationException; + +@SuperBuilder +@Getter +@NoArgsConstructor +public class URIInput extends Input { + @Override + public void validate(String input) throws ConstraintViolationException { + // no validation yet + } +} diff --git a/core/src/main/java/io/kestra/core/runners/RunnerUtils.java b/core/src/main/java/io/kestra/core/runners/RunnerUtils.java index eefb352c732..fded5655559 100644 --- a/core/src/main/java/io/kestra/core/runners/RunnerUtils.java +++ b/core/src/main/java/io/kestra/core/runners/RunnerUtils.java @@ -39,6 +39,7 @@ import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; @Singleton @@ -118,120 +119,126 @@ public Map typedInputs(Flow flow, Execution execution, Map( - input.getName(), - current - )); + var parsedInput = parseInput(flow, execution, input, current); + parsedInput.ifPresent(parsed -> input.validate(parsed.getValue())); + return parsedInput; + }) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - case INT: - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - Integer.valueOf(current) - )); + return handleNestedInputs(results); + } + + private Optional> parseInput(Flow flow, Execution execution, Input input, String current) { + switch (input.getType()) { + case STRING: + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + current + )); + + case INT: + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + Integer.valueOf(current) + )); + + case FLOAT: + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + Float.valueOf(current) + )); + + case BOOLEAN: + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + Boolean.valueOf(current) + )); + + case DATETIME: + try { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + Instant.parse(current) + )); + } catch (DateTimeParseException e) { + throw new MissingRequiredInput("Invalid DATETIME format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); + } - case FLOAT: - return Optional.of(new AbstractMap.SimpleEntry( + case DATE: + try { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + LocalDate.parse(current) + )); + } catch (DateTimeParseException e) { + throw new MissingRequiredInput("Invalid DATE format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); + } + + case TIME: + try { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + LocalTime.parse(current) + )); + } catch (DateTimeParseException e) { + throw new MissingRequiredInput("Invalid TIME format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); + } + + case DURATION: + try { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + Duration.parse(current) + )); + } catch (DateTimeParseException e) { + throw new MissingRequiredInput("Invalid DURATION format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); + } + + case FILE: + try { + URI uri = URI.create(current.replace(File.separator, "/")); + + if (uri.getScheme() != null && uri.getScheme().equals("kestra")) { + return Optional.of(new AbstractMap.SimpleEntry<>( input.getName(), - Float.valueOf(current) + uri )); - - case BOOLEAN: - return Optional.of(new AbstractMap.SimpleEntry( + } else { + return Optional.of(new AbstractMap.SimpleEntry<>( input.getName(), - Boolean.valueOf(current) + storageInterface.from(flow, execution, input, new File(current)) )); + } + } catch (Exception e) { + throw new MissingRequiredInput("Invalid input arguments for file on input '" + input.getName() + "'", e); + } - case DATETIME: - try { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - Instant.parse(current) - )); - } catch (DateTimeParseException e) { - throw new MissingRequiredInput("Invalid DATETIME format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); - } - - case DATE: - try { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - LocalDate.parse(current) - )); - } catch (DateTimeParseException e) { - throw new MissingRequiredInput("Invalid DATE format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); - } - - case TIME: - try { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - LocalTime.parse(current) - )); - } catch (DateTimeParseException e) { - throw new MissingRequiredInput("Invalid TIME format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); - } - - case DURATION: - try { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - Duration.parse(current) - )); - } catch (DateTimeParseException e) { - throw new MissingRequiredInput("Invalid DURATION format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); - } - - case FILE: - try { - URI uri = URI.create(current.replace(File.separator, "/")); - - if (uri.getScheme() != null && uri.getScheme().equals("kestra")) { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - uri - )); - } else { - return Optional.of(new AbstractMap.SimpleEntry( - input.getName(), - storageInterface.from(flow, execution, input, new File(current)) - )); - } - } catch (Exception e) { - throw new MissingRequiredInput("Invalid input arguments for file on input '" + input.getName() + "'", e); - } - - case JSON: - try { - return Optional.of(new AbstractMap.SimpleEntry<>( - input.getName(), - JacksonMapper.toObject(current) - )); - } catch (JsonProcessingException e) { - throw new MissingRequiredInput("Invalid JSON format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); - } - - case URI: - Matcher matcher = URI_PATTERN.matcher(current); - if (matcher.matches()) { - return Optional.of(new AbstractMap.SimpleEntry<>( - input.getName(), - current - )); - } else { - throw new MissingRequiredInput("Invalid URI format for '" + input.getName() + "' for '" + current + "'"); - } - - default: - throw new MissingRequiredInput("Invalid input type '" + input.getType() + "' for '" + input.getName() + "'"); + case JSON: + try { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + JacksonMapper.toObject(current) + )); + } catch (JsonProcessingException e) { + throw new MissingRequiredInput("Invalid JSON format for '" + input.getName() + "' for '" + current + "' with error " + e.getMessage(), e); } - }) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - return handleNestedInputs(results); + case URI: + Matcher matcher = URI_PATTERN.matcher(current); + if (matcher.matches()) { + return Optional.of(new AbstractMap.SimpleEntry<>( + input.getName(), + current + )); + } else { + throw new MissingRequiredInput("Invalid URI format for '" + input.getName() + "' for '" + current + "'"); + } + + default: + throw new MissingRequiredInput("Invalid input type '" + input.getType() + "' for '" + input.getName() + "'"); + } } @SuppressWarnings("unchecked") diff --git a/core/src/main/java/io/kestra/core/validations/Regex.java b/core/src/main/java/io/kestra/core/validations/Regex.java new file mode 100644 index 00000000000..c59dd2cc927 --- /dev/null +++ b/core/src/main/java/io/kestra/core/validations/Regex.java @@ -0,0 +1,11 @@ +package io.kestra.core.validations; + +import javax.validation.Constraint; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = { }) +public @interface Regex { + String message() default "invalid pattern ({validatedValue})"; +} diff --git a/core/src/main/java/io/kestra/core/validations/ValidationFactory.java b/core/src/main/java/io/kestra/core/validations/ValidationFactory.java index 694f627ed34..7c54c83b65c 100644 --- a/core/src/main/java/io/kestra/core/validations/ValidationFactory.java +++ b/core/src/main/java/io/kestra/core/validations/ValidationFactory.java @@ -3,6 +3,7 @@ import com.cronutils.model.Cron; import com.fasterxml.jackson.databind.ObjectMapper; import io.kestra.core.models.flows.Flow; +import io.kestra.core.models.flows.Input; import io.kestra.core.models.tasks.Task; import io.kestra.core.tasks.flows.Switch; import io.micronaut.context.annotation.Factory; @@ -12,6 +13,8 @@ import java.io.IOException; import java.text.SimpleDateFormat; import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; import javax.validation.ConstraintViolation; @@ -134,7 +137,7 @@ ConstraintValidator flowValidation() { .filter(entry -> Collections.frequency(ids, entry) > 1).collect(Collectors.toList()); if (duplicates.size() > 0) { - violations.add("Duplicate task id with name [" + String.join(", ", duplicates) + "]"); + violations.add("Duplicate task id with name [" + String.join(", ", duplicates) + "]"); } if (violations.size() > 0) { @@ -145,5 +148,23 @@ ConstraintValidator flowValidation() { } }; } + + @Singleton + ConstraintValidator patternValidator() { + return (value, annotationMetadata, context) -> { + if (value == null) { + return true; + } + + try { + Pattern.compile(value); + } catch(PatternSyntaxException e) { + context.messageTemplate("invalid pattern [" + value + "]"); + return false; + } + + return true; + }; + } } diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java index 33c73fd326c..7640ee857da 100644 --- a/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java +++ b/core/src/test/java/io/kestra/core/repositories/AbstractFlowRepositoryTest.java @@ -8,6 +8,7 @@ import io.kestra.core.models.flows.Flow; import io.kestra.core.models.flows.FlowWithSource; import io.kestra.core.models.flows.Input; +import io.kestra.core.models.flows.input.StringInput; import io.kestra.core.models.triggers.Trigger; import io.kestra.core.queues.QueueFactoryInterface; import io.kestra.core.queues.QueueInterface; @@ -113,7 +114,7 @@ protected void revision() throws JsonProcessingException { .id(flowId) .namespace("io.kestra.unittest") .tasks(Collections.singletonList(Return.builder().id("test").type(Return.class.getName()).format("test").build())) - .inputs(ImmutableList.of(Input.builder().type(Input.Type.STRING).name("a").build())) + .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).name("a").build())) .build(); // create with repository FlowWithSource flow = flowRepository.create(first, first.generateSource(), taskDefaultService.injectDefaults(first)); @@ -133,7 +134,7 @@ protected void revision() throws JsonProcessingException { .commands(Collections.singletonList("echo 1").toArray(new String[0])) .build() )) - .inputs(ImmutableList.of(Input.builder().type(Input.Type.STRING).name("b").build())) + .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).name("b").build())) .build(); // revision is incremented @@ -258,7 +259,7 @@ void updateConflict() { Flow flow = Flow.builder() .id(flowId) .namespace("io.kestra.unittest") - .inputs(ImmutableList.of(Input.builder().type(Input.Type.STRING).name("a").build())) + .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).name("a").build())) .tasks(Collections.singletonList(Return.builder().id("test").type(Return.class.getName()).format("test").build())) .build(); @@ -269,7 +270,7 @@ void updateConflict() { Flow update = Flow.builder() .id(IdUtils.create()) .namespace("io.kestra.unittest2") - .inputs(ImmutableList.of(Input.builder().type(Input.Type.STRING).name("b").build())) + .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).name("b").build())) .tasks(Collections.singletonList(Return.builder().id("test").type(Return.class.getName()).format("test").build())) .build(); ; diff --git a/core/src/test/java/io/kestra/core/runners/InputsTest.java b/core/src/test/java/io/kestra/core/runners/InputsTest.java index e5fcb37c742..7568986e957 100644 --- a/core/src/test/java/io/kestra/core/runners/InputsTest.java +++ b/core/src/test/java/io/kestra/core/runners/InputsTest.java @@ -3,6 +3,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.io.CharStreams; import io.kestra.core.exceptions.MissingRequiredInput; +import io.kestra.core.models.flows.Flow; +import io.kestra.core.serializers.YamlFlowParser; +import io.kestra.core.utils.TestsUtils; import org.junit.jupiter.api.Test; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.flows.State; @@ -10,12 +13,15 @@ import io.kestra.core.storages.StorageInterface; import jakarta.inject.Inject; + +import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -25,6 +31,8 @@ import java.util.Objects; import java.util.concurrent.TimeoutException; +import javax.validation.ConstraintViolationException; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -46,6 +54,8 @@ public class InputsTest extends AbstractMemoryRunnerTest { .put("nested.string", "a string") .put("nested.more.int", "123") .put("nested.bool", "true") + .put("validatedString", "A123") + .put("validatedInt", "12") .build(); @Inject @@ -54,9 +64,16 @@ public class InputsTest extends AbstractMemoryRunnerTest { @Inject private StorageInterface storageInterface; + @Inject + private YamlFlowParser yamlFlowParser; + private Map typedInputs(Map map) { + return typedInputs(map, flowRepository.findById("io.kestra.tests", "inputs").get()); + } + + private Map typedInputs(Map map, Flow flow) { return runnerUtils.typedInputs( - flowRepository.findById("io.kestra.tests", "inputs").get(), + flow, Execution.builder() .id("test") .namespace("test") @@ -170,6 +187,49 @@ void inputUri() { assertThat(typeds.get("uri"), is("https://www.google.com")); } + @Test + void inputValidatedString() { + Map typeds = typedInputs(inputs); + assertThat(typeds.get("validatedString"), is("A123")); + } + + @Test + void inputValidatedStringBadValue() { + HashMap map = new HashMap<>(inputs); + map.put("validatedString", "foo"); + + ConstraintViolationException e = assertThrows(ConstraintViolationException.class, () -> { + Map typeds = typedInputs(map); + }); + + assertThat(e.getMessage(), is("Invalid input 'foo', it must match the pattern 'A\\d+'")); + } + + @Test + void inputValidatedInteger() { + Map typeds = typedInputs(inputs); + assertThat(typeds.get("validatedInt"), is(12)); + } + + @Test + void inputValidatedIntegerBadValue() { + HashMap mapMin = new HashMap<>(inputs); + mapMin.put("validatedInt", "9"); + ConstraintViolationException e = assertThrows(ConstraintViolationException.class, () -> { + Map typeds = typedInputs(mapMin); + }); + assertThat(e.getMessage(), is("Invalid input '9', it must be more than '10'")); + + HashMap mapMax = new HashMap<>(inputs); + mapMax.put("validatedInt", "21"); + + e = assertThrows(ConstraintViolationException.class, () -> { + Map typeds = typedInputs(mapMax); + }); + + assertThat(e.getMessage(), is("Invalid input '21', it must be less than '20'")); + } + @Test void inputFailed() { HashMap map = new HashMap<>(inputs); @@ -190,4 +250,13 @@ void inputNested() { assertThat(((Map)typeds.get("nested")).get("bool"), is(true)); assertThat(((Map)((Map)typeds.get("nested")).get("more")).get("int"), is(123)); } + + private Flow parse(String path) { + URL resource = TestsUtils.class.getClassLoader().getResource(path); + assert resource != null; + + File file = new File(resource.getFile()); + + return yamlFlowParser.parse(file, Flow.class); + } } diff --git a/core/src/test/java/io/kestra/core/schedulers/AbstractSchedulerTest.java b/core/src/test/java/io/kestra/core/schedulers/AbstractSchedulerTest.java index db6552fb778..62c6d250e7d 100644 --- a/core/src/test/java/io/kestra/core/schedulers/AbstractSchedulerTest.java +++ b/core/src/test/java/io/kestra/core/schedulers/AbstractSchedulerTest.java @@ -4,6 +4,7 @@ import io.kestra.core.models.conditions.ConditionContext; import io.kestra.core.models.flows.Input; import io.kestra.core.models.flows.TaskDefault; +import io.kestra.core.models.flows.input.StringInput; import io.micronaut.context.ApplicationContext; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import lombok.*; @@ -46,13 +47,13 @@ protected static Flow createFlow(List triggers, List r.getPropertyPath().toString().equals("inputs[0].name")).findFirst().orElseThrow().getMessage(), containsString("must match")); - assertThat(exception.getConstraintViolations().stream().filter(r -> r.getPropertyPath().toString().equals("inputs[0].type")).findFirst().orElseThrow().getMessage(), is("must not be null")); + exception.getConstraintViolations().forEach( + c -> assertThat(c.getMessage(), anyOf( + is("Invalid type: null"), + containsString("missing type id property 'type' (for POJO property 'inputs')")) + ) + ); } @Test void inputs() { Flow flow = this.parse("flows/valids/inputs.yaml"); - assertThat(flow.getInputs().size(), is(17)); + assertThat(flow.getInputs().size(), is(19)); assertThat(flow.getInputs().stream().filter(Input::getRequired).count(), is(6L)); - assertThat(flow.getInputs().stream().filter(r -> !r.getRequired()).count(), is(11L)); + assertThat(flow.getInputs().stream().filter(r -> !r.getRequired()).count(), is(13L)); assertThat(flow.getInputs().stream().filter(r -> r.getDefaults() != null).count(), is(1L)); + assertThat(flow.getInputs().stream().filter(r -> r instanceof StringInput && ((StringInput)r).getValidator() != null).count(), is(1L)); } @Test @@ -133,7 +139,7 @@ void inputsBadType() { () -> this.parse("flows/invalids/inputs-bad-type.yaml") ); - assertThat(exception.getMessage(), containsString("not one of the values accepted for Enum class")); + assertThat(exception.getMessage(), containsString("Invalid type: FOO")); } @Test diff --git a/core/src/test/java/io/kestra/core/validations/InputTest.java b/core/src/test/java/io/kestra/core/validations/InputTest.java new file mode 100644 index 00000000000..13f167452c4 --- /dev/null +++ b/core/src/test/java/io/kestra/core/validations/InputTest.java @@ -0,0 +1,28 @@ +package io.kestra.core.validations; + +import io.kestra.core.models.flows.Input; +import io.kestra.core.models.flows.input.StringInput; +import io.kestra.core.models.validations.ModelValidator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@MicronautTest +class InputTest { + @Inject + private ModelValidator modelValidator; + + @Test + void inputValidation() { + final Input validInput = StringInput.builder() + .name("test") + .type(Input.Type.STRING) + .validator("[A-Z]+") + .build(); + + assertThat(modelValidator.isValid(validInput).isEmpty(), is(true)); + } +} diff --git a/core/src/test/java/io/kestra/core/validations/RegexTest.java b/core/src/test/java/io/kestra/core/validations/RegexTest.java new file mode 100644 index 00000000000..23a2f2852ac --- /dev/null +++ b/core/src/test/java/io/kestra/core/validations/RegexTest.java @@ -0,0 +1,39 @@ +package io.kestra.core.validations; + +import io.kestra.core.models.validations.ModelValidator; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +@MicronautTest +class RegexTest { + @Inject + private ModelValidator modelValidator; + + @AllArgsConstructor + @Introspected + @Getter + public static class RegexCls { + @Regex + String pattern; + } + + @Test + void inputValidation() { + final RegexCls validRegex = new RegexCls("[A-Z]+"); + + assertThat(modelValidator.isValid(validRegex).isEmpty(), is(true)); + + final RegexCls invalidRegex = new RegexCls("\\"); + + assertThat(modelValidator.isValid(invalidRegex).isPresent(), is(true)); + assertThat(modelValidator.isValid(invalidRegex).get().getMessage(), containsString("invalid pattern")); + } +} diff --git a/core/src/test/resources/flows/invalids/inputs-bad-validator-syntax.yaml b/core/src/test/resources/flows/invalids/inputs-bad-validator-syntax.yaml new file mode 100644 index 00000000000..ac8a78e6437 --- /dev/null +++ b/core/src/test/resources/flows/invalids/inputs-bad-validator-syntax.yaml @@ -0,0 +1,10 @@ +id: empty +namespace: io.kestra.tests +inputs: + - name: badValidatorSyntax + type: STRING + validator: \ +tasks: + - id: date + type: io.kestra.core.tasks.debugs.Return + format: "{{taskrun.startDate}}" diff --git a/core/src/test/resources/flows/valids/inputs.yaml b/core/src/test/resources/flows/valids/inputs.yaml index 32883642e32..40c42e6cbe6 100644 --- a/core/src/test/resources/flows/valids/inputs.yaml +++ b/core/src/test/resources/flows/valids/inputs.yaml @@ -48,6 +48,15 @@ inputs: - name: nested.bool type: BOOLEAN required: false +- name: validatedString + type: STRING + validator: A\d+ + required: false +- name: validatedInt + type: INT + min: 10 + max: 20 + required: false tasks: - id: string type: io.kestra.core.tasks.debugs.Return diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java index 95f0bb0bd93..9bbd8b94932 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java @@ -6,6 +6,7 @@ import io.kestra.core.models.flows.Flow; import io.kestra.core.models.flows.FlowWithSource; import io.kestra.core.models.flows.Input; +import io.kestra.core.models.flows.input.StringInput; import io.kestra.core.models.hierarchies.FlowGraph; import io.kestra.core.models.tasks.Task; import io.kestra.core.runners.AbstractMemoryRunnerTest; @@ -647,7 +648,7 @@ private Flow generateFlow(String friendlyId, String namespace, String inputName) return Flow.builder() .id(friendlyId) .namespace(namespace) - .inputs(ImmutableList.of(Input.builder().type(Input.Type.STRING).name(inputName).build())) + .inputs(ImmutableList.of(StringInput.builder().type(Input.Type.STRING).name(inputName).build())) .tasks(Collections.singletonList(generateTask("test", "test"))) .build(); }