diff --git a/doc/walkers.md b/doc/walkers.md index 84f8e152a..99e376679 100644 --- a/doc/walkers.md +++ b/doc/walkers.md @@ -1,6 +1,6 @@ ### JSON Schema Walkers -There can be use-cases where we need the capability to walk through the given JsonNode allowing functionality beyond validation like collecting information,handling cross cutting concerns like logging or instrumentation. JSON walkers were introduced to complement the validation functionality this library already provides. +There can be use-cases where we need the capability to walk through the given JsonNode allowing functionality beyond validation like collecting information,handling cross cutting concerns like logging or instrumentation, or applying default values. JSON walkers were introduced to complement the validation functionality this library already provides. Currently, walking is defined at the validator instance level for all the built-in keywords. @@ -237,4 +237,56 @@ Few important points to note about the flow. 5. Since we have a property listener defined, When we are walking through a property that has a "$ref" keyword which might have some more properties defined, Our property listener would be invoked for each of the property defined in the "$ref" schema. 6. As mentioned earlier anywhere during the "Walk Flow", we can return a WalkFlow.SKIP from onWalkStart method to stop the walk method of a particular "property schema" from being called. - Since the walk method will not be called any property or keyword listeners in the "property schema" will not be invoked. \ No newline at end of file + Since the walk method will not be called any property or keyword listeners in the "property schema" will not be invoked. + + +### Applying defaults + +In some use cases we may want to apply defaults while walking the schema. +To accomplish this, create an ApplyDefaultsStrategy when creating a SchemaValidatorsConfig. +The input object is changed in place, even if validation fails, or a fail-fast or some other exception is thrown. + +Here is the order of operations in walker. +1. apply defaults +1. run listeners +1. validate if shouldValidateSchema is true + +Suppose the JSON schema is +```json +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema with default values ", + "type": "object", + "properties": { + "intValue": { + "type": "integer", + "default": 15, + "minimum": 20 + } + }, + "required": ["intValue"] +} +``` + +A JSON file like +```json +{ +} +``` + +would normally fail validation as "intValue" is required. +But if we apply defaults while walking, then required validation passes, and the object is changed in place. + +```java + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + SchemaValidatorsConfig schemaValidatorsConfig = new SchemaValidatorsConfig(); + schemaValidatorsConfig.setApplyDefaultsStrategy(new ApplyDefaultsStrategy(true, true, true)); + JsonSchema jsonSchema = schemaFactory.getSchema(getClass().getClassLoader().getResourceAsStream("schema.json"), schemaValidatorsConfig); + + JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data.json")); + ValidationResult result = jsonSchema.walk(inputNode, true); + assertThat(result.getValidationMessages(), Matchers.empty()); + assertEquals("{\"intValue\":15}", inputNode.toString()); + assertThat(result.getValidationMessages().stream().map(ValidationMessage::getMessage).collect(Collectors.toList()), + Matchers.containsInAnyOrder("$.intValue: must have a minimum value of 20.")); +``` diff --git a/pom.xml b/pom.xml index 297847f09..3656c51f7 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,12 @@ ${version.junit} test + + org.junit.jupiter + junit-jupiter-params + ${version.junit} + test + org.mockito mockito-core diff --git a/src/main/java/com/networknt/schema/ApplyDefaultsStrategy.java b/src/main/java/com/networknt/schema/ApplyDefaultsStrategy.java new file mode 100644 index 000000000..4e93826f6 --- /dev/null +++ b/src/main/java/com/networknt/schema/ApplyDefaultsStrategy.java @@ -0,0 +1,41 @@ +package com.networknt.schema; + +public class ApplyDefaultsStrategy { + private final boolean applyPropertyDefaults; + private final boolean applyPropertyDefaultsIfNull; + private final boolean applyArrayDefaults; + + /** + * Specify which default values to apply. + * We can apply property defaults only if they are missing or if they are declared to be null in the input json, + * and we can apply array defaults if they are declared to be null in the input json. + * + *

