diff --git a/core/pom.xml b/core/pom.xml index 469017fd..2f2778e0 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -131,13 +131,12 @@ - **/tests/draft2020-12/maxItems.json **/tests/draft2020-12/maxLength.json **/tests/draft2020-12/maximum.json **/tests/draft2020-12/maxProperties.json + **/tests/draft2020-12/minContains.json **/tests/draft2020-12/minItems.json **/tests/draft2020-12/minLength.json **/tests/draft2020-12/minimum.json diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectedByKeywordType.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectedByKeywordType.java new file mode 100644 index 00000000..ab528879 --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectedByKeywordType.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.keyword.type; + +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toList; + +import io.github.sebastiantoepfer.jsonschema.JsonSchema; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiFunction; + +public class AffectedByKeywordType implements KeywordType { + + private final String name; + private final List affectedBy; + private final BiFunction, JsonSchema, Keyword> keywordCreator; + + public AffectedByKeywordType( + final String name, + final List affectedBy, + final BiFunction, JsonSchema, Keyword> keywordCreator + ) { + this.name = Objects.requireNonNull(name); + this.affectedBy = List.copyOf(affectedBy); + this.keywordCreator = Objects.requireNonNull(keywordCreator); + } + + @Override + public String name() { + return name; + } + + @Override + public Keyword createKeyword(final JsonSchema schema) { + return new AffectedByKeyword(schema, name, affectedBy, keywordCreator); + } + + static final class AffectedByKeyword extends KeywordRelationship { + + private final JsonSchema schema; + private final List affectedBy; + private final BiFunction, JsonSchema, Keyword> keywordCreator; + + public AffectedByKeyword( + final JsonSchema schema, + final String name, + final List affectedBy, + final BiFunction, JsonSchema, Keyword> keywordCreator + ) { + super(name); + this.schema = Objects.requireNonNull(schema); + this.affectedBy = List.copyOf(affectedBy); + this.keywordCreator = Objects.requireNonNull(keywordCreator); + } + + @Override + protected Keyword delegate() { + return affectedBy + .stream() + .map(schema::keywordByName) + .flatMap(Optional::stream) + .collect(collectingAndThen(toList(), k -> keywordCreator.apply(k, schema))); + } + } +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectsKeywordType.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectsKeywordType.java new file mode 100644 index 00000000..33a27e87 --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/AffectsKeywordType.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.keyword.type; + +import io.github.sebastiantoepfer.jsonschema.JsonSchema; +import io.github.sebastiantoepfer.jsonschema.keyword.Annotation; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; +import io.github.sebastiantoepfer.jsonschema.keyword.StaticAnnotation; +import jakarta.json.JsonValue; +import java.util.Objects; +import java.util.function.BiFunction; + +public class AffectsKeywordType implements KeywordType { + + private final String name; + private final String affects; + private final BiFunction keywordCreator; + + public AffectsKeywordType( + final String name, + final String affects, + final BiFunction keywordCreator + ) { + this.name = name; + this.affects = affects; + this.keywordCreator = keywordCreator; + } + + @Override + public String name() { + return name; + } + + @Override + public Keyword createKeyword(final JsonSchema schema) { + return new AffectsKeyword(schema, name, affects, keywordCreator); + } + + static final class AffectsKeyword extends KeywordRelationship { + + private final JsonSchema schema; + private final String affects; + private final BiFunction keywordCreator; + + public AffectsKeyword( + final JsonSchema schema, + final String name, + final String affects, + final BiFunction keywordCreator + ) { + super(name); + this.schema = Objects.requireNonNull(schema); + this.affects = affects; + this.keywordCreator = Objects.requireNonNull(keywordCreator); + } + + @Override + protected Keyword delegate() { + return keywordCreator.apply( + schema + .keywordByName(affects) + .map(Keyword::asAnnotation) + .orElseGet(() -> new StaticAnnotation(affects, JsonValue.NULL)), + schema + ); + } + } +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/KeywordRelationship.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/KeywordRelationship.java new file mode 100644 index 00000000..de395e83 --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/keyword/type/KeywordRelationship.java @@ -0,0 +1,85 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.keyword.type; + +import io.github.sebastiantoepfer.ddd.common.Media; +import io.github.sebastiantoepfer.jsonschema.keyword.Annotation; +import io.github.sebastiantoepfer.jsonschema.keyword.Applicator; +import io.github.sebastiantoepfer.jsonschema.keyword.Assertion; +import io.github.sebastiantoepfer.jsonschema.keyword.Identifier; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.ReservedLocation; +import java.util.Collection; +import java.util.Objects; + +abstract class KeywordRelationship implements Keyword { + + private final String name; + + protected KeywordRelationship(final String name) { + this.name = Objects.requireNonNull(name); + } + + @Override + public final Collection categories() { + return delegate().categories(); + } + + @Override + public final boolean hasName(final String name) { + return Objects.equals(this.name, name); + } + + @Override + public final > T printOn(final T media) { + return delegate().printOn(media); + } + + @Override + public final Identifier asIdentifier() { + throw new UnsupportedOperationException(); + } + + @Override + public final Assertion asAssertion() { + return delegate().asAssertion(); + } + + @Override + public final Annotation asAnnotation() { + return delegate().asAnnotation(); + } + + @Override + public final Applicator asApplicator() { + return delegate().asApplicator(); + } + + @Override + public final ReservedLocation asReservedLocation() { + throw new UnsupportedOperationException(); + } + + protected abstract Keyword delegate(); +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ApplicatorVocabulary.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ApplicatorVocabulary.java index 0c5b7e6c..ba3a0076 100644 --- a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ApplicatorVocabulary.java +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ApplicatorVocabulary.java @@ -24,12 +24,14 @@ package io.github.sebastiantoepfer.jsonschema.core.vocab.applicator; import io.github.sebastiantoepfer.jsonschema.Vocabulary; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AffectedByKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.ArraySubSchemaKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.NamedJsonSchemaKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.SubSchemaKeywordType; import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; import io.github.sebastiantoepfer.jsonschema.vocabulary.spi.DefaultVocabulary; import java.net.URI; +import java.util.List; import java.util.Optional; /** @@ -51,7 +53,13 @@ public ApplicatorVocabulary() { new NamedJsonSchemaKeywordType(PatternPropertiesKeyword.NAME, PatternPropertiesKeyword::new), new SubSchemaKeywordType(ItemsKeyword.NAME, ItemsKeyword::new), new ArraySubSchemaKeywordType(PrefixItemsKeyword.NAME, PrefixItemsKeyword::new), - new SubSchemaKeywordType(ContainsKeyword.NAME, ContainsKeyword::new) + //normally affeced by minContains and maxContains, but only min has a direct effect! + new AffectedByKeywordType( + ContainsKeyword.NAME, + List.of("minContains"), + (a, schema) -> + new SubSchemaKeywordType(ContainsKeyword.NAME, s -> new ContainsKeyword(a, s)).createKeyword(schema) + ) ); } diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeyword.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeyword.java index dcb14df0..49636d27 100644 --- a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeyword.java +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeyword.java @@ -27,14 +27,16 @@ import io.github.sebastiantoepfer.ddd.common.Media; import io.github.sebastiantoepfer.jsonschema.InstanceType; -import io.github.sebastiantoepfer.jsonschema.JsonSubSchema; +import io.github.sebastiantoepfer.jsonschema.JsonSchema; import io.github.sebastiantoepfer.jsonschema.keyword.Annotation; import io.github.sebastiantoepfer.jsonschema.keyword.Applicator; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; import jakarta.json.JsonArray; import jakarta.json.JsonValue; import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.stream.Stream; /** * contains : Schema @@ -51,9 +53,11 @@ final class ContainsKeyword implements Applicator, Annotation { static final String NAME = "contains"; - private final JsonSubSchema contains; + private final JsonSchema contains; + private final List affectedBy; - public ContainsKeyword(final JsonSubSchema contains) { + public ContainsKeyword(final List affectedBy, final JsonSchema contains) { + this.affectedBy = List.copyOf(affectedBy); this.contains = Objects.requireNonNull(contains); } @@ -78,7 +82,7 @@ public boolean applyTo(final JsonValue instance) { } private boolean contains(final JsonArray array) { - return array.stream().anyMatch(contains.validator()::isValid); + return !affectedBy.isEmpty() || matchingValues(array).findAny().isPresent(); } @Override @@ -94,12 +98,16 @@ public JsonValue valueFor(final JsonValue value) { private JsonValue valueFor(final JsonArray values) { final JsonValue result; - final JsonArray matchingItems = values.stream().filter(contains.validator()::isValid).collect(toJsonArray()); - if (matchingItems.size() == values.size()) { + final JsonArray matchingItems = matchingValues(values).collect(toJsonArray()); + if (matchingItems.size() == values.size() && !values.isEmpty()) { result = JsonValue.TRUE; } else { result = matchingItems; } return result; } + + Stream matchingValues(final JsonArray values) { + return values.stream().filter(contains.validator()::isValid); + } } diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeyword.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeyword.java new file mode 100644 index 00000000..e9e51768 --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeyword.java @@ -0,0 +1,86 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; + +import io.github.sebastiantoepfer.ddd.common.Media; +import io.github.sebastiantoepfer.jsonschema.InstanceType; +import io.github.sebastiantoepfer.jsonschema.keyword.Annotation; +import io.github.sebastiantoepfer.jsonschema.keyword.Assertion; +import jakarta.json.JsonArray; +import jakarta.json.JsonValue; +import java.math.BigInteger; +import java.util.Objects; + +/** + * minProperties : Integer + * An object instance is valid if its number of properties is less than, or equal to, the value of this keyword.
+ * keyword.
+ *
+ *
    + *
  • assertion
  • + *
+ * + * source: https://www.learnjsonschema.com/2020-12/validation/maxcontains/ + * spec: https://json-schema.org/draft/2020-12/json-schema-validation.html#section-6.4.4 + */ +final class MaxContainsKeyword implements Assertion { + + static final String NAME = "maxContains"; + private final Annotation affects; + private final BigInteger maxContains; + + public MaxContainsKeyword(final Annotation affects, final BigInteger maxContains) { + this.affects = Objects.requireNonNull(affects); + this.maxContains = Objects.requireNonNull(maxContains); + } + + @Override + public boolean hasName(final String name) { + return Objects.equals(NAME, name); + } + + @Override + public > T printOn(final T media) { + return media.withValue(NAME, maxContains); + } + + @Override + public boolean isValidFor(final JsonValue instance) { + return ( + !InstanceType.ARRAY.isInstance(instance) || isValidFor(affects.valueFor(instance), instance.asJsonArray()) + ); + } + + private boolean isValidFor(final JsonValue containing, final JsonArray values) { + final boolean result; + if (JsonValue.NULL.equals(containing)) { + result = true; + } else if (JsonValue.TRUE.equals(containing)) { + result = values.size() <= maxContains.intValue(); + } else { + result = containing.asJsonArray().size() <= maxContains.intValue(); + } + return result; + } +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordType.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordType.java deleted file mode 100644 index 9b61deed..00000000 --- a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordType.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * The MIT License - * - * Copyright 2024 sebastian. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; - -import io.github.sebastiantoepfer.ddd.common.Media; -import io.github.sebastiantoepfer.jsonschema.InstanceType; -import io.github.sebastiantoepfer.jsonschema.JsonSchema; -import io.github.sebastiantoepfer.jsonschema.core.keyword.type.IntegerKeywordType; -import io.github.sebastiantoepfer.jsonschema.keyword.Assertion; -import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; -import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; -import jakarta.json.JsonArray; -import jakarta.json.JsonValue; -import jakarta.json.spi.JsonProvider; -import java.math.BigInteger; -import java.util.Objects; - -/** - * minProperties : Integer - * An object instance is valid if its number of properties is less than, or equal to, the value of this keyword.
- * keyword.
- *
- *
    - *
  • assertion
  • - *
- * - * source: https://www.learnjsonschema.com/2020-12/validation/maxcontains/ - * spec: https://json-schema.org/draft/2020-12/json-schema-validation.html#section-6.4.4 - */ -final class MaxContainsKeywordType implements KeywordType { - - private final JsonProvider jsonContext; - - public MaxContainsKeywordType(final JsonProvider jsonContext) { - this.jsonContext = jsonContext; - } - - @Override - public String name() { - return "maxContains"; - } - - @Override - public Keyword createKeyword(final JsonSchema schema) { - return new IntegerKeywordType( - jsonContext, - name(), - value -> new MaxContainsKeyword(schema, value) - ).createKeyword(schema); - } - - private final class MaxContainsKeyword implements Assertion { - - private final JsonSchema owner; - private final BigInteger maxContains; - - public MaxContainsKeyword(final JsonSchema owner, final BigInteger maxContains) { - this.owner = Objects.requireNonNull(owner); - this.maxContains = Objects.requireNonNull(maxContains); - } - - @Override - public boolean hasName(final String name) { - return Objects.equals(name(), name); - } - - @Override - public > T printOn(final T media) { - return media.withValue(name(), maxContains); - } - - @Override - public boolean isValidFor(final JsonValue instance) { - return ( - !InstanceType.ARRAY.isInstance(instance) || - owner - .keywordByName("contains") - .map(Keyword::asAnnotation) - .map(annotation -> annotation.valueFor(instance)) - .map(contains -> isValidFor(contains, instance.asJsonArray())) - .orElse(true) - ); - } - - private boolean isValidFor(final JsonValue containing, final JsonArray values) { - final boolean result; - if (JsonValue.TRUE.equals(containing)) { - result = values.size() <= maxContains.intValue(); - } else { - result = containing.asJsonArray().size() <= maxContains.intValue(); - } - return result; - } - } -} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeyword.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeyword.java new file mode 100644 index 00000000..4b6ef50b --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeyword.java @@ -0,0 +1,86 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; + +import io.github.sebastiantoepfer.ddd.common.Media; +import io.github.sebastiantoepfer.jsonschema.InstanceType; +import io.github.sebastiantoepfer.jsonschema.keyword.Annotation; +import io.github.sebastiantoepfer.jsonschema.keyword.Assertion; +import jakarta.json.JsonArray; +import jakarta.json.JsonValue; +import java.math.BigInteger; +import java.util.Objects; + +/** + * minContains : Integer + * The number of times that the contains keyword (if set) successfully validates against the instance must be + * greater than or equal to the given integer.
+ *
+ *
    + *
  • assertion
  • + *
+ * + * source: https://www.learnjsonschema.com/2020-12/validation/mincontains/ + * spec: https://json-schema.org/draft/2020-12/json-schema-validation.html#section-6.4.5 + */ +final class MinContainsKeyword implements Assertion { + + static final String NAME = "minContains"; + private final Annotation affects; + private final BigInteger minContains; + + public MinContainsKeyword(final Annotation affects, final BigInteger minContains) { + this.affects = Objects.requireNonNull(affects); + this.minContains = Objects.requireNonNull(minContains); + } + + @Override + public boolean hasName(final String name) { + return Objects.equals(NAME, name); + } + + @Override + public > T printOn(final T media) { + return media.withValue(NAME, minContains); + } + + @Override + public boolean isValidFor(final JsonValue instance) { + return ( + !InstanceType.ARRAY.isInstance(instance) || isValidFor(affects.valueFor(instance), instance.asJsonArray()) + ); + } + + private boolean isValidFor(final JsonValue containing, final JsonArray values) { + final boolean result; + if (JsonValue.NULL.equals(containing)) { + result = true; + } else if (JsonValue.TRUE.equals(containing)) { + result = values.size() >= minContains.intValue(); + } else { + result = containing.asJsonArray().size() >= minContains.intValue(); + } + return result; + } +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java index d8914192..f7d3ea36 100644 --- a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java @@ -24,6 +24,7 @@ package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; import io.github.sebastiantoepfer.jsonschema.Vocabulary; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AffectsKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AnyKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.ArrayKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.BooleanKeywordType; @@ -62,6 +63,26 @@ public ValidationVocabulary(final JsonProvider jsonContext) { new StringArrayKeywordType(jsonContext, RequiredKeyword.NAME, RequiredKeyword::new), new IntegerKeywordType(jsonContext, MaxItemsKeyword.NAME, MaxItemsKeyword::new), new IntegerKeywordType(jsonContext, MinItemsKeyword.NAME, MinItemsKeyword::new), + new AffectsKeywordType( + MaxContainsKeyword.NAME, + "contains", + (affects, schema) -> + new IntegerKeywordType( + JsonProvider.provider(), + MaxContainsKeyword.NAME, + value -> new MaxContainsKeyword(affects, value) + ).createKeyword(schema) + ), + new AffectsKeywordType( + MinContainsKeyword.NAME, + "contains", + (a, s) -> + new IntegerKeywordType( + JsonProvider.provider(), + MinContainsKeyword.NAME, + value -> new MinContainsKeyword(a, value) + ).createKeyword(s) + ), new BooleanKeywordType(jsonContext, UniqueItemsKeyword.NAME, UniqueItemsKeyword::new) ); } diff --git a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeywordTest.java b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeywordTest.java index f307b6c8..e6ddbad0 100644 --- a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeywordTest.java +++ b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/applicator/ContainsKeywordTest.java @@ -31,11 +31,13 @@ import io.github.sebastiantoepfer.ddd.media.core.HashMapMedia; import io.github.sebastiantoepfer.jsonschema.core.DefaultJsonSchemaFactory; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AffectedByKeywordType; import io.github.sebastiantoepfer.jsonschema.core.keyword.type.SubSchemaKeywordType; import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonValue; +import java.util.List; import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; @@ -54,8 +56,9 @@ void should_be_printable() { @Test void should_know_his_name() { - final Keyword enumKeyword = createKeywordFrom( - Json.createObjectBuilder().add("contains", Json.createObjectBuilder().add("type", "number")).build() + final Keyword enumKeyword = new ContainsKeyword( + List.of(), + new DefaultJsonSchemaFactory().create(JsonValue.TRUE) ); assertThat(enumKeyword.hasName("contains"), is(true)); @@ -84,6 +87,33 @@ void should_apply_for_non_array() { ); } + @Test + void should_apply_to_empty_array_if_min_andor_max_provided() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "number")) + .add("minContains", 0) + .build() + ) + .asApplicator() + .applyTo(JsonValue.EMPTY_JSON_ARRAY), + is(true) + ); + } + + @Test + void should_not_apply_to_empty_array_if_non_min_andor_max_is_provided() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder().add("contains", Json.createObjectBuilder().add("type", "number")).build() + ) + .asApplicator() + .applyTo(JsonValue.EMPTY_JSON_ARRAY), + is(false) + ); + } + @Test void should_apply_if_one_item_applies() { assertThat( @@ -155,6 +185,19 @@ void should_return_empty_array_if_no_item_applies() { ); } + @Test + void should_return_empty_array_for_empty_array() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder().add("contains", Json.createObjectBuilder().add("type", "number")).build() + ) + .asAnnotation() + .valueFor(JsonValue.EMPTY_JSON_ARRAY) + .asJsonArray(), + is(empty()) + ); + } + @Test void should_return_matching_items() { assertThat( @@ -189,8 +232,10 @@ void should_return_true_if_all_item_applies() { } private static Keyword createKeywordFrom(final JsonObject json) { - return new SubSchemaKeywordType("contains", ContainsKeyword::new).createKeyword( - new DefaultJsonSchemaFactory().create(json) - ); + return new AffectedByKeywordType( + "contains", + List.of("minContains", "maxContains"), + (a, schema) -> new SubSchemaKeywordType("contains", s -> new ContainsKeyword(a, s)).createKeyword(schema) + ).createKeyword(new DefaultJsonSchemaFactory().create(json)); } } diff --git a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTypeTest.java b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTest.java similarity index 89% rename from core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTypeTest.java rename to core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTest.java index 01882a5c..2b7a3861 100644 --- a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTypeTest.java +++ b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxContainsKeywordTest.java @@ -29,7 +29,10 @@ import io.github.sebastiantoepfer.ddd.media.core.HashMapMedia; import io.github.sebastiantoepfer.jsonschema.core.DefaultJsonSchemaFactory; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AffectsKeywordType; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.IntegerKeywordType; import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.StaticAnnotation; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonValue; @@ -38,12 +41,11 @@ import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; -class MaxContainsKeywordTypeTest { +class MaxContainsKeywordTest { @Test void should_know_his_name() { - final Keyword enumKeyword = createKeywordFrom(Json.createObjectBuilder().add("maxContains", 2).build()); - + final Keyword enumKeyword = new MaxContainsKeyword(new StaticAnnotation("", JsonValue.NULL), BigInteger.ONE); assertThat(enumKeyword.hasName("maxContains"), is(true)); assertThat(enumKeyword.hasName("test"), is(false)); } @@ -182,8 +184,15 @@ void should_be_invalid_if_contains_applies_to_all_and_more_items_in_array() { } private static Keyword createKeywordFrom(final JsonObject json) { - return new MaxContainsKeywordType(JsonProvider.provider()).createKeyword( - new DefaultJsonSchemaFactory().create(json) - ); + return new AffectsKeywordType( + "maxContains", + "contains", + (a, s) -> + new IntegerKeywordType( + JsonProvider.provider(), + "maxContains", + value -> new MaxContainsKeyword(a, value) + ).createKeyword(s) + ).createKeyword(new DefaultJsonSchemaFactory().create(json)); } } diff --git a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeywordTest.java b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeywordTest.java new file mode 100644 index 00000000..682bcab6 --- /dev/null +++ b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MinContainsKeywordTest.java @@ -0,0 +1,199 @@ +/* + * The MIT License + * + * Copyright 2024 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; + +import io.github.sebastiantoepfer.ddd.media.core.HashMapMedia; +import io.github.sebastiantoepfer.jsonschema.core.DefaultJsonSchemaFactory; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.AffectsKeywordType; +import io.github.sebastiantoepfer.jsonschema.core.keyword.type.IntegerKeywordType; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.StaticAnnotation; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import jakarta.json.spi.JsonProvider; +import java.math.BigInteger; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; + +class MinContainsKeywordTest { + + @Test + void should_know_his_name() { + final Keyword enumKeyword = new MinContainsKeyword(new StaticAnnotation("", JsonValue.NULL), BigInteger.ONE); + + assertThat(enumKeyword.hasName("minContains"), is(true)); + assertThat(enumKeyword.hasName("test"), is(false)); + } + + @Test + void should_be_printable() { + assertThat( + createKeywordFrom(Json.createObjectBuilder().add("minContains", 2).build()).printOn(new HashMapMedia()), + (Matcher) hasEntry(is("minContains"), is(BigInteger.valueOf(2))) + ); + } + + @Test + void should_be_valid_for_non_arrays() { + assertThat( + createKeywordFrom(Json.createObjectBuilder().add("minContains", 2).build()) + .asAssertion() + .isValidFor(JsonValue.EMPTY_JSON_OBJECT), + is(true) + ); + } + + @Test + void should_be_valid_if_no_contains_is_present() { + assertThat( + createKeywordFrom(Json.createObjectBuilder().add("minContains", 2).build()) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").build()), + is(true) + ); + } + + @Test + void should_be_valid_if_contains_applies_to_exact_count() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").add("bar").add(1).build()), + is(true) + ); + } + + @Test + void should_be_valid_if_contains_applies_to_more_items() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").add(2).add(3).add("bar").add("baz").build()), + is(true) + ); + } + + @Test + void should_be_valid_for_empty_arrays() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("const", 1)) + .add("minContains", 0) + .build() + ) + .asAssertion() + .isValidFor(JsonValue.EMPTY_JSON_ARRAY), + is(true) + ); + } + + @Test + void should_be_invalid_if_contains_applies_to_less_items() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").add(1).build()), + is(false) + ); + } + + @Test + void should_be_valid_if_contains_applies_to_all_and_more_items_in_array() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").add("bar").add("baz").build()), + is(true) + ); + } + + @Test + void should_be_valid_if_contains_applies_to_all_and_exact_items_count_in_array() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").add("bar").build()), + is(true) + ); + } + + @Test + void should_be_invalid_if_contains_applies_to_all_and_less_items_in_array() { + assertThat( + createKeywordFrom( + Json.createObjectBuilder() + .add("contains", Json.createObjectBuilder().add("type", "string")) + .add("minContains", 2) + .build() + ) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("foo").build()), + is(false) + ); + } + + private static Keyword createKeywordFrom(final JsonObject json) { + return new AffectsKeywordType( + "minContains", + "contains", + (a, s) -> + new IntegerKeywordType( + JsonProvider.provider(), + "minContains", + value -> new MinContainsKeyword(a, value) + ).createKeyword(s) + ).createKeyword(new DefaultJsonSchemaFactory().create(json)); + } +}