From 02484c0043672479ce8f4c6da5d9479463b9b9c6 Mon Sep 17 00:00:00 2001 From: francois papon Date: Mon, 26 Feb 2024 21:59:35 +0100 Subject: [PATCH] [json] Add json schema validator --- .../validation/JsonSchemaValidator.java | 51 ++ .../JsonSchemaValidatorFactory.java | 301 +++++++ .../schema/validation/ValidationResult.java | 27 + .../validation/spi/ValidationContext.java | 28 + .../validation/spi/ValidationExtension.java | 26 + .../spi/builtin/BaseNumberValidation.java | 44 + .../spi/builtin/BaseValidation.java | 91 +++ .../spi/builtin/ContainsValidation.java | 72 ++ .../spi/builtin/EnumValidation.java | 70 ++ .../builtin/ExclusiveMaximumValidation.java | 58 ++ .../builtin/ExclusiveMinimumValidation.java | 58 ++ .../spi/builtin/IntegerValidation.java | 34 + .../spi/builtin/ItemsValidation.java | 99 +++ .../spi/builtin/MaxItemsValidation.java | 62 ++ .../spi/builtin/MaxLengthValidation.java | 60 ++ .../spi/builtin/MaxPropertiesValidation.java | 62 ++ .../spi/builtin/MaximumValidation.java | 58 ++ .../spi/builtin/MinItemsValidation.java | 61 ++ .../spi/builtin/MinLengthValidation.java | 60 ++ .../spi/builtin/MinPropertiesValidation.java | 62 ++ .../spi/builtin/MinimumValidation.java | 58 ++ .../spi/builtin/MultipleOfValidation.java | 62 ++ .../spi/builtin/PatternValidation.java | 70 ++ .../spi/builtin/RequiredValidation.java | 72 ++ .../spi/builtin/TypeValidation.java | 94 +++ .../spi/builtin/UniqueItemsValidation.java | 60 ++ .../spi/builtin/regex/JavaRegex.java | 40 + .../spi/builtin/type/JsonSchemaFormat.java | 31 + .../spi/builtin/type/TypeFilter.java | 102 +++ .../JsonSchemaValidatorFactoryTest.java | 767 ++++++++++++++++++ 30 files changed, 2740 insertions(+) create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidator.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactory.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/ValidationResult.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationContext.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationExtension.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseNumberValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ContainsValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/EnumValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMaximumValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMinimumValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/IntegerValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ItemsValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxItemsValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxLengthValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxPropertiesValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaximumValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinItemsValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinLengthValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinPropertiesValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinimumValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MultipleOfValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/PatternValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/RequiredValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/TypeValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/UniqueItemsValidation.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/regex/JavaRegex.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/JsonSchemaFormat.java create mode 100644 fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/TypeFilter.java create mode 100644 fusion-json/src/test/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactoryTest.java diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidator.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidator.java new file mode 100644 index 00000000..0af708e0 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidator.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation; + +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; + +public class JsonSchemaValidator implements Function, AutoCloseable { + private static final ValidationResult SUCCESS = new ValidationResult(emptyList()); + + private final Function> validationFunction; + + JsonSchemaValidator(final Function> validationFunction) { + this.validationFunction = validationFunction; + } + + @Override + public ValidationResult apply(final Object object) { + final var errors = validationFunction.apply(object).collect(toList()); + if (!errors.isEmpty()) { + return new ValidationResult(errors); + } + return SUCCESS; + } + + @Override + public void close() { + // no-op + } + + @Override + public String toString() { + return "JsonSchemaValidator{validationFunction=" + validationFunction + '}'; + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactory.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactory.java new file mode 100644 index 00000000..97358cab --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactory.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation; + +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; +import io.yupiik.fusion.json.schema.validation.spi.builtin.ContainsValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.EnumValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.ExclusiveMaximumValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.ExclusiveMinimumValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.IntegerValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.ItemsValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MaxItemsValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MaxLengthValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MaxPropertiesValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MaximumValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MinItemsValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MinLengthValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MinPropertiesValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MinimumValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.MultipleOfValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.PatternValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.RequiredValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.TypeValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.UniqueItemsValidation; +import io.yupiik.fusion.json.schema.validation.spi.builtin.regex.JavaRegex; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.toList; + +public class JsonSchemaValidatorFactory implements AutoCloseable { + private static final String[] ROOT_PATH = new String[0]; + private static final Function> NO_VALIDATION = new Function<>() { + @Override + public Stream apply(Object Object) { + return Stream.empty(); + } + + @Override + public String toString() { + return "NoValidation"; + } + }; + + private final List extensions = new ArrayList<>(); + + // js is closer to default and actually most used in the industry + private final AtomicReference>> regexFactory = new AtomicReference<>(this::newRegexFactory); + + public JsonSchemaValidatorFactory(final ValidationExtension... customValidations) { + extensions.addAll(createDefaultValidations()); + } + + // enable to use a javascript impl if people add the megs needed to have an embedded (or not) js runtime + // but default to something faster + protected Predicate newRegexFactory(final String regex) { + return new JavaRegex(regex); + } + + // see http://json-schema.org/latest/json-schema-validation.html + public List createDefaultValidations() { + return asList( + new RequiredValidation(), + new TypeValidation(), + new IntegerValidation(), + new EnumValidation(), + new MultipleOfValidation(), + new MaximumValidation(), + new MinimumValidation(), + new ExclusiveMaximumValidation(), + new ExclusiveMinimumValidation(), + new MaxLengthValidation(), + new MinLengthValidation(), + new PatternValidation(regexFactory.get()), + new ItemsValidation(this), + new MaxItemsValidation(), + new MinItemsValidation(), + new UniqueItemsValidation(), + new ContainsValidation(this), + new MaxPropertiesValidation(), + new MinPropertiesValidation() + // TODO: dependencies, propertyNames, if/then/else, allOf/anyOf/oneOf/not, + // format validations + ); + } + + public JsonSchemaValidatorFactory appendExtensions(final ValidationExtension... extensions) { + this.extensions.addAll(asList(extensions)); + return this; + } + + public JsonSchemaValidatorFactory setExtensions(final ValidationExtension... extensions) { + this.extensions.clear(); + return appendExtensions(extensions); + } + + public JsonSchemaValidatorFactory setRegexFactory(final Function> factory) { + regexFactory.set(factory); + return this; + } + + public JsonSchemaValidator newInstance(final Map schema) { + return new JsonSchemaValidator(buildValidator(ROOT_PATH, schema, null)); + } + + @Override + public void close() { + // no-op for now + } + + private Function> buildValidator(final String[] path, + final Map schema, + final Function valueProvider) { + final List>> directValidations = buildDirectValidations(path, schema, valueProvider).toList(); + final Function> nestedValidations = buildPropertiesValidations(path, schema, valueProvider); + final Function> dynamicNestedValidations = buildPatternPropertiesValidations(path, schema, valueProvider); + final Function> fallbackNestedValidations = buildAdditionalPropertiesValidations(path, schema, valueProvider); + return new ValidationsFunction( + Stream.concat( + directValidations.stream(), + Stream.of(nestedValidations, dynamicNestedValidations, fallbackNestedValidations)) + .collect(toList())); + } + + private Stream>> buildDirectValidations(final String[] path, + final Map schema, + final Function valueProvider) { + final var model = new ValidationContext(path, schema, valueProvider); + return extensions.stream() + .map(e -> e.create(model)) + .filter(Optional::isPresent) + .map(Optional::get); + } + + @SuppressWarnings("unchecked") + private Function> buildPropertiesValidations(final String[] path, + final Map schema, + final Function valueProvider) { + return ofNullable(schema.get("properties")) + .filter(TypeFilter.OBJECT) + .map(it -> ((Map) it).entrySet().stream() + .filter(e -> TypeFilter.OBJECT.test(e.getValue())) + .map(obj -> { + final var key = obj.getKey(); + final var fieldPath = Stream.concat(Stream.of(path), Stream.of(key)).toArray(String[]::new); + return buildValidator(fieldPath, (Map) obj.getValue(), new ChainedValueAccessor(valueProvider, key)); + }) + .collect(toList())) + .map(this::toFunction) + .orElse(NO_VALIDATION); + } + + // not the best impl but is it really an important case? + @SuppressWarnings("unchecked") + private Function> buildPatternPropertiesValidations(final String[] path, + final Map schema, + final Function valueProvider) { + return ofNullable(schema.get("patternProperties")) + .filter(TypeFilter.OBJECT) + .map(it -> ((Map) it).entrySet().stream() + .filter(e -> TypeFilter.OBJECT.test(e.getValue())) + .map(obj -> { + final var pattern = regexFactory.get().apply(obj.getKey()); + final var currentSchema = (Map) obj.getValue(); + // no cache cause otherwise it could be in properties + return (Function>) root -> { + final var validable = Optional.ofNullable(valueProvider) + .map(provider -> provider.apply(root)) + .orElse(root); + if (!(validable instanceof Map map)) { + return Stream.empty(); + } + return ((Map) map).entrySet().stream() + .filter(e -> pattern.test(e.getKey())) + .flatMap(e -> buildValidator( + Stream.concat(Stream.of(path), Stream.of(e.getKey())).toArray(String[]::new), + currentSchema, + o -> ((Map) o).get(e.getKey())) + .apply(validable)); + }; + }) + .collect(toList())) + .map(this::toFunction) + .orElse(NO_VALIDATION); + } + + @SuppressWarnings("unchecked") + private Function> buildAdditionalPropertiesValidations(final String[] path, + final Map schema, + final Function valueProvider) { + return ofNullable(schema.get("additionalProperties")) + .filter(TypeFilter.OBJECT) + .map(it -> { + Predicate excluded = s -> false; + if (schema.containsKey("properties")) { + final var properties = ((Map) schema.get("properties")).keySet(); + excluded = excluded.and(s -> !properties.contains(s)); + } + if (schema.containsKey("patternProperties")) { + final var properties = ((Map) schema.get("patternProperties")).keySet().stream() + .map(regexFactory.get()) + .toList(); + excluded = excluded.and(s -> properties.stream().noneMatch(p -> p.test(s))); + } + final var excludeAttrRef = excluded; + + final var currentSchema = (Map) it; + return (Function>) validable -> { + if (!(validable instanceof Map map)) { + return Stream.empty(); + } + + final var casted = (Map) map; + return casted.entrySet().stream() + .filter(e -> excludeAttrRef.test(e.getKey())) + .flatMap(e -> buildValidator( + Stream.concat(Stream.of(path), Stream.of(e.getKey())).toArray(String[]::new), + currentSchema, + new ChainedValueAccessor(valueProvider, e.getKey())).apply(validable)); + }; + }) + .orElse(NO_VALIDATION); + } + + private Function> toFunction( + final List>> validations) { + return new ValidationsFunction(validations); + } + + private static class ValidationsFunction implements Function> { + private final List>> delegates; + + private ValidationsFunction(final List>> validations) { + // unwrap when possible to simplify the stack and make toString readable (debug) + this.delegates = validations.stream() + .flatMap(it -> it instanceof ValidationsFunction v ? v.delegates.stream() : Stream.of(it)) + .filter(it -> it != NO_VALIDATION) + .collect(toList()); + } + + @Override + public Stream apply(final Object Object) { + return delegates.stream().flatMap(v -> v.apply(Object)); + } + + @Override + public String toString() { + return delegates.toString(); + } + } + + private static class ChainedValueAccessor implements Function { + private final Function parent; + private final String key; + + private ChainedValueAccessor(final Function valueProvider, final String key) { + this.parent = valueProvider; + this.key = key; + } + + @Override + public Object apply(final Object value) { + if ((parent == null ? value : parent.apply(value)) instanceof Map map) { + return map.get(key); + } + return null; + } + + @Override + public String toString() { + return "ChainedValueAccessor{" + + "parent=" + parent + + ", key='" + key + '\'' + + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/ValidationResult.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/ValidationResult.java new file mode 100644 index 00000000..1cc61073 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/ValidationResult.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation; + +import java.util.List; + +public record ValidationResult(List errors) { + public boolean isSuccess() { + return errors.isEmpty(); + } + + public record ValidationError(String field, String message) { + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationContext.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationContext.java new file mode 100644 index 00000000..909a5a27 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationContext.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.joining; + +public record ValidationContext(String[] path, Map schema, Function valueProvider) { + public String toPointer() { + return Stream.of(path).collect(joining("/", "/", "")); + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationExtension.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationExtension.java new file mode 100644 index 00000000..ab82e85d --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/ValidationExtension.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public interface ValidationExtension { + Optional>> create(ValidationContext model); +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseNumberValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseNumberValidation.java new file mode 100644 index 00000000..aa4bb27c --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseNumberValidation.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; + +import java.util.function.Function; +import java.util.stream.Stream; + +abstract class BaseNumberValidation extends BaseValidation { + protected final double bound; + + BaseNumberValidation(final String pointer, final Function extractor, final double bound) { + super(pointer, extractor, TypeFilter.NUMBER); + this.bound = bound; + } + + @Override + protected Stream onNumber(final Number number) { + final double val = number.doubleValue(); + if (isValid(val)) { + return Stream.empty(); + } + return toError(val); + } + + protected abstract boolean isValid(double val); + + protected abstract Stream toError(double val); +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseValidation.java new file mode 100644 index 00000000..2b7991ad --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/BaseValidation.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; + +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +public abstract class BaseValidation implements Function> { + protected final String pointer; + protected final Function extractor; + private final TypeFilter typeValidator; + private final boolean rootCanBeNull; + + public BaseValidation(final String pointer, final Function extractor, final TypeFilter typeValidator) { + this.pointer = pointer; + this.extractor = extractor != null ? extractor : v -> v; + this.rootCanBeNull = extractor != null; + this.typeValidator = typeValidator; + } + + @Override + @SuppressWarnings("unchecked") + public Stream apply(final Object obj) { + if (obj == null && rootCanBeNull) { + return Stream.empty(); + } + + final var value = extractor.apply(obj); + if (!typeValidator.test(value)) { + return Stream.empty(); + } + + if (value instanceof String s) { + return onString(s); + } + if (value instanceof Number n) { + return onNumber(n); + } + if (value instanceof Boolean b) { + return onBoolean(b); + } + if (value instanceof Collection c) { + return onArray(c); + } + if (value instanceof Map m) { + return onObject((Map) m); + } + if (value == null) { + return Stream.empty(); + } + throw new IllegalArgumentException("Unsupported value type: " + value); + } + + protected Stream onArray(final Collection array) { + return Stream.empty(); + } + + protected Stream onObject(final Map object) { + return Stream.empty(); + } + + protected Stream onNumber(final Number number) { + return Stream.empty(); + } + + protected Stream onBoolean(final boolean value) { + return Stream.empty(); + } + + protected Stream onString(final String string) { + return Stream.empty(); + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ContainsValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ContainsValidation.java new file mode 100644 index 00000000..1e9545de --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ContainsValidation.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.JsonSchemaValidator; +import io.yupiik.fusion.json.schema.validation.JsonSchemaValidatorFactory; +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class ContainsValidation implements ValidationExtension { + private final JsonSchemaValidatorFactory factory; + + public ContainsValidation(final JsonSchemaValidatorFactory factory) { + this.factory = factory; + } + + @Override + @SuppressWarnings("unchecked") + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("contains")) + .filter(TypeFilter.OBJECT) + .map(it -> new ItemsValidator(model.toPointer(), model.valueProvider(), factory.newInstance((Map) it))); + } + + private static class ItemsValidator extends BaseValidation { + private final JsonSchemaValidator validator; + + private ItemsValidator(final String pointer, + final Function extractor, + JsonSchemaValidator validator) { + super(pointer, extractor, TypeFilter.ARRAY); + this.validator = validator; + } + + @Override + protected Stream onArray(final Collection array) { + for (final var value : array) { + final var itemErrors = validator.apply(value).errors(); + if (itemErrors.isEmpty()) { + return Stream.empty(); + } + } + return Stream.of(new ValidationResult.ValidationError(pointer, "No item matching the expected schema")); + } + + @Override + public String toString() { + return "Contains{validator=" + validator + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/EnumValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/EnumValidation.java new file mode 100644 index 00000000..8804b585 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/EnumValidation.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; + +public class EnumValidation implements ValidationExtension { + @Override + @SuppressWarnings("unchecked") + public Optional>> create(final ValidationContext model) { + return ofNullable(model.schema().get("enum")) + .filter(Collection.class::isInstance) + .map(it -> (Collection) it) + .map(values -> new Impl(values, model.valueProvider(), model.toPointer(), Boolean.TRUE.equals(model.schema().get("nullable")))); + } + + private static class Impl extends BaseValidation { + private final Collection valid; + private final boolean nullable; + + private Impl(final Collection valid, final Function extractor, final String pointer, final boolean nullable) { + super(pointer, extractor, TypeFilter.OBJECT /*ignored*/); + this.valid = valid; + this.nullable = nullable; + } + + @Override + public Stream apply(final Object root) { + if (root == null) { + return Stream.empty(); + } + final var value = extractor.apply(root); + if (nullable && value == null) { + return Stream.empty(); + } + if (valid.contains(value)) { + return Stream.empty(); + } + return Stream.of(new ValidationResult.ValidationError(pointer, "Invalid value, got " + value + ", expected: " + valid)); + } + + @Override + public String toString() { + return "Enum{valid=" + valid + ", pointer='" + pointer + '\'' + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMaximumValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMaximumValidation.java new file mode 100644 index 00000000..e68d15fa --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMaximumValidation.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class ExclusiveMaximumValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("number")) { + return Optional.ofNullable(model.schema().get("exclusiveMaximum")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.doubleValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseNumberValidation { + private Impl(final String pointer, final Function valueProvider, final double bound) { + super(pointer, valueProvider, bound); + } + + @Override + protected boolean isValid(final double val) { + return val < this.bound; + } + + @Override + protected Stream toError(final double val) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " is strictly more than " + this.bound)); + } + + @Override + public String toString() { + return "ExclusiveMaximum{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMinimumValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMinimumValidation.java new file mode 100644 index 00000000..70bd461e --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ExclusiveMinimumValidation.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class ExclusiveMinimumValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("number")) { + return Optional.ofNullable(model.schema().get("exclusiveMinimum")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.doubleValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseNumberValidation { + private Impl(final String pointer, final Function valueProvider, final double bound) { + super(pointer, valueProvider, bound); + } + + @Override + protected boolean isValid(final double val) { + return val > this.bound; + } + + @Override + protected Stream toError(final double val) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " is strictly less than " + this.bound)); + } + + @Override + public String toString() { + return "ExclusiveMinimum{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/IntegerValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/IntegerValidation.java new file mode 100644 index 00000000..ebb17271 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/IntegerValidation.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class IntegerValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if ("integer".equals(model.schema().get("type"))) { + return Optional.of(new MultipleOfValidation.Impl(model.toPointer(), model.valueProvider(), 1)); + } + return Optional.empty(); + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ItemsValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ItemsValidation.java new file mode 100644 index 00000000..c1fc5e56 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/ItemsValidation.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.JsonSchemaValidator; +import io.yupiik.fusion.json.schema.validation.JsonSchemaValidatorFactory; +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Collections.singleton; + +public class ItemsValidation implements ValidationExtension { + private final JsonSchemaValidatorFactory factory; + + public ItemsValidation(final JsonSchemaValidatorFactory factory) { + this.factory = factory; + } + + @Override + @SuppressWarnings("unchecked") + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("items")) + .map(items -> { + if (TypeFilter.OBJECT.test(items)) { + final var objectValidator = factory.newInstance((Map) items); + return new ItemsValidator(model.toPointer(), model.valueProvider(), singleton(objectValidator)); + } + if (TypeFilter.ARRAY.test(items)) { + return new ItemsValidator(model.toPointer(), model.valueProvider(), ((Collection) items).stream() + .filter(TypeFilter.OBJECT) + .map(it -> factory.newInstance((Map) it)) + .toList()); + } + return null; + }); + } + + private static class ItemsValidator extends BaseValidation { + private final Collection objectValidators; + + private ItemsValidator(final String pointer, + final Function extractor, + final Collection objectValidators) { + super(pointer, extractor, TypeFilter.ARRAY); + this.objectValidators = objectValidators; + } + + @Override + protected Stream onArray(final Collection array) { + Collection errors = null; + int i = 0; + final var it = array.iterator(); + while (it.hasNext()) { + final Object value = it.next(); + final Collection itemErrors = objectValidators.stream() + .flatMap(validator -> validator.apply(value).errors().stream()) + .toList(); + if (!itemErrors.isEmpty()) { + if (errors == null) { + errors = new ArrayList<>(); + } + final String suffix = "[" + i + "]"; + errors.addAll(itemErrors.stream() + .map(e -> new ValidationResult.ValidationError(pointer + e.field() + suffix, e.message())) + .toList()); + } + i++; + } + return errors == null ? Stream.empty() : errors.stream(); + } + + @Override + public String toString() { + return "Items{validators=" + objectValidators + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxItemsValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxItemsValidation.java new file mode 100644 index 00000000..b9a00d0e --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxItemsValidation.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MaxItemsValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("maxItems")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(Number::intValue) + .filter(it -> it >= 0) + .map(max -> new Impl(model.toPointer(), model.valueProvider(), max)); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, + final Function extractor, + final int bound) { + super(pointer, extractor, TypeFilter.ARRAY); + this.bound = bound; + } + + @Override + protected Stream onArray(final Collection array) { + if (array.size() > bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, "Too much items in the array (> " + bound + ")")); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MaxItems{max=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxLengthValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxLengthValidation.java new file mode 100644 index 00000000..c52336e7 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxLengthValidation.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MaxLengthValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("string")) { + return Optional.ofNullable(model.schema().get("maxLength")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.intValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, final Function valueProvider, final int bound) { + super(pointer, valueProvider, TypeFilter.STRING); + this.bound = bound; + } + + @Override + protected Stream onString(final String val) { + if (val.length() > bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " length is more than " + this.bound)); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MaxLength{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxPropertiesValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxPropertiesValidation.java new file mode 100644 index 00000000..2ddeffae --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaxPropertiesValidation.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MaxPropertiesValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("maxProperties")) + .filter(TypeFilter.NUMBER) + .map(Number.class::cast) + .map(Number::intValue) + .filter(it -> it >= 0) + .map(max -> new Impl(model.toPointer(), model.valueProvider(), max)); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, + final Function extractor, + final int bound) { + super(pointer, extractor, TypeFilter.OBJECT); + this.bound = bound; + } + + @Override + protected Stream onObject(final Map object) { + if (object.size() > bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, "Too much properties (> " + bound + ")")); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MaxProperties{max=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaximumValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaximumValidation.java new file mode 100644 index 00000000..eb7b157f --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MaximumValidation.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MaximumValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("number")) { + return Optional.ofNullable(model.schema().get("maximum")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.doubleValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseNumberValidation { + private Impl(final String pointer, final Function valueProvider, final double bound) { + super(pointer, valueProvider, bound); + } + + @Override + protected boolean isValid(final double val) { + return val <= this.bound; + } + + @Override + protected Stream toError(final double val) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " is more than " + this.bound)); + } + + @Override + public String toString() { + return "Maximum{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinItemsValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinItemsValidation.java new file mode 100644 index 00000000..8d75d296 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinItemsValidation.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MinItemsValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("minItems")) + .map(Number.class::cast) + .map(Number::intValue) + .filter(it -> it >= 0) + .map(max -> new Impl(model.toPointer(), model.valueProvider(), max)); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, + final Function extractor, + final int bound) { + super(pointer, extractor, TypeFilter.ARRAY); + this.bound = bound; + } + + @Override + protected Stream onArray(final Collection array) { + if (array.size() < bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, "Not enough items in the array (< " + bound + ")")); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MinItems{min=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinLengthValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinLengthValidation.java new file mode 100644 index 00000000..e01b7d4d --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinLengthValidation.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MinLengthValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("string")) { + return Optional.ofNullable(model.schema().get("minLength")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.intValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, final Function valueProvider, final int bound) { + super(pointer, valueProvider, TypeFilter.STRING); + this.bound = bound; + } + + @Override + protected Stream onString(final String val) { + if (val.length() < bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " length is less than " + this.bound)); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MinLength{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinPropertiesValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinPropertiesValidation.java new file mode 100644 index 00000000..d1225d61 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinPropertiesValidation.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MinPropertiesValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("minProperties")) + .filter(TypeFilter.NUMBER) + .map(Number.class::cast) + .map(Number::intValue) + .filter(it -> it >= 0) + .map(max -> new Impl(model.toPointer(), model.valueProvider(), max)); + } + + private static class Impl extends BaseValidation { + private final int bound; + + private Impl(final String pointer, + final Function extractor, + final int bound) { + super(pointer, extractor, TypeFilter.OBJECT); + this.bound = bound; + } + + @Override + protected Stream onObject(final Map object) { + if (object.size() < bound) { + return Stream.of(new ValidationResult.ValidationError(pointer, "Not enough properties (> " + bound + ")")); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "MinProperties{min=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinimumValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinimumValidation.java new file mode 100644 index 00000000..daa784ff --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MinimumValidation.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MinimumValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("number")) { + return Optional.ofNullable(model.schema().get("minimum")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.doubleValue())); + } + return Optional.empty(); + } + + private static class Impl extends BaseNumberValidation { + private Impl(final String pointer, final Function valueProvider, final double bound) { + super(pointer, valueProvider, bound); + } + + @Override + protected boolean isValid(final double val) { + return val >= this.bound; + } + + @Override + protected Stream toError(final double val) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " is less than " + this.bound)); + } + + @Override + public String toString() { + return "Minimum{factor=" + bound + ", pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MultipleOfValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MultipleOfValidation.java new file mode 100644 index 00000000..f4d90834 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/MultipleOfValidation.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class MultipleOfValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("number")) { + return Optional.ofNullable(model.schema().get("multipleOf")) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(m -> new Impl(model.toPointer(), model.valueProvider(), m.doubleValue())); + } + return Optional.empty(); + } + + static class Impl extends BaseNumberValidation { + Impl(final String pointer, final Function valueProvider, final double multipleOf) { + super(pointer, valueProvider, multipleOf); + } + + @Override + protected boolean isValid(double val) { + final double divided = val / bound; + return divided == (long) divided; + } + + @Override + protected Stream toError(final double val) { + return Stream.of(new ValidationResult.ValidationError(pointer, val + " is not a multiple of " + bound)); + } + + @Override + public String toString() { + return "MultipleOf{" + + "factor=" + bound + + ", pointer='" + pointer + '\'' + + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/PatternValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/PatternValidation.java new file mode 100644 index 00000000..96955d87 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/PatternValidation.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public class PatternValidation implements ValidationExtension { + private final Function> predicateFactory; + + public PatternValidation(final Function> predicateFactory) { + this.predicateFactory = predicateFactory; + } + + @Override + public Optional>> create(final ValidationContext model) { + if (model.schema().getOrDefault("type", "object").equals("string")) { + return Optional.ofNullable(model.schema().get("pattern")) + .filter(TypeFilter.STRING) + .map(pattern -> new Impl(model.toPointer(), model.valueProvider(), predicateFactory.apply(pattern.toString()))); + } + return Optional.empty(); + } + + private static class Impl extends BaseValidation { + private final Predicate matcher; + + private Impl(final String pointer, final Function valueProvider, + final Predicate matcher) { + super(pointer, valueProvider, TypeFilter.STRING); + this.matcher = matcher; + } + + @Override + public Stream onString(final String value) { + if (!matcher.test(value)) { + return Stream.of(new ValidationResult.ValidationError(pointer, value + " doesn't match " + matcher)); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "Pattern{" + + "regex=" + matcher + + ", pointer='" + pointer + '\'' + + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/RequiredValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/RequiredValidation.java new file mode 100644 index 00000000..1c03f79b --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/RequiredValidation.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.toSet; + +public class RequiredValidation implements ValidationExtension { + @Override + @SuppressWarnings("unchecked") + public Optional>> create(final ValidationContext model) { + return ofNullable(model.schema().get("required")) + .filter(Collection.class::isInstance) + .map(it -> (Collection) it) + .filter(arr -> arr.stream().allMatch(TypeFilter.STRING)) + .map(arr -> arr.stream().map(Object::toString).collect(toSet())) + .map(required -> new Impl(required, model.valueProvider(), model.toPointer())); + } + + private static class Impl extends BaseValidation { + private final Collection required; + + private Impl(final Collection required, final Function extractor, final String pointer) { + super(pointer, extractor, TypeFilter.OBJECT); + this.required = required; + } + + @Override + public Stream onObject(final Map obj) { + if (obj == null) { + return toErrors(required.stream()); + } + return toErrors(required.stream().filter(name -> obj.get(name) == null)); + } + + private Stream toErrors(final Stream fields) { + return fields.map(name -> new ValidationResult.ValidationError(pointer, name + " is required and is not present")); + } + + @Override + public String toString() { + return "Required{" + + "required=" + required + + ", pointer='" + pointer + '\'' + + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/TypeValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/TypeValidation.java new file mode 100644 index 00000000..d53e4793 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/TypeValidation.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.toList; + +public class TypeValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + final var value = model.schema().get("type"); + if (value instanceof String type) { + return Optional.of(new Impl(model.toPointer(), model.valueProvider(), mapType(type).toArray(TypeFilter[]::new))); + } + if (value instanceof Collection list) { + return Optional.of(new Impl( + model.toPointer(), model.valueProvider(), + list.stream().flatMap(this::mapType).toArray(TypeFilter[]::new))); + } + throw new IllegalArgumentException(value + " is neither an array or string nor a string"); + } + + private Stream mapType(final Object value) { + switch (String.valueOf(value)) { + case "null": + return Stream.of(TypeFilter.NULL); + case "string": + return Stream.of(TypeFilter.STRING); + case "number": + case "integer": + return Stream.of(TypeFilter.NUMBER); + case "array": + return Stream.of(TypeFilter.ARRAY); + case "boolean": + return Stream.of(TypeFilter.BOOL); + case "object": + default: + return Stream.of(TypeFilter.OBJECT); + } + } + + private static class Impl extends BaseValidation { + private final Collection types; + + private Impl(final String pointer, final Function extractor, final TypeFilter... types) { + super(pointer, extractor, types[0] /*ignored anyway*/); + // note: should we always add NULL? if not it leads to a very weird behavior for partial objects and required fixes it + this.types = Stream.concat(Stream.of(types), Stream.of(TypeFilter.NULL)) + .distinct() + .sorted(comparing(i -> i.getClass().getSimpleName())) + .collect(toList()); + } + + @Override + public Stream apply(final Object root) { + if (root == null) { + return Stream.empty(); + } + final var value = extractor.apply(root); + if (value == null || types.stream().anyMatch(f -> f.test(value))) { + return Stream.empty(); + } + return Stream.of(new ValidationResult.ValidationError(pointer, "Expected " + types + " but got " + value.getClass().getTypeName())); + } + + @Override + public String toString() { + return "Type{type=" + types + ", pointer='" + pointer + '\'' + '}'; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/UniqueItemsValidation.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/UniqueItemsValidation.java new file mode 100644 index 00000000..cf6ceae9 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/UniqueItemsValidation.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin; + +import io.yupiik.fusion.json.schema.validation.ValidationResult; +import io.yupiik.fusion.json.schema.validation.spi.builtin.type.TypeFilter; +import io.yupiik.fusion.json.schema.validation.spi.ValidationContext; +import io.yupiik.fusion.json.schema.validation.spi.ValidationExtension; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class UniqueItemsValidation implements ValidationExtension { + @Override + public Optional>> create(final ValidationContext model) { + return Optional.ofNullable(model.schema().get("uniqueItems")) + .filter(Boolean.TRUE::equals) + .map(max -> new Impl(model.toPointer(), model.valueProvider())); + } + + private static class Impl extends BaseValidation { + private Impl(final String pointer, + final Function extractor) { + super(pointer, extractor, TypeFilter.ARRAY); + } + + @Override + protected Stream onArray(final Collection array) { + final var uniques = new HashSet<>(array); + if (array.size() != uniques.size()) { + final Collection duplicated = new ArrayList<>(array); + duplicated.removeAll(uniques); + return Stream.of(new ValidationResult.ValidationError(pointer, "duplicated items: " + duplicated)); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "UniqueItems{pointer='" + pointer + "'}"; + } + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/regex/JavaRegex.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/regex/JavaRegex.java new file mode 100644 index 00000000..c90b758d --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/regex/JavaRegex.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin.regex; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +// not 100% a JSON-Schema regex impl but way lighter than any js impl (ECMA 262) +// TIP: use POSIX regex and it should work portably +public class JavaRegex implements Predicate { + + private final Pattern pattern; + + public JavaRegex(final String pattern) { + this.pattern = Pattern.compile(pattern); + } + + @Override + public boolean test(final CharSequence charSequence) { + return pattern.matcher(charSequence).find(); + } + + @Override + public String toString() { + return "JavaRegex{" + pattern + '}'; + } +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/JsonSchemaFormat.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/JsonSchemaFormat.java new file mode 100644 index 00000000..b401dc40 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/JsonSchemaFormat.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin.type; + +public enum JsonSchemaFormat { // https://json-schema.org/understanding-json-schema/reference/string + date_time, + time, + date, + duration, + email, + hostname, + ipv4, + ipv6, + uuid, + uri, + json_pointer, + regex +} diff --git a/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/TypeFilter.java b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/TypeFilter.java new file mode 100644 index 00000000..260a8ca0 --- /dev/null +++ b/fusion-json/src/main/java/io/yupiik/fusion/json/schema/validation/spi/builtin/type/TypeFilter.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation.spi.builtin.type; + +import java.util.Collection; +import java.util.Map; +import java.util.StringJoiner; +import java.util.function.Predicate; + +public sealed interface TypeFilter extends Predicate permits TypeFilter.ObjectFilter, TypeFilter.ArrayFilter, TypeFilter.NumberFilter, TypeFilter.BooleanFilter, TypeFilter.StringFilter, TypeFilter.NullFilter { + TypeFilter OBJECT = new ObjectFilter(); + TypeFilter ARRAY = new ArrayFilter(); + TypeFilter NUMBER = new NumberFilter(); + TypeFilter BOOL = new BooleanFilter(); + TypeFilter STRING = new StringFilter(); + TypeFilter NULL = new NullFilter(); + + final class ObjectFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o instanceof Map; + } + + @Override + public String toString() { + return "OBJECT"; + } + } + + final class ArrayFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o instanceof Collection; + } + + @Override + public String toString() { + return "ARRAY"; + } + } + + final class NumberFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o instanceof Number; + } + + @Override + public String toString() { + return "NUMBER"; + } + } + + final class BooleanFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o instanceof Boolean; + } + + @Override + public String toString() { + return "BOOLEAN"; + } + } + + final class StringFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o instanceof String; + } + + @Override + public String toString() { + return "STRING"; + } + } + + final class NullFilter implements TypeFilter { + @Override + public boolean test(final Object o) { + return o == null; + } + + @Override + public String toString() { + return "NULL"; + } + } +} diff --git a/fusion-json/src/test/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactoryTest.java b/fusion-json/src/test/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactoryTest.java new file mode 100644 index 00000000..e8e0b4ac --- /dev/null +++ b/fusion-json/src/test/java/io/yupiik/fusion/json/schema/validation/JsonSchemaValidatorFactoryTest.java @@ -0,0 +1,767 @@ +/* + * Copyright (c) 2022 - present - Yupiik SAS - https://www.yupiik.com + * 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 + * + * http://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.yupiik.fusion.json.schema.validation; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +class JsonSchemaValidatorFactoryTest { + private static JsonSchemaValidatorFactory FACTORY; + private static final Factory INSTANCE_FACTORY = new Factory(); + + @BeforeAll + static void init() { + FACTORY = new JsonSchemaValidatorFactory(); + } + + @AfterAll + static void destroy() { + FACTORY.close(); + } + + @Test + void patternPropertiesNested() { + try (final var validator = FACTORY.newInstance(Map.of( + "type", "object", + "properties", Map.of( + "nested", Map.of( + "type", "object", + "patternProperties", Map.of( + "[0-9]+", Map.of("type", "number"))))))) { + + assertTrue(validator.apply(Map.of()).isSuccess()); + assertTrue(validator.apply(Map.of("nested", Map.of("1", 1))).isSuccess()); + + final var result = validator.apply(Map.of("nested", Map.of("1", "test"))); + assertFalse(result.isSuccess(), result::toString); + } + } + + + @Test + void rootRequired() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .build()) + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .build()) + .build()) + .add("required", INSTANCE_FACTORY.createArrayBuilder().add("name").build()) + .build()); + + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().addNull("name").build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/", error.field()); + assertEquals("name is required and is not present", error.message()); + + validator.close(); + } + + @Test + void rootType() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .build()) + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .build()) + .build()) + .build()); + + { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + } + { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().addNull("name").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + } + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", 5).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/name", error.field()); + assertEquals("Expected [NULL, STRING] but got java.lang.Integer", error.message()); + + validator.close(); + } + + @Test + void typeArray() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", INSTANCE_FACTORY.createArrayBuilder() + .add("string") + .add("number")) + .build()) + .build()) + .build()); + + { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + } + { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().addNull("name").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + } + { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", 5).build()); + assertTrue(success.isSuccess(), success.errors()::toString); + } + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", true).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/name", error.field()); + assertEquals("Expected [NULL, NUMBER, STRING] but got java.lang.Boolean", error.message()); + + validator.close(); + } + + @Test + void nestedType() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("person", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .build()) + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .build()) + .build()) + .build()) + .build()) + .build()); + + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("person", INSTANCE_FACTORY.createObjectBuilder() + .add("name", "ok") + .build()) + .build()); + assertTrue(success.isSuccess(), success.errors()::toString); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("person", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder().build()) + .build()) + .build()); + assertFalse(failure.isSuccess(), failure::toString); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/person/name", error.field()); + assertEquals("Expected [NULL, STRING] but got java.util.HashMap", error.message()); + + validator.close(); + } + + @Test + void enumValues() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .add("enum", INSTANCE_FACTORY.createArrayBuilder().add("a").add("b").build()) + .build()) + .build()) + .build()); + + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "a").build()); + assertTrue(success.isSuccess(), success.errors()::toString); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", 5).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(2, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/name", error.field()); + assertEquals("Expected [NULL, STRING] but got java.lang.Integer", error.message()); + + validator.close(); + } + + @Test + void multipleOf() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("multipleOf", 5) + .build()) + .build()) + .build()); + + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 5).build()); + assertTrue(success.isSuccess(), success.errors()::toString); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 6).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/age", error.field()); + assertEquals("6.0 is not a multiple of 5.0", error.message()); + + validator.close(); + } + + @Test + void minimum() { + { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("minimum", 5) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 5).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 6).build()).isSuccess()); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 2).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/age", error.field()); + assertEquals("2.0 is less than 5.0", error.message()); + + validator.close(); + } + { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("minimum", -1) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 1).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 0).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", -1).build()).isSuccess()); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", -2).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/age", error.field()); + assertEquals("-2.0 is less than -1.0", error.message()); + + validator.close(); + } + } + + @Test + void maximum() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("maximum", 5) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 5).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 4).build()).isSuccess()); + + final var failure = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 6).build()); + assertFalse(failure.isSuccess()); + final var errors = failure.errors(); + assertEquals(1, errors.size()); + final var error = errors.iterator().next(); + assertEquals("/age", error.field()); + assertEquals("6.0 is more than 5.0", error.message()); + + validator.close(); + } + + @Test + void exclusiveMinimum() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("exclusiveMinimum", 5) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 6).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 5).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 4).build()).isSuccess()); + validator.close(); + } + + @Test + void exclusiveMaximum() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("exclusiveMaximum", 5) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 4).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 5).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 6).build()).isSuccess()); + + validator.close(); + } + + @Test + void integerType() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "integer") + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 30).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", -10).build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", BigInteger.valueOf(50)).build()).isSuccess()); + // check no decimal numbers allowed + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", 30.3f).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", -7.4d).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("age", BigDecimal.valueOf(50.35613d)).build()).isSuccess()); + + validator.close(); + } + + @Test + void minLength() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .add("minLength", 2) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "okk").build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "-").build()).isSuccess()); + + validator.close(); + } + + @Test + void maxLength() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .add("maxLength", 2) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "-").build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "fail").build()).isSuccess()); + + validator.close(); + } + + @Test + void pattern() { + try (final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + .add("pattern", "[a-z]") + .build()) + .build()) + .build())) { + final var success = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "ok").build()); + assertTrue(success.isSuccess(), success::toString); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "-").build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", "0").build()).isSuccess()); + } + } + + @TestFactory + Stream patternFull() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("name", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string") + // from https://json-schema.org/understanding-json-schema/reference/regular_expressions + .add("pattern", "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$") + .build()) + .build()) + .build()); + final BiConsumer test = (value, result) -> { + final var res = validator.apply(INSTANCE_FACTORY.createObjectBuilder().add("name", value).build()); + assertEquals(result, res.isSuccess(), () -> "Error: " + res.errors() + "\nValue: " + value); + }; + return Stream.of( + dynamicTest("patternFull_ok[555-1212]", () -> test.accept("555-1212", true)), + dynamicTest("patternFull_ok_[(888)555-1212]", () -> test.accept("(888)555-1212", true)), + dynamicTest("patternFull_ok_[(888)555-1212 ext. 532]", () -> test.accept("(888)555-1212 ext. 532", false)), + dynamicTest("patternFull_ok_[(800)FLOWERS]", () -> test.accept("(800)FLOWERS", false))) + .onClose(validator::close); + } + + @Test + void itemsObject() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("items", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string")) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add("1")).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(1)).build()).isSuccess()); + + validator.close(); + } + + @Test + void itemsArray() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("items", INSTANCE_FACTORY.createArrayBuilder().add(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "string")) + .build()).build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add("1")).build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(1)).build()).isSuccess()); + + validator.close(); + } + + @Test + void itemsValidatesObject() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("items", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("age", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "number") + .add("maximum", 2) + .build()) + .build())) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder() + .add(INSTANCE_FACTORY.createObjectBuilder().add("age", 2))) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder() + .add(INSTANCE_FACTORY.createArrayBuilder().build())) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder() + .add(INSTANCE_FACTORY.createObjectBuilder().add("age", 3))) + .build()).isSuccess()); + + validator.close(); + } + + @Test + void maxItems() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("maxItems", 1) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2)) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2).add(3)) + .build()).isSuccess()); + + validator.close(); + } + + @Test + void minItems() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("minItems", 1) + .build()) + .build()) + .build()); + + final var result = validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2)) + .build()); + assertTrue(result.isSuccess(), result::toString); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder()) + .build()).isSuccess()); + + validator.close(); + } + + @Test + void uniqueItems() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("uniqueItems", true) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2)) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2).add(2)) + .build()).isSuccess()); + + validator.close(); + } + + @Test + void containsItems() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("properties", INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createObjectBuilder() + .add("type", "array") + .add("contains", INSTANCE_FACTORY.createObjectBuilder().add("type", "number")) + .build()) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2)) + .build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add(2).add("test")) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("names", INSTANCE_FACTORY.createArrayBuilder().add("test")) + .build()).isSuccess()); + + validator.close(); + } + + @Test + void maxProperties() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("maxProperties", 1) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("name", "test") + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("name", "test") + .add("name2", "test") + .build()).isSuccess()); + + validator.close(); + } + + @Test + void minProperties() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("minProperties", 1) + .build()); + + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder().build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("name", "test") + .build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("name", "test") + .add("name2", "test") + .build()).isSuccess()); + + validator.close(); + } + + @Test + void patternProperties() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("patternProperties", INSTANCE_FACTORY.createObjectBuilder() + .add("[0-9]+", INSTANCE_FACTORY.createObjectBuilder().add("type", "number")) + .build()) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("1", 1) + .build()).isSuccess()); + assertFalse(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("1", "test") + .build()).isSuccess()); + + validator.close(); + } + + @Test + void additionalProperties() { + final var validator = FACTORY.newInstance(INSTANCE_FACTORY.createObjectBuilder() + .add("type", "object") + .add("additionalProperties", INSTANCE_FACTORY.createObjectBuilder().add("type", "number")) + .build()); + + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder().build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("1", 1) + .build()).isSuccess()); + assertTrue(validator.apply(INSTANCE_FACTORY.createObjectBuilder() + .add("1", "test") + .build()).isSuccess()); + + validator.close(); + } + + private static class Factory { // bridge to not rewrite all johnzon's tests + private MapBuilder createObjectBuilder() { + return new MapBuilder(); + } + + private ArrayBuilder createArrayBuilder() { + return new ArrayBuilder(); + } + } + + private static class ArrayBuilder { + private final List list = new ArrayList<>(); + + private ArrayBuilder add(final Object value) { + list.add(value instanceof MapBuilder mb ? mb.build() : (value instanceof ArrayBuilder a ? a.build() : value)); + return this; + } + + private List build() { + return list; + } + } + + private static class MapBuilder { + private final Map map = new HashMap<>(); + + private MapBuilder add(final String key, final Object value) { + map.put(key, value instanceof MapBuilder mb ? mb.build() : (value instanceof ArrayBuilder a ? a.build() : value)); + return this; + } + + public MapBuilder addNull(final String name) { + return add(name, null); + } + + private Map build() { + return map; + } + } +}