Skip to content

Commit

Permalink
Lenient validation mode (#429)
Browse files Browse the repository at this point in the history
## 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 :)
  • Loading branch information
erosb committed Jul 16, 2021
1 parent 9512ec6 commit f99034e
Show file tree
Hide file tree
Showing 14 changed files with 535 additions and 142 deletions.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@

class ArraySchemaValidatingVisitor extends Visitor {

private final Object subject;

private final ValidatingVisitor owner;

private JSONArray arraySubject;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<? extends Number> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@

class ObjectSchemaValidatingVisitor extends Visitor {

private final Object subject;

private JSONObject objSubject;

private ObjectSchema schema;
Expand All @@ -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<String> 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<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.everit.json.schema;

public enum PrimitiveValidationStrategy {
STRICT, LENIENT
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> failure = formatValidator.validate(stringSubject);
if (failure.isPresent()) {
owner.failure(failure.get(), "format");
Expand Down
Loading

0 comments on commit f99034e

Please sign in to comment.