Note that the walker changes the input object in place. + * If validation fails, the input object will be changed. + * + * @param applyPropertyDefaults if true then apply defaults inside json objects if the attribute is missing + * @param applyPropertyDefaultsIfNull if true then apply defaults inside json objects if the attribute is explicitly null + * @param applyArrayDefaults if true then apply defaults inside json arrays if the attribute is explicitly null + * @throws IllegalArgumentException if applyPropertyDefaults is false and applyPropertyDefaultsIfNull is true + */ + public ApplyDefaultsStrategy(boolean applyPropertyDefaults, boolean applyPropertyDefaultsIfNull, boolean applyArrayDefaults) { + if (!applyPropertyDefaults && applyPropertyDefaultsIfNull) { + throw new IllegalArgumentException(); + } + this.applyPropertyDefaults = applyPropertyDefaults; + this.applyPropertyDefaultsIfNull = applyPropertyDefaultsIfNull; + this.applyArrayDefaults = applyArrayDefaults; + } + + public boolean shouldApplyPropertyDefaults() { + return applyPropertyDefaults; + } + + public boolean shouldApplyPropertyDefaultsIfNull() { + return applyPropertyDefaultsIfNull; + } + + public boolean shouldApplyArrayDefaults() { + return applyArrayDefaults; + } +} diff --git a/src/main/java/com/networknt/schema/BaseJsonValidator.java b/src/main/java/com/networknt/schema/BaseJsonValidator.java index 3aa9a0b39..6a9da5585 100644 --- a/src/main/java/com/networknt/schema/BaseJsonValidator.java +++ b/src/main/java/com/networknt/schema/BaseJsonValidator.java @@ -38,11 +38,18 @@ public abstract class BaseJsonValidator implements JsonValidator { private ErrorMessageType errorMessageType; protected final boolean failFast; + protected final ApplyDefaultsStrategy applyDefaultsStrategy; public BaseJsonValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema, ValidatorTypeCode validatorType, ValidationContext validationContext) { - this(schemaPath, schemaNode, parentSchema, validatorType, false, - validationContext.getConfig() != null && validationContext.getConfig().isFailFast()); + this.errorMessageType = validatorType; + this.schemaPath = schemaPath; + this.schemaNode = schemaNode; + this.parentSchema = parentSchema; + this.validatorType = validatorType; + this.suppressSubSchemaRetrieval = false; + this.failFast = validationContext.getConfig() != null && validationContext.getConfig().isFailFast(); + this.applyDefaultsStrategy = validationContext.getConfig() != null ? validationContext.getConfig().getApplyDefaultsStrategy() : null; } public BaseJsonValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema, @@ -55,6 +62,7 @@ public BaseJsonValidator(String schemaPath, JsonNode schemaNode, JsonSchema pare this.validatorType = validatorType; this.suppressSubSchemaRetrieval = suppressSubSchemaRetrieval; this.failFast = failFast; + this.applyDefaultsStrategy = null; } public String getSchemaPath() { diff --git a/src/main/java/com/networknt/schema/ItemsValidator.java b/src/main/java/com/networknt/schema/ItemsValidator.java index 69abc8bab..80c70426a 100644 --- a/src/main/java/com/networknt/schema/ItemsValidator.java +++ b/src/main/java/com/networknt/schema/ItemsValidator.java @@ -17,6 +17,7 @@ package com.networknt.schema; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.networknt.schema.walk.DefaultItemWalkListenerRunner; import com.networknt.schema.walk.WalkListenerRunner; @@ -118,9 +119,21 @@ private void doValidate(Set errors, int i, JsonNode node, Jso @Override public Set walk(JsonNode node, JsonNode rootNode, String at, boolean shouldValidateSchema) { HashSet validationMessages = new LinkedHashSet(); - if (node != null && node.isArray()) { + if (node instanceof ArrayNode) { + ArrayNode arrayNode = (ArrayNode) node; + JsonNode defaultNode = null; + if (applyDefaultsStrategy.shouldApplyArrayDefaults() && schema != null) { + defaultNode = schema.getSchemaNode().get("default"); + if (defaultNode != null && defaultNode.isNull()) { + defaultNode = null; + } + } int i = 0; - for (JsonNode n : node) { + for (JsonNode n : arrayNode) { + if (n.isNull() && defaultNode != null) { + arrayNode.set(i, defaultNode); + n = defaultNode; + } doWalk(validationMessages, i, n, rootNode, at, shouldValidateSchema); i++; } diff --git a/src/main/java/com/networknt/schema/JsonSchema.java b/src/main/java/com/networknt/schema/JsonSchema.java index a4de67329..86b5c07fc 100644 --- a/src/main/java/com/networknt/schema/JsonSchema.java +++ b/src/main/java/com/networknt/schema/JsonSchema.java @@ -19,7 +19,6 @@ import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; -import java.sql.Ref; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -37,8 +36,6 @@ import com.networknt.schema.walk.JsonSchemaWalker; import com.networknt.schema.walk.WalkListenerRunner; -import javax.xml.validation.Schema; - /** * This is the core of json constraint implementation. It parses json constraint * file and generates JsonValidators. The class is thread safe, once it is @@ -80,7 +77,7 @@ public JsonSchema(ValidationContext validationContext, URI baseUri, JsonNode sch private JsonSchema(ValidationContext validationContext, String schemaPath, URI currentUri, JsonNode schemaNode, JsonSchema parent, boolean suppressSubSchemaRetrieval) { super(schemaPath, schemaNode, parent, null, suppressSubSchemaRetrieval, - validationContext.getConfig() != null && validationContext.getConfig().isFailFast()); + validationContext.getConfig() != null && validationContext.getConfig().isFailFast()); this.validationContext = validationContext; this.idKeyword = validationContext.getMetaSchema().getIdKeyword(); this.currentUri = this.combineCurrentUriWithIds(currentUri, schemaNode); @@ -320,7 +317,7 @@ protected ValidationResult validateAndCollect(JsonNode jsonNode, JsonNode rootNo SchemaValidatorsConfig config = validationContext.getConfig(); // Get the collector context from the thread local. CollectorContext collectorContext = getCollectorContext(); - // Valdiate. + // Validate. Set errors = validate(jsonNode, rootNode, at); // When walk is called in series of nested call we don't want to load the collectors every time. Leave to the API to decide when to call collectors. if (config.doLoadCollectors()) { @@ -374,7 +371,7 @@ public Set walk(JsonNode node, JsonNode rootNode, String at, JsonSchemaWalker jsonWalker = entry.getValue(); String schemaPathWithKeyword = entry.getKey(); try { - // Call all the pre-walk listeners. If atleast one of the pre walk listeners + // Call all the pre-walk listeners. If at least one of the pre walk listeners // returns SKIP, then skip the walk. if (keywordWalkListenerRunner.runPreWalkListeners(schemaPathWithKeyword, node, diff --git a/src/main/java/com/networknt/schema/PropertiesValidator.java b/src/main/java/com/networknt/schema/PropertiesValidator.java index b0e1154a0..d9c9bef68 100644 --- a/src/main/java/com/networknt/schema/PropertiesValidator.java +++ b/src/main/java/com/networknt/schema/PropertiesValidator.java @@ -17,6 +17,7 @@ package com.networknt.schema; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.networknt.schema.walk.DefaultPropertyWalkListenerRunner; import com.networknt.schema.walk.WalkListenerRunner; import org.slf4j.Logger; @@ -52,7 +53,6 @@ public Set validate(JsonNode node, JsonNode rootNode, String for (Map.Entry entry : schemas.entrySet()) { JsonSchema propertySchema = entry.getValue(); JsonNode propertyNode = node.get(entry.getKey()); - if (propertyNode != null) { // check whether this is a complex validator. save the state boolean isComplex = state.isComplexValidator(); @@ -117,6 +117,12 @@ private void walkSchema(Map.Entry entry, JsonNode node, Json boolean shouldValidateSchema, Set validationMessages, WalkListenerRunner propertyWalkListenerRunner) { JsonSchema propertySchema = entry.getValue(); JsonNode propertyNode = (node == null ? null : node.get(entry.getKey())); + if (propertyNode instanceof ObjectNode && applyDefaultsStrategy.shouldApplyPropertyDefaults()) { + JsonNode schemaPropertiesNode = propertySchema.getSchemaNode().get("properties"); + if (schemaPropertiesNode != null) { + applyPropertyDefaults((ObjectNode) propertyNode, schemaPropertiesNode, applyDefaultsStrategy.shouldApplyPropertyDefaultsIfNull()); + } + } boolean executeWalk = propertyWalkListenerRunner.runPreWalkListeners(ValidatorTypeCode.PROPERTIES.getValue(), propertyNode, rootNode, at + "." + entry.getKey(), propertySchema.getSchemaPath(), propertySchema.getSchemaNode(), propertySchema.getParentSchema(), validationContext, @@ -131,6 +137,22 @@ private void walkSchema(Map.Entry entry, JsonNode node, Json } + private static void applyPropertyDefaults(ObjectNode node, JsonNode schemaPropertiesNode, boolean applyPropertyDefaultsIfNull) { + for (Iterator> iter = schemaPropertiesNode.fields(); iter.hasNext(); ) { + Map.Entry entry = iter.next(); + String name = entry.getKey(); + JsonNode propertyNode = node.get(name); + + if (propertyNode == null || (applyPropertyDefaultsIfNull && propertyNode.isNull())) { + JsonNode defaultNode = entry.getValue().get("default"); + if (defaultNode != null && !defaultNode.isNull()) { + // mutate the input json + node.set(name, defaultNode); + } + } + } + } + public Map getSchemas() { return schemas; } diff --git a/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java b/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java index bb935ee35..b822e612f 100644 --- a/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java +++ b/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java @@ -37,6 +37,11 @@ public class SchemaValidatorsConfig { */ private boolean failFast; + /** + * When set to true, walker sets nodes that are missing or NullNode to the default value, if any, and mutate the input json. + */ + private ApplyDefaultsStrategy applyDefaultsStrategy; + /** * When set to true, use ECMA-262 compatible validator */ @@ -112,6 +117,14 @@ public boolean isFailFast() { return this.failFast; } + public void setApplyDefaultsStrategy(ApplyDefaultsStrategy applyDefaultsStrategy) { + this.applyDefaultsStrategy = applyDefaultsStrategy; + } + + public ApplyDefaultsStrategy getApplyDefaultsStrategy() { + return applyDefaultsStrategy; + } + public Map getUriMappings() { // return a copy of the mappings return new HashMap(uriMappings); diff --git a/src/test/java/com/networknt/schema/JsonWalkApplyDefaultsTest.java b/src/test/java/com/networknt/schema/JsonWalkApplyDefaultsTest.java new file mode 100644 index 000000000..c86986f2b --- /dev/null +++ b/src/test/java/com/networknt/schema/JsonWalkApplyDefaultsTest.java @@ -0,0 +1,112 @@ +package com.networknt.schema; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Set; +import java.util.stream.Collectors; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + + +class JsonWalkApplyDefaultsTest { + + @ParameterizedTest + @ValueSource(booleans = { true, false}) + void testApplyDefaults3(boolean shouldValidateSchema) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data/walk-data-default.json")); + JsonSchema jsonSchema = createSchema(new ApplyDefaultsStrategy(true, true, true)); + ValidationResult result = jsonSchema.walk(inputNode, shouldValidateSchema); + if (shouldValidateSchema) { + assertThat(result.getValidationMessages().stream().map(ValidationMessage::getMessage).collect(Collectors.toList()), + Matchers.containsInAnyOrder("$.outer.mixedObject.intValue_missingButError: string found, integer expected", + "$.outer.badArray[1]: integer found, string expected")); + } else { + assertThat(result.getValidationMessages(), Matchers.empty()); + } + assertEquals("{\"outer\":{\"mixedObject\":{\"intValue_present\":11,\"intValue_null\":25,\"intValue_missing\":15,\"intValue_missingButError\":\"thirty-five\"},\"goodArray\":[\"hello\",\"five\"],\"badArray\":[\"hello\",5]}}", + inputNode.toString()); + } + + @Test + void testApplyDefaults2() throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data/walk-data-default.json")); + JsonSchema jsonSchema = createSchema(new ApplyDefaultsStrategy(true, true, false)); + ValidationResult result = jsonSchema.walk(inputNode, true); + assertThat(result.getValidationMessages().stream().map(ValidationMessage::getMessage).collect(Collectors.toList()), + Matchers.containsInAnyOrder("$.outer.mixedObject.intValue_missingButError: string found, integer expected", + "$.outer.goodArray[1]: null found, string expected", + "$.outer.badArray[1]: null found, string expected")); + assertEquals("{\"outer\":{\"mixedObject\":{\"intValue_present\":11,\"intValue_null\":25,\"intValue_missing\":15,\"intValue_missingButError\":\"thirty-five\"},\"goodArray\":[\"hello\",null],\"badArray\":[\"hello\",null]}}", + inputNode.toString()); + } + + @Test + void testApplyDefaults1() throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data/walk-data-default.json")); + JsonSchema jsonSchema = createSchema(new ApplyDefaultsStrategy(true, false, false)); + ValidationResult result = jsonSchema.walk(inputNode, true); + assertThat(result.getValidationMessages().stream().map(ValidationMessage::getMessage).collect(Collectors.toList()), + Matchers.containsInAnyOrder("$.outer.mixedObject.intValue_null: null found, integer expected", + "$.outer.mixedObject.intValue_missingButError: string found, integer expected", + "$.outer.goodArray[1]: null found, string expected", + "$.outer.badArray[1]: null found, string expected")); + assertEquals("{\"outer\":{\"mixedObject\":{\"intValue_present\":11,\"intValue_null\":null,\"intValue_missing\":15,\"intValue_missingButError\":\"thirty-five\"},\"goodArray\":[\"hello\",null],\"badArray\":[\"hello\",null]}}", + inputNode.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { "walkWithNoDefaults", "validateWithApplyAllDefaults"} ) + void testApplyDefaults0(String method) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data/walk-data-default.json")); + JsonNode inputNodeOriginal = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data/walk-data-default.json")); + Set validationMessages; + switch (method) { + case "walkWithNoDefaults": { + JsonSchema jsonSchema = createSchema(new ApplyDefaultsStrategy(false, false, false)); + validationMessages = jsonSchema.walk(inputNode, true).getValidationMessages(); + break; + } + case "validateWithApplyAllDefaults": { + JsonSchema jsonSchema = createSchema(new ApplyDefaultsStrategy(true, true, true)); + validationMessages = jsonSchema.validate(inputNode); + break; + } + default: throw new UnsupportedOperationException(); + } + assertThat(validationMessages.stream().map(ValidationMessage::getMessage).collect(Collectors.toList()), + Matchers.containsInAnyOrder("$.outer.mixedObject.intValue_missing: is missing but it is required", + "$.outer.mixedObject.intValue_null: null found, integer expected", + "$.outer.mixedObject.intValue_missingButError: is missing but it is required", + "$.outer.goodArray[1]: null found, string expected", + "$.outer.badArray[1]: null found, string expected")); + assertEquals(inputNodeOriginal, inputNode); + CollectorContext.getInstance().reset(); // necessary because we are calling both jsonSchema.walk and jsonSchema.validate + } + + @Test + void testIllegalArgumentException() { + try { + new ApplyDefaultsStrategy(false, true, false); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException ignored) { + } + } + + private JsonSchema createSchema(ApplyDefaultsStrategy applyDefaultsStrategy) { + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + SchemaValidatorsConfig schemaValidatorsConfig = new SchemaValidatorsConfig(); + schemaValidatorsConfig.setApplyDefaultsStrategy(applyDefaultsStrategy); + return schemaFactory.getSchema(getClass().getClassLoader().getResourceAsStream("schema/walk-schema-default.json"), schemaValidatorsConfig); + } +} diff --git a/src/test/resources/data/walk-data-default.json b/src/test/resources/data/walk-data-default.json new file mode 100644 index 000000000..3c6da3812 --- /dev/null +++ b/src/test/resources/data/walk-data-default.json @@ -0,0 +1,10 @@ +{ + "outer": { + "mixedObject": { + "intValue_present": 11, + "intValue_null": null + }, + "goodArray": ["hello", null], + "badArray": ["hello", null] + } +} \ No newline at end of file diff --git a/src/test/resources/schema/walk-schema-default.json b/src/test/resources/schema/walk-schema-default.json new file mode 100644 index 000000000..a47aa93ef --- /dev/null +++ b/src/test/resources/schema/walk-schema-default.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema with default values ", + "type": "object", + "properties": { + "outer": { + "type": "object", + "properties": { + "mixedObject": { + "type": "object", + "properties": { + "intValue_present": { + "description": "the test data supplies a value for this attribute so the default is ignored", + "type": "integer", + "default": 5 + }, + "intValue_missing": { + "description": "the test data does not have this attribute, so default should be applied", + "type": "integer", + "default": 15 + }, + "intValue_null": { + "description": "the test data supplies the value null for this attribute so the default should be applied", + "type": "integer", + "default": 25 + }, + "intValue_missingButError": { + "description": "the test data does not have this attribute, so default should be applied, but the default is wrong so there should be an error", + "type": "integer", + "default": "thirty-five" + } + }, + "additionalProperties": false, + "required": [ + "intValue_present", + "intValue_missing", + "intValue_null", + "intValue_missingButError" + ] + }, + "goodArray": { + "type": "array", + "items": { + "description": "if an item in the array is null, then default value should be applied", + "type": "string", + "default": "five" + } + }, + "badArray": { + "type": "array", + "items": { + "description": "if an item in the array is null, then default value should be applied", + "type": "string", + "default": 5 + } + } + }, + "additionalProperties": false, + "required": ["mixedObject", "goodArray", "badArray"] + } + }, + "additionalProperties": false, + "required": ["outer"] +}