From f99034e278816eacecf053de3f1166a414d73e5d Mon Sep 17 00:00:00 2001 From: Bence Eros Date: Fri, 16 Jul 2021 12:52:39 +0200 Subject: [PATCH] Lenient validation mode (#429) ## Summary Adds lenient validation mode for primitive values. In lenient mode, strings are accepted as numbers, booleans or nulls, if they are parseable. By default, lenient mode is turned off. ## Details Adding `PrimitiveValidationStrategy` enum as a feature switch for the lenient validation mode. Also adding appropriate builder method for `Validator` for setting it up. Changing signature of `ValidatingVisitor#passesTypeCheck()` so that instead of returning a boolean, it accepts a callback, which is either called or not, depending on if the type-check passed or not. Furthermore, if the validator runs in LENIENT mode, the passesTypeCheck() attempts to perform a conversion to the expected value, if that is possible. If it succeeds, then the `onPass` callback will be invoked with the converted value as the parameter. The following ValidatingVisitor subclasses are updated to call the new `passesTypeCheck()` method correctly: * `ArraySchemaValidatingVisitor` * `StringSchemaValidatingVisitor` * `NumberSchemaValidatingVisitor` * `ObjectSchemaValidatingVisitor` The string-to-other-primitive conversion is performed by the `StringToValueConverter` class. The methods of this class are copied from `org.json.JSONObject`. Although it would be possible to call `JSONObject#stringToValue()` from ValidatingVisitor#ifPassesTypeCheck()`, we can not do it, because `JSONObject#stringToValue()` does not exist in the android flavor of the org.json package, therefore on android it would throw a `NoSuchMethodError`. For that reason, these methods are copied to the everit-org/json-schema library, to make sure that they exist at run-time. Furthermore, this implementation accepts [all 22 boolean literals of YAML](https://yaml.org/type/bool.html) as valid booleans. Credits go to @nfrankel for pointing out this extensive boolean support in a recent LinkedIn post :) --- README.md | 57 +++++ .../schema/ArraySchemaValidatingVisitor.java | 21 +- .../schema/NumberSchemaValidatingVisitor.java | 13 +- .../schema/ObjectSchemaValidatingVisitor.java | 37 ++- .../schema/PrimitiveValidationStrategy.java | 5 + .../schema/StringSchemaValidatingVisitor.java | 29 ++- .../json/schema/StringToValueConverter.java | 152 ++++++++++++ .../everit/json/schema/ValidatingVisitor.java | 48 +++- .../org/everit/json/schema/Validator.java | 21 +- .../json/schema/ValidatingVisitorTest.java | 228 ++++++++++++------ .../org/everit/json/schema/IssueTest.java | 8 +- .../json/schema/issues/issue428/schema.json | 25 ++ .../schema/issues/issue428/subject-valid.json | 30 +++ .../issues/issue428/validator-config.json | 3 + 14 files changed, 535 insertions(+), 142 deletions(-) create mode 100644 core/src/main/java/org/everit/json/schema/PrimitiveValidationStrategy.java create mode 100644 core/src/main/java/org/everit/json/schema/StringToValueConverter.java create mode 100644 tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/schema.json create mode 100644 tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/subject-valid.json create mode 100644 tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/validator-config.json diff --git a/README.md b/README.md index 14572c95e..cd9a4f81b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ * [JSON report of the failures](#json-report-of-the-failures) * [ValidationListeners - Tracking the validation process](#validationlisteners---tracking-the-validation-process) * [Early failure mode](#early-failure-mode) +* [Lenient mode](#lenient-mode) * [Default values](#default-values) * [RegExp implementations](#regexp-implementations) * [readOnly and writeOnly context](#readonly-and-writeonly-context) @@ -276,6 +277,62 @@ validator.performValidation(schema, input); _Note: the `Validator` class is immutable and thread-safe, so you don't have to create a new one for each validation, it is enough to configure it only once._ +## Lenient mode + +In some cases, when validating numbers or booleans, it makes sense to accept string values that are parseable as such primitives, because +any successive processing will also automatically parse these literals into proper numeric and logical values. Also, non-string primitive values are trivial to convert to strings, so why not to permit any json primitives as strings? + +For example, let's take this schema: + +```json +{ + "properties": { + "booleanProp": { + "type": "boolean" + }, + "integerProp": { + "type": "integer" + }, + "nullProp": { + "type": "null" + }, + "numberProp": { + "type": "number" + }, + "stringProp": { + "type": "string" + } + } +} +``` + +The following JSON document fails to validate, although all of the strings could easily be converted into appropriate values: + +```json +{ + "numberProp": "12.34", + "integerProp": "12", + "booleanProp": "true", + "nullProp": "null", + "stringProp": 12.34 +} +``` + +In this case, if you want the above instance to pass the validation against the schema, you need to use the lenient primitive validation configuration turned on. Example: + + +```java +import org.everit.json.schema.*; +... +Validator validator = Validator.builder() + .primitiveValidationStrategry(PrimitiveValidationStrategy.LENIENT) + .build(); +validator.performValidation(schema, input); +``` + +_Note: in lenient parsing mode, [all 22 possible boolean literals](https://yaml.org/type/bool.html) will be accepted as logical values._ + + ## Default values diff --git a/core/src/main/java/org/everit/json/schema/ArraySchemaValidatingVisitor.java b/core/src/main/java/org/everit/json/schema/ArraySchemaValidatingVisitor.java index 82848dc24..4c81541c5 100644 --- a/core/src/main/java/org/everit/json/schema/ArraySchemaValidatingVisitor.java +++ b/core/src/main/java/org/everit/json/schema/ArraySchemaValidatingVisitor.java @@ -14,8 +14,6 @@ class ArraySchemaValidatingVisitor extends Visitor { - private final Object subject; - private final ValidatingVisitor owner; private JSONArray arraySubject; @@ -24,18 +22,19 @@ class ArraySchemaValidatingVisitor extends Visitor { private int subjectLength; - public ArraySchemaValidatingVisitor(Object subject, ValidatingVisitor owner) { - this.subject = subject; + public ArraySchemaValidatingVisitor(ValidatingVisitor owner) { this.owner = requireNonNull(owner, "owner cannot be null"); } - @Override void visitArraySchema(ArraySchema arraySchema) { - if (owner.passesTypeCheck(JSONArray.class, arraySchema.requiresArray(), arraySchema.isNullable())) { - this.arraySubject = (JSONArray) subject; - this.subjectLength = arraySubject.length(); - this.arraySchema = arraySchema; - super.visitArraySchema(arraySchema); - } + @Override + void visitArraySchema(ArraySchema arraySchema) { + owner.ifPassesTypeCheck(JSONArray.class, arraySchema.requiresArray(), arraySchema.isNullable(), + arraySubject -> { + this.arraySubject = arraySubject; + this.subjectLength = arraySubject.length(); + this.arraySchema = arraySchema; + super.visitArraySchema(arraySchema); + }); } @Override void visitMinItems(Integer minItems) { diff --git a/core/src/main/java/org/everit/json/schema/NumberSchemaValidatingVisitor.java b/core/src/main/java/org/everit/json/schema/NumberSchemaValidatingVisitor.java index 161ad870a..cbd3f6f85 100644 --- a/core/src/main/java/org/everit/json/schema/NumberSchemaValidatingVisitor.java +++ b/core/src/main/java/org/everit/json/schema/NumberSchemaValidatingVisitor.java @@ -25,11 +25,14 @@ class NumberSchemaValidatingVisitor extends Visitor { @Override void visitNumberSchema(NumberSchema numberSchema) { - Class expectedType = numberSchema.requiresInteger() ? Integer.class : Number.class; - if (owner.passesTypeCheck(expectedType, numberSchema.requiresInteger() || numberSchema.isRequiresNumber(), numberSchema.isNullable())) { - this.numberSubject = ((Number) subject); - super.visitNumberSchema(numberSchema); - } + Class expectedType = numberSchema.requiresInteger() ? Integer.class : Number.class; + boolean schemaRequiresType = numberSchema.requiresInteger() || numberSchema.isRequiresNumber(); + owner.ifPassesTypeCheck(expectedType, Number.class::cast, schemaRequiresType, + numberSchema.isNullable(), + numberSubject -> { + this.numberSubject = numberSubject; + super.visitNumberSchema(numberSchema); + }); } @Override diff --git a/core/src/main/java/org/everit/json/schema/ObjectSchemaValidatingVisitor.java b/core/src/main/java/org/everit/json/schema/ObjectSchemaValidatingVisitor.java index 5f8befdd2..a1bc605d2 100644 --- a/core/src/main/java/org/everit/json/schema/ObjectSchemaValidatingVisitor.java +++ b/core/src/main/java/org/everit/json/schema/ObjectSchemaValidatingVisitor.java @@ -14,8 +14,6 @@ class ObjectSchemaValidatingVisitor extends Visitor { - private final Object subject; - private JSONObject objSubject; private ObjectSchema schema; @@ -24,26 +22,27 @@ class ObjectSchemaValidatingVisitor extends Visitor { private final ValidatingVisitor owner; - public ObjectSchemaValidatingVisitor(Object subject, ValidatingVisitor owner) { - this.subject = requireNonNull(subject, "subject cannot be null"); + public ObjectSchemaValidatingVisitor(ValidatingVisitor owner) { this.owner = requireNonNull(owner, "owner cannot be null"); } - @Override void visitObjectSchema(ObjectSchema objectSchema) { - if (owner.passesTypeCheck(JSONObject.class, objectSchema.requiresObject(), objectSchema.isNullable())) { - objSubject = (JSONObject) subject; - objectSize = objSubject.length(); - this.schema = objectSchema; - Object failureState = owner.getFailureState(); - Set objSubjectKeys = null; - if (objectSchema.hasDefaultProperty()) { - objSubjectKeys = new HashSet<>(objSubject.keySet()); - } - super.visitObjectSchema(objectSchema); - if (owner.isFailureStateChanged(failureState) && objectSchema.hasDefaultProperty()) { - objSubject.keySet().retainAll(objSubjectKeys); - } - } + @Override + void visitObjectSchema(ObjectSchema objectSchema) { + owner.ifPassesTypeCheck(JSONObject.class, objectSchema.requiresObject(), objectSchema.isNullable(), + objSubject -> { + this.objSubject = objSubject; + this.objectSize = objSubject.length(); + this.schema = objectSchema; + Object failureState = owner.getFailureState(); + Set objSubjectKeys = null; + if (objectSchema.hasDefaultProperty()) { + objSubjectKeys = new HashSet<>(objSubject.keySet()); + } + super.visitObjectSchema(objectSchema); + if (owner.isFailureStateChanged(failureState) && objectSchema.hasDefaultProperty()) { + objSubject.keySet().retainAll(objSubjectKeys); + } + }); } @Override void visitRequiredPropertyName(String requiredPropName) { diff --git a/core/src/main/java/org/everit/json/schema/PrimitiveValidationStrategy.java b/core/src/main/java/org/everit/json/schema/PrimitiveValidationStrategy.java new file mode 100644 index 000000000..f3071d6ef --- /dev/null +++ b/core/src/main/java/org/everit/json/schema/PrimitiveValidationStrategy.java @@ -0,0 +1,5 @@ +package org.everit.json.schema; + +public enum PrimitiveValidationStrategy { + STRICT, LENIENT +} diff --git a/core/src/main/java/org/everit/json/schema/StringSchemaValidatingVisitor.java b/core/src/main/java/org/everit/json/schema/StringSchemaValidatingVisitor.java index c32ebbe65..07626e0bd 100644 --- a/core/src/main/java/org/everit/json/schema/StringSchemaValidatingVisitor.java +++ b/core/src/main/java/org/everit/json/schema/StringSchemaValidatingVisitor.java @@ -7,7 +7,8 @@ import org.everit.json.schema.regexp.Regexp; -public class StringSchemaValidatingVisitor extends Visitor { +public class StringSchemaValidatingVisitor + extends Visitor { private final Object subject; @@ -22,36 +23,42 @@ public StringSchemaValidatingVisitor(Object subject, ValidatingVisitor owner) { this.owner = requireNonNull(owner, "failureReporter cannot be null"); } - @Override void visitStringSchema(StringSchema stringSchema) { - if (owner.passesTypeCheck(String.class, stringSchema.requireString(), stringSchema.isNullable())) { - stringSubject = (String) subject; - stringLength = stringSubject.codePointCount(0, stringSubject.length()); - super.visitStringSchema(stringSchema); - } + @Override + void visitStringSchema(StringSchema stringSchema) { + owner.ifPassesTypeCheck(String.class, stringSchema.requireString(), stringSchema.isNullable(), + stringSubject -> { + this.stringSubject = stringSubject; + this.stringLength = stringSubject.codePointCount(0, stringSubject.length()); + super.visitStringSchema(stringSchema); + }); } - @Override void visitMinLength(Integer minLength) { + @Override + void visitMinLength(Integer minLength) { if (minLength != null && stringLength < minLength.intValue()) { owner.failure("expected minLength: " + minLength + ", actual: " + stringLength, "minLength"); } } - @Override void visitMaxLength(Integer maxLength) { + @Override + void visitMaxLength(Integer maxLength) { if (maxLength != null && stringLength > maxLength.intValue()) { owner.failure("expected maxLength: " + maxLength + ", actual: " + stringLength, "maxLength"); } } - @Override void visitPattern(Regexp pattern) { + @Override + void visitPattern(Regexp pattern) { if (pattern != null && pattern.patternMatchingFailure(stringSubject).isPresent()) { String message = format("string [%s] does not match pattern %s", subject, pattern.toString()); owner.failure(message, "pattern"); } } - @Override void visitFormat(FormatValidator formatValidator) { + @Override + void visitFormat(FormatValidator formatValidator) { Optional failure = formatValidator.validate(stringSubject); if (failure.isPresent()) { owner.failure(failure.get(), "format"); diff --git a/core/src/main/java/org/everit/json/schema/StringToValueConverter.java b/core/src/main/java/org/everit/json/schema/StringToValueConverter.java new file mode 100644 index 000000000..eede2d5c0 --- /dev/null +++ b/core/src/main/java/org/everit/json/schema/StringToValueConverter.java @@ -0,0 +1,152 @@ +package org.everit.json.schema; + +import org.json.JSONObject; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; + +import static java.util.Arrays.asList; + +/** + * The methods of this class are copied from {@code org.json.JSONObject}. + * + * Although it would be possible to call {@code JSONObject#stringToValue()} from + * {@link ValidatingVisitor#ifPassesTypeCheck(Class, Function, boolean, Boolean, Consumer)}, we can not do it, + * because {@code JSONObject#stringToValue()} does not exist in the android flavor of the org.json package, + * therefore on android it would throw a {@link NoSuchMethodError}. For that reason, these methods are copied + * to the everit-org/json-schema library, to make sure that they exist at run-time. + * + * Furthermore, this implementation accepts all 22 boolean literals of YAML ( https://yaml.org/type/bool.html ) + * as valid booleans. + */ +class StringToValueConverter { + + private static final Set YAML_BOOLEAN_TRUE_LITERALS = new HashSet<>(asList( + "y", + "Y", + "yes", + "Yes", + "YES", + "true", + "True", + "TRUE", + "on", + "On", + "ON" + )); + + private static final Set YAML_BOOLEAN_FALSE_LITERALS = new HashSet<>(asList( + "n", + "N", + "no", + "No", + "NO", + "false", + "False", + "FALSE", + "off", + "Off", + "OFF" + )); + + + + static Object stringToValue(String string) { + if ("".equals(string)) { + return string; + } + + if (YAML_BOOLEAN_TRUE_LITERALS.contains(string)) { + return Boolean.TRUE; + } + if (YAML_BOOLEAN_FALSE_LITERALS.contains(string)) { + return Boolean.FALSE; + } + if ("null".equalsIgnoreCase(string)) { + return JSONObject.NULL; + } + + /* + * If it might be a number, try converting it. If a number cannot be + * produced, then the value will just be a string. + */ + + char initial = string.charAt(0); + if ((initial >= '0' && initial <= '9') || initial == '-') { + try { + return stringToNumber(string); + } catch (Exception ignore) { + } + } + return string; + } + + private static boolean isDecimalNotation(final String val) { + return val.indexOf('.') > -1 || val.indexOf('e') > -1 + || val.indexOf('E') > -1 || "-0".equals(val); + } + + private static Number stringToNumber(final String val) throws NumberFormatException { + char initial = val.charAt(0); + if ((initial >= '0' && initial <= '9') || initial == '-') { + // decimal representation + if (isDecimalNotation(val)) { + // Use a BigDecimal all the time so we keep the original + // representation. BigDecimal doesn't support -0.0, ensure we + // keep that by forcing a decimal. + try { + BigDecimal bd = new BigDecimal(val); + if(initial == '-' && BigDecimal.ZERO.compareTo(bd)==0) { + return Double.valueOf(-0.0); + } + return bd; + } catch (NumberFormatException retryAsDouble) { + // this is to support "Hex Floats" like this: 0x1.0P-1074 + try { + Double d = Double.valueOf(val); + if(d.isNaN() || d.isInfinite()) { + throw new NumberFormatException("val ["+val+"] is not a valid number."); + } + return d; + } catch (NumberFormatException ignore) { + throw new NumberFormatException("val ["+val+"] is not a valid number."); + } + } + } + // block items like 00 01 etc. Java number parsers treat these as Octal. + if(initial == '0' && val.length() > 1) { + char at1 = val.charAt(1); + if(at1 >= '0' && at1 <= '9') { + throw new NumberFormatException("val ["+val+"] is not a valid number."); + } + } else if (initial == '-' && val.length() > 2) { + char at1 = val.charAt(1); + char at2 = val.charAt(2); + if(at1 == '0' && at2 >= '0' && at2 <= '9') { + throw new NumberFormatException("val ["+val+"] is not a valid number."); + } + } + // integer representation. + // This will narrow any values to the smallest reasonable Object representation + // (Integer, Long, or BigInteger) + + // BigInteger down conversion: We use a similar bitLenth compare as + // BigInteger#intValueExact uses. Increases GC, but objects hold + // only what they need. i.e. Less runtime overhead if the value is + // long lived. + BigInteger bi = new BigInteger(val); + if(bi.bitLength() <= 31){ + return Integer.valueOf(bi.intValue()); + } + if(bi.bitLength() <= 63){ + return Long.valueOf(bi.longValue()); + } + return bi; + } + throw new NumberFormatException("val ["+val+"] is not a valid number."); + } +} diff --git a/core/src/main/java/org/everit/json/schema/ValidatingVisitor.java b/core/src/main/java/org/everit/json/schema/ValidatingVisitor.java index 5bbc89b87..4644a6179 100644 --- a/core/src/main/java/org/everit/json/schema/ValidatingVisitor.java +++ b/core/src/main/java/org/everit/json/schema/ValidatingVisitor.java @@ -3,12 +3,17 @@ import static java.lang.String.format; import static java.util.Arrays.asList; import static java.util.Collections.unmodifiableList; +import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; import static org.everit.json.schema.EnumSchema.toJavaValue; +import static org.everit.json.schema.PrimitiveValidationStrategy.LENIENT; +import static org.everit.json.schema.StringToValueConverter.stringToValue; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; import org.everit.json.schema.event.CombinedSchemaMatchEvent; import org.everit.json.schema.event.CombinedSchemaMismatchEvent; @@ -43,6 +48,8 @@ private static boolean isNull(Object obj) { private final ReadWriteValidator readWriteValidator; + private final PrimitiveValidationStrategy primitiveValidationStrategy; + @Override void visit(Schema schema) { if (Boolean.FALSE.equals(schema.isNullable()) && isNull(subject)) { @@ -53,7 +60,8 @@ void visit(Schema schema) { } ValidatingVisitor(Object subject, ValidationFailureReporter failureReporter, ReadWriteValidator readWriteValidator, - ValidationListener validationListener) { + ValidationListener validationListener, + PrimitiveValidationStrategy primitiveValidationStrategy) { if (subject != null && !VALIDATED_TYPES.stream().anyMatch(type -> type.isAssignableFrom(subject.getClass()))) { throw new IllegalArgumentException(format(TYPE_FAILURE_MSG, subject.getClass().getSimpleName())); } @@ -61,6 +69,7 @@ void visit(Schema schema) { this.failureReporter = failureReporter; this.readWriteValidator = readWriteValidator; this.validationListener = validationListener; + this.primitiveValidationStrategy = requireNonNull(primitiveValidationStrategy); } @Override @@ -70,19 +79,17 @@ void visitNumberSchema(NumberSchema numberSchema) { @Override void visitArraySchema(ArraySchema arraySchema) { - arraySchema.accept(new ArraySchemaValidatingVisitor(subject, this)); + arraySchema.accept(new ArraySchemaValidatingVisitor(this)); } @Override void visitBooleanSchema(BooleanSchema schema) { - if (!(subject instanceof Boolean)) { - failureReporter.failure(Boolean.class, subject); - } + ifPassesTypeCheck(Boolean.class, true, schema.isNullable(), v -> {}); } @Override void visitNullSchema(NullSchema nullSchema) { - if (!(subject == null || subject == JSONObject.NULL)) { + if (!(isNull(subject) || (primitiveValidationStrategy == LENIENT && "null".equals(subject)))) { failureReporter.failure("expected: null, found: " + subject.getClass().getSimpleName(), "type"); } } @@ -140,7 +147,7 @@ void visitReferenceSchema(ReferenceSchema referenceSchema) { @Override void visitObjectSchema(ObjectSchema objectSchema) { - objectSchema.accept(new ObjectSchemaValidatingVisitor(subject, this)); + objectSchema.accept(new ObjectSchemaValidatingVisitor(this)); } @Override @@ -218,19 +225,34 @@ boolean isFailureStateChanged(Object olState) { return failureReporter.isChanged(olState); } - boolean passesTypeCheck(Class expectedType, boolean schemaRequiresType, Boolean nullable) { + void ifPassesTypeCheck(Class expectedType, Function castFn, boolean schemaRequiresType, Boolean nullable, + Consumer onPass) { + Object subject = this.subject; + if (primitiveValidationStrategy == LENIENT) { + boolean expectedString = expectedType.isAssignableFrom(String.class); + if (subject instanceof String && !expectedString) { + subject = stringToValue((String) subject); + } else if (expectedString) { + subject = subject.toString(); + } + } if (isNull(subject)) { if (schemaRequiresType && !Boolean.TRUE.equals(nullable)) { - failureReporter.failure(expectedType, subject); + failureReporter.failure(expectedType, this.subject); } - return false; + return; } if (TypeChecker.isAssignableFrom(expectedType, subject.getClass())) { - return true; + onPass.accept(castFn.apply(subject)); + return; } if (schemaRequiresType) { - failureReporter.failure(expectedType, subject); + failureReporter.failure(expectedType, this.subject); } - return false; + } + + void ifPassesTypeCheck(Class expectedType, boolean schemaRequiresType, Boolean nullable, + Consumer onPass) { + ifPassesTypeCheck(expectedType, expectedType::cast, schemaRequiresType, nullable, onPass); } } diff --git a/core/src/main/java/org/everit/json/schema/Validator.java b/core/src/main/java/org/everit/json/schema/Validator.java index d4e0e4120..877bd04ec 100644 --- a/core/src/main/java/org/everit/json/schema/Validator.java +++ b/core/src/main/java/org/everit/json/schema/Validator.java @@ -1,7 +1,5 @@ package org.everit.json.schema; -import java.util.function.BiFunction; - import org.everit.json.schema.event.ValidationListener; public interface Validator { @@ -14,6 +12,8 @@ class ValidatorBuilder { private ValidationListener validationListener = ValidationListener.NOOP; + private PrimitiveValidationStrategy primitiveValidationStrategy = PrimitiveValidationStrategy.STRICT; + public ValidatorBuilder failEarly() { this.failEarly = true; return this; @@ -29,10 +29,14 @@ public ValidatorBuilder withListener(ValidationListener validationListener) { return this; } - public Validator build() { - return new DefaultValidator(failEarly, readWriteContext, validationListener); + public ValidatorBuilder primitiveValidationStrategy(PrimitiveValidationStrategy primitiveValidationStrategy) { + this.primitiveValidationStrategy = primitiveValidationStrategy; + return this; } + public Validator build() { + return new DefaultValidator(failEarly, readWriteContext, validationListener, primitiveValidationStrategy); + } } static ValidatorBuilder builder() { @@ -50,16 +54,21 @@ class DefaultValidator implements Validator { private final ValidationListener validationListener; - DefaultValidator(boolean failEarly, ReadWriteContext readWriteContext, ValidationListener validationListener) { + private final PrimitiveValidationStrategy primitiveValidationStrategy; + + DefaultValidator(boolean failEarly, ReadWriteContext readWriteContext, ValidationListener validationListener, + PrimitiveValidationStrategy primitiveValidationStrategy) { this.failEarly = failEarly; this.readWriteContext = readWriteContext; this.validationListener = validationListener; + this.primitiveValidationStrategy = primitiveValidationStrategy; } @Override public void performValidation(Schema schema, Object input) { ValidationFailureReporter failureReporter = createFailureReporter(schema); ReadWriteValidator readWriteValidator = ReadWriteValidator.createForContext(readWriteContext, failureReporter); - ValidatingVisitor visitor = new ValidatingVisitor(input, failureReporter, readWriteValidator, validationListener); + ValidatingVisitor visitor = new ValidatingVisitor(input, failureReporter, readWriteValidator, validationListener, + primitiveValidationStrategy); try { visitor.visit(schema); visitor.failIfErrorFound(); diff --git a/core/src/test/java/org/everit/json/schema/ValidatingVisitorTest.java b/core/src/test/java/org/everit/json/schema/ValidatingVisitorTest.java index e58257c98..b1ebf924f 100644 --- a/core/src/test/java/org/everit/json/schema/ValidatingVisitorTest.java +++ b/core/src/test/java/org/everit/json/schema/ValidatingVisitorTest.java @@ -1,123 +1,206 @@ package org.everit.json.schema; +import static org.everit.json.schema.PrimitiveValidationStrategy.LENIENT; +import static org.everit.json.schema.PrimitiveValidationStrategy.STRICT; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import java.math.BigDecimal; import java.math.BigInteger; import java.util.ArrayList; +import java.util.function.Consumer; import org.everit.json.schema.event.CombinedSchemaMatchEvent; import org.everit.json.schema.event.CombinedSchemaMismatchEvent; import org.everit.json.schema.event.ValidationListener; import org.json.JSONArray; import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -public class ValidatingVisitorTest { +class ValidatingVisitorTest { private ValidationFailureReporter reporter; @BeforeEach - public void before() { + void before() { reporter = mock(ValidationFailureReporter.class); } - @Test - public void passesTypeCheck_otherType_noRequires() { - ValidatingVisitor subject = new ValidatingVisitor("string", reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, false, null)); - verifyZeroInteractions(reporter); - } - - @Test - public void passesTypeCheck_otherType_requires() { - ValidatingVisitor subject = new ValidatingVisitor("string", reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, true, null)); - verify(reporter).failure(JSONObject.class, "string"); - } - - @Test - public void passesTypeCheck_otherType_nullPermitted_nullObject() { - ValidatingVisitor subject = new ValidatingVisitor(JSONObject.NULL, reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, true, Boolean.TRUE)); - verifyZeroInteractions(reporter); - } - - @Test - public void passesTypeCheck_otherType_nullPermitted_nullReference() { - ValidatingVisitor subject = new ValidatingVisitor(null, reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, true, Boolean.TRUE)); - verifyZeroInteractions(reporter); - } - - @Test - public void passesTypeCheck_nullPermitted_nonNullValue() { - ValidatingVisitor subject = new ValidatingVisitor("string", reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, true, Boolean.TRUE)); - verify(reporter).failure(JSONObject.class, "string"); - } - - @Test - public void passesTypeCheck_requiresType_nullableIsNull() { - ValidatingVisitor subject = new ValidatingVisitor(null, reporter, null, null); - assertFalse(subject.passesTypeCheck(JSONObject.class, true, null)); - verify(reporter).failure(JSONObject.class, null); - } - - @Test - public void passesTypeCheck_sameType() { - ValidatingVisitor subject = new ValidatingVisitor("string", reporter, null, null); - assertTrue(subject.passesTypeCheck(String.class, true, Boolean.TRUE)); - verifyZeroInteractions(reporter); + @Nested + class PassesTypeCheckTests { + + Consumer onPass; + + @BeforeEach + void before() { + onPass = mock(Consumer.class); + } + + @AfterEach + void after() { + verifyNoMoreInteractions(reporter); + verifyNoMoreInteractions(onPass); + } + + private Consumer onPassConsumer() { + return (Consumer) onPass; + } + + private void verifyTypeCheckDidNotPass() { + verify(onPass, never()).accept(any()); + } + + private ValidatingVisitor createValidatingVisitor() { + return createValidatingVisitor("string", STRICT); + } + + private ValidatingVisitor createValidatingVisitor(Object instance, + PrimitiveValidationStrategy primitiveValidationStrategy) { + return new ValidatingVisitor(instance, reporter, + ReadWriteValidator.NONE, + ValidationListener.NOOP, + primitiveValidationStrategy); + } + + @Test + void otherType_noRequires() { + ValidatingVisitor subject = createValidatingVisitor(); + subject.ifPassesTypeCheck(JSONObject.class, false, null, onPassConsumer()); + verifyTypeCheckDidNotPass(); + } + + @Test + void otherType_requires() { + ValidatingVisitor subject = createValidatingVisitor(); + subject.ifPassesTypeCheck(JSONObject.class, true, null, onPassConsumer()); + verifyTypeCheckDidNotPass(); + verify(reporter).failure(JSONObject.class, "string"); + } + + @Test + void otherType_nullPermitted_nullObject() { + ValidatingVisitor subject = createValidatingVisitor(JSONObject.NULL, STRICT); + subject.ifPassesTypeCheck(JSONObject.class, true, Boolean.TRUE, onPassConsumer()); + verifyTypeCheckDidNotPass(); + } + + @Test + void otherType_nullPermitted_nullReference() { + ValidatingVisitor subject = createValidatingVisitor(null, STRICT); + subject.ifPassesTypeCheck(JSONObject.class, true, Boolean.TRUE, onPassConsumer()); + verifyTypeCheckDidNotPass(); + } + + @Test + void nullPermitted_nonNullValue() { + ValidatingVisitor subject = createValidatingVisitor(); + subject.ifPassesTypeCheck(JSONObject.class, true, Boolean.TRUE, onPassConsumer()); + verifyTypeCheckDidNotPass(); + verify(reporter).failure(JSONObject.class, "string"); + } + + @Test + void requiresType_nullableIsNull() { + ValidatingVisitor subject = createValidatingVisitor(null, STRICT); + subject.ifPassesTypeCheck(JSONObject.class, true, null, onPassConsumer()); + verifyTypeCheckDidNotPass(); + verify(reporter).failure(JSONObject.class, null); + } + + @Test + void lenientMode_expectedString_actualString() { + ValidatingVisitor subject = createValidatingVisitor("str", LENIENT); + subject.ifPassesTypeCheck(String.class, true, Boolean.TRUE, onPassConsumer()); + verify(onPassConsumer()).accept("str"); + } + + @Test + void lenientMode_expectedString_actualNumber() { + ValidatingVisitor subject = createValidatingVisitor(2, LENIENT); + subject.ifPassesTypeCheck(String.class, true, Boolean.TRUE, onPassConsumer()); + verify(onPassConsumer()).accept("2"); + } + + @Test + void lenientMode_expectedBoolean_actualString() { + ValidatingVisitor subject = createValidatingVisitor("Yes", LENIENT); + subject.ifPassesTypeCheck(Boolean.class, true, Boolean.TRUE, onPassConsumer()); + verify(onPassConsumer()).accept(true); + } + + @Test + void lenientMode_expectedInteger_actualString() { + ValidatingVisitor subject = createValidatingVisitor("2", LENIENT); + subject.ifPassesTypeCheck(Integer.class, true, Boolean.TRUE, onPassConsumer()); + verify(onPassConsumer()).accept(2); + } + + @Test + void lenientMode_expecedInteger_actualBooleanAsString() { + ValidatingVisitor subject = createValidatingVisitor("true", LENIENT); + subject.ifPassesTypeCheck(Integer.class, true, Boolean.TRUE, onPassConsumer()); + verifyTypeCheckDidNotPass(); + verify(reporter).failure(Integer.class, "true"); + } + + @Test + public void sameType() { + ValidatingVisitor subject = createValidatingVisitor(); + subject.ifPassesTypeCheck(String.class, true, Boolean.TRUE, onPassConsumer()); + verify(onPassConsumer()).accept("string"); + } } public static Arguments[] permittedTypes() { - return new Arguments[] { - Arguments.of(new Object[]{"str"}), - Arguments.of(new Object[]{1}), - Arguments.of(new Object[]{1L}), - Arguments.of(new Object[]{1.0}), - Arguments.of(new Object[]{1.0f}), - Arguments.of(new Object[]{new BigInteger("42")}), - Arguments.of(new Object[]{new BigDecimal("42.3")}), - Arguments.of(new Object[]{true}), + return new Arguments[]{ + Arguments.of("str"), + Arguments.of(1), + Arguments.of(1L), + Arguments.of(1.0), + Arguments.of(1.0f), + Arguments.of(new BigInteger("42")), + Arguments.of(new BigDecimal("42.3")), + Arguments.of(true), Arguments.of(new Object[]{null}), - Arguments.of(new Object[]{JSONObject.NULL}), - Arguments.of(new Object[]{new JSONObject("{}")}), - Arguments.of(new Object[]{new JSONArray("[]")}) + Arguments.of(JSONObject.NULL), + Arguments.of(new JSONObject("{}")), + Arguments.of(new JSONArray("[]")) }; } - public static Arguments[] notPermittedTypes() { - return new Arguments[] { - Arguments.of(new Object[] { new ArrayList() }), - Arguments.of(new Object[] { new RuntimeException() }) + static Arguments[] notPermittedTypes() { + return new Arguments[]{ + Arguments.of(new ArrayList()), + Arguments.of(new RuntimeException()) }; } @ParameterizedTest @MethodSource("permittedTypes") - public void permittedTypeSuccess(Object subject) { - new ValidatingVisitor(subject, reporter, ReadWriteValidator.NONE, null); + void permittedTypeSuccess(Object subject) { + new ValidatingVisitor(subject, reporter, ReadWriteValidator.NONE, null, STRICT); } @ParameterizedTest @MethodSource("notPermittedTypes") - public void notPermittedTypeFailure(Object subject) { + void notPermittedTypeFailure(Object subject) { assertThrows(IllegalArgumentException.class, () -> { - new ValidatingVisitor(subject, reporter, ReadWriteValidator.NONE, null); + new ValidatingVisitor(subject, reporter, ReadWriteValidator.NONE, null, STRICT); }); } @Test - public void triggersCombinedSchemaEvents() { + void triggersCombinedSchemaEvents() { ValidationListener listener = mock(ValidationListener.class); StringSchema stringSchema = StringSchema.builder().requiresString(true).build(); EmptySchema emptySchema = EmptySchema.builder().build(); @@ -130,7 +213,8 @@ public void triggersCombinedSchemaEvents() { ValidationFailureReporter reporter = new CollectingFailureReporter(combinedSchema); JSONObject instance = new JSONObject(); - new ValidatingVisitor(instance, reporter, ReadWriteValidator.NONE, listener).visit(combinedSchema); + new ValidatingVisitor(instance, reporter, ReadWriteValidator.NONE, listener, STRICT) + .visit(combinedSchema); ValidationException exc = new InternalValidationException(stringSchema, String.class, instance); verify(listener).combinedSchemaMismatch(new CombinedSchemaMismatchEvent(combinedSchema, stringSchema, instance, exc)); diff --git a/tests/vanilla/src/main/java/org/everit/json/schema/IssueTest.java b/tests/vanilla/src/main/java/org/everit/json/schema/IssueTest.java index 682a8ae69..60f37c23b 100644 --- a/tests/vanilla/src/main/java/org/everit/json/schema/IssueTest.java +++ b/tests/vanilla/src/main/java/org/everit/json/schema/IssueTest.java @@ -55,13 +55,9 @@ public static List params() { private JettyWrapper servletSupport; - private List validationFailureList; - - private List expectedFailureList; - private SchemaLoader.SchemaLoaderBuilder loaderBuilder; - private Validator.ValidatorBuilder validatorBuilder = Validator.builder(); + private final Validator.ValidatorBuilder validatorBuilder = Validator.builder(); private Optional fileByName(final String fileName) { return Optional.ofNullable(getClass().getResourceAsStream(issueDir + "/" + fileName)); @@ -136,6 +132,8 @@ private void consumeValidatorConfig() { loaderBuilder.enableOverrideOfBuiltInFormatValidators(); } }); + configKeyHandlers.put("primitiveParsing", + value -> validatorBuilder.primitiveValidationStrategy(PrimitiveValidationStrategy.valueOf((String) value))); fileByName("validator-config.json").map(file -> streamAsJson(file)).ifPresent(configJson -> { configKeyHandlers.entrySet() .stream() diff --git a/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/schema.json b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/schema.json new file mode 100644 index 000000000..7f869d8e9 --- /dev/null +++ b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/schema.json @@ -0,0 +1,25 @@ +{ + "properties": { + "booleanProps": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "integerProp": { + "type": "integer" + }, + "nullProp": { + "type": "null" + }, + "numberProp": { + "type": "number" + }, + "stringProp": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/subject-valid.json b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/subject-valid.json new file mode 100644 index 000000000..c497af8c9 --- /dev/null +++ b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/subject-valid.json @@ -0,0 +1,30 @@ +{ + "numberProp": "12.34", + "integerProp": "12", + "booleanProps": [ + "y", + "Y", + "yes", + "Yes", + "YES", + "n", + "N", + "no", + "No", + "NO", + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "on", + "On", + "ON", + "off", + "Off", + "OFF" + ], + "nullProp": "null", + "stringProp": [true, false, "yes", 12, 12.34] +} diff --git a/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/validator-config.json b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/validator-config.json new file mode 100644 index 000000000..dba6b05a8 --- /dev/null +++ b/tests/vanilla/src/main/resources/org/everit/json/schema/issues/issue428/validator-config.json @@ -0,0 +1,3 @@ +{ + "primitiveParsing": "LENIENT" +}