Skip to content

Commit fad2a58

Browse files
authored
apply default in objects and arrays (#477)
* apply default in objects and arrays * introduce a class ApplyDefaultsStrategy * ensure that validate function does not apply defaults * add documentation * support default value in referenced property * fix NullPointerException when no applyDefaultStragey is supplied * fix the 4 test errors mentioned: call CollectorContext.getInstance().reset() after each test
1 parent fdae2f8 commit fad2a58

File tree

10 files changed

+416
-13
lines changed

10 files changed

+416
-13
lines changed

doc/walkers.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
### JSON Schema Walkers
22

3-
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.
3+
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.
44

55
Currently, walking is defined at the validator instance level for all the built-in keywords.
66

@@ -237,4 +237,56 @@ Few important points to note about the flow.
237237
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,
238238
Our property listener would be invoked for each of the property defined in the "$ref" schema.
239239
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.
240-
Since the walk method will not be called any property or keyword listeners in the "property schema" will not be invoked.
240+
Since the walk method will not be called any property or keyword listeners in the "property schema" will not be invoked.
241+
242+
243+
### Applying defaults
244+
245+
In some use cases we may want to apply defaults while walking the schema.
246+
To accomplish this, create an ApplyDefaultsStrategy when creating a SchemaValidatorsConfig.
247+
The input object is changed in place, even if validation fails, or a fail-fast or some other exception is thrown.
248+
249+
Here is the order of operations in walker.
250+
1. apply defaults
251+
1. run listeners
252+
1. validate if shouldValidateSchema is true
253+
254+
Suppose the JSON schema is
255+
```json
256+
{
257+
"$schema": "http://json-schema.org/draft-04/schema#",
258+
"title": "Schema with default values ",
259+
"type": "object",
260+
"properties": {
261+
"intValue": {
262+
"type": "integer",
263+
"default": 15,
264+
"minimum": 20
265+
}
266+
},
267+
"required": ["intValue"]
268+
}
269+
```
270+
271+
A JSON file like
272+
```json
273+
{
274+
}
275+
```
276+
277+
would normally fail validation as "intValue" is required.
278+
But if we apply defaults while walking, then required validation passes, and the object is changed in place.
279+
280+
```java
281+
JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4);
282+
SchemaValidatorsConfig schemaValidatorsConfig = new SchemaValidatorsConfig();
283+
schemaValidatorsConfig.setApplyDefaultsStrategy(new ApplyDefaultsStrategy(true, true, true));
284+
JsonSchema jsonSchema = schemaFactory.getSchema(getClass().getClassLoader().getResourceAsStream("schema.json"), schemaValidatorsConfig);
285+
286+
JsonNode inputNode = objectMapper.readTree(getClass().getClassLoader().getResourceAsStream("data.json"));
287+
ValidationResult result = jsonSchema.walk(inputNode, true);
288+
assertThat(result.getValidationMessages(), Matchers.empty());
289+
assertEquals("{\"intValue\":15}", inputNode.toString());
290+
assertThat(result.getValidationMessages().stream().map(ValidationMessage::getMessage).collect(Collectors.toList()),
291+
Matchers.containsInAnyOrder("$.intValue: must have a minimum value of 20."));
292+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.networknt.schema;
2+
3+
public class ApplyDefaultsStrategy {
4+
static final ApplyDefaultsStrategy EMPTY_APPLY_DEFAULTS_STRATEGY = new ApplyDefaultsStrategy(false, false, false);
5+
6+
private final boolean applyPropertyDefaults;
7+
private final boolean applyPropertyDefaultsIfNull;
8+
private final boolean applyArrayDefaults;
9+
10+
/**
11+
* Specify which default values to apply.
12+
* We can apply property defaults only if they are missing or if they are declared to be null in the input json,
13+
* and we can apply array defaults if they are declared to be null in the input json.
14+
*
15+
* <p>Note that the walker changes the input object in place.
16+
* If validation fails, the input object will be changed.
17+
*
18+
* @param applyPropertyDefaults if true then apply defaults inside json objects if the attribute is missing
19+
* @param applyPropertyDefaultsIfNull if true then apply defaults inside json objects if the attribute is explicitly null
20+
* @param applyArrayDefaults if true then apply defaults inside json arrays if the attribute is explicitly null
21+
* @throws IllegalArgumentException if applyPropertyDefaults is false and applyPropertyDefaultsIfNull is true
22+
*/
23+
public ApplyDefaultsStrategy(boolean applyPropertyDefaults, boolean applyPropertyDefaultsIfNull, boolean applyArrayDefaults) {
24+
if (!applyPropertyDefaults && applyPropertyDefaultsIfNull) {
25+
throw new IllegalArgumentException();
26+
}
27+
this.applyPropertyDefaults = applyPropertyDefaults;
28+
this.applyPropertyDefaultsIfNull = applyPropertyDefaultsIfNull;
29+
this.applyArrayDefaults = applyArrayDefaults;
30+
}
31+
32+
public boolean shouldApplyPropertyDefaults() {
33+
return applyPropertyDefaults;
34+
}
35+
36+
public boolean shouldApplyPropertyDefaultsIfNull() {
37+
return applyPropertyDefaultsIfNull;
38+
}
39+
40+
public boolean shouldApplyArrayDefaults() {
41+
return applyArrayDefaults;
42+
}
43+
}

src/main/java/com/networknt/schema/BaseJsonValidator.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,37 @@ public abstract class BaseJsonValidator implements JsonValidator {
3838
private ErrorMessageType errorMessageType;
3939

4040
protected final boolean failFast;
41+
protected final ApplyDefaultsStrategy applyDefaultsStrategy;
4142

4243
public BaseJsonValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema,
4344
ValidatorTypeCode validatorType, ValidationContext validationContext) {
4445
this(schemaPath, schemaNode, parentSchema, validatorType, false,
45-
validationContext.getConfig() != null && validationContext.getConfig().isFailFast());
46+
validationContext.getConfig() != null && validationContext.getConfig().isFailFast(),
47+
validationContext.getConfig() != null ? validationContext.getConfig().getApplyDefaultsStrategy() : null);
4648
}
4749

50+
// TODO: can this be made package private?
51+
@Deprecated // use the BaseJsonValidator below
4852
public BaseJsonValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema,
4953
ValidatorTypeCode validatorType, boolean suppressSubSchemaRetrieval, boolean failFast) {
54+
this(schemaPath, schemaNode, parentSchema, validatorType, false, failFast, null);
55+
}
5056

57+
public BaseJsonValidator(String schemaPath,
58+
JsonNode schemaNode,
59+
JsonSchema parentSchema,
60+
ValidatorTypeCode validatorType,
61+
boolean suppressSubSchemaRetrieval,
62+
boolean failFast,
63+
ApplyDefaultsStrategy applyDefaultsStrategy) {
5164
this.errorMessageType = validatorType;
5265
this.schemaPath = schemaPath;
5366
this.schemaNode = schemaNode;
5467
this.parentSchema = parentSchema;
5568
this.validatorType = validatorType;
5669
this.suppressSubSchemaRetrieval = suppressSubSchemaRetrieval;
5770
this.failFast = failFast;
71+
this.applyDefaultsStrategy = applyDefaultsStrategy != null ? applyDefaultsStrategy : ApplyDefaultsStrategy.EMPTY_APPLY_DEFAULTS_STRATEGY;
5872
}
5973

6074
public String getSchemaPath() {

src/main/java/com/networknt/schema/ItemsValidator.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.networknt.schema;
1818

1919
import com.fasterxml.jackson.databind.JsonNode;
20+
import com.fasterxml.jackson.databind.node.ArrayNode;
2021
import com.networknt.schema.walk.DefaultItemWalkListenerRunner;
2122
import com.networknt.schema.walk.WalkListenerRunner;
2223

@@ -118,9 +119,21 @@ private void doValidate(Set<ValidationMessage> errors, int i, JsonNode node, Jso
118119
@Override
119120
public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at, boolean shouldValidateSchema) {
120121
HashSet<ValidationMessage> validationMessages = new LinkedHashSet<ValidationMessage>();
121-
if (node != null && node.isArray()) {
122+
if (node instanceof ArrayNode) {
123+
ArrayNode arrayNode = (ArrayNode) node;
124+
JsonNode defaultNode = null;
125+
if (applyDefaultsStrategy.shouldApplyArrayDefaults() && schema != null) {
126+
defaultNode = schema.getSchemaNode().get("default");
127+
if (defaultNode != null && defaultNode.isNull()) {
128+
defaultNode = null;
129+
}
130+
}
122131
int i = 0;
123-
for (JsonNode n : node) {
132+
for (JsonNode n : arrayNode) {
133+
if (n.isNull() && defaultNode != null) {
134+
arrayNode.set(i, defaultNode);
135+
n = defaultNode;
136+
}
124137
doWalk(validationMessages, i, n, rootNode, at, shouldValidateSchema);
125138
i++;
126139
}

src/main/java/com/networknt/schema/JsonSchema.java

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919
import java.io.UnsupportedEncodingException;
2020
import java.net.URI;
2121
import java.net.URLDecoder;
22-
import java.sql.Ref;
2322
import java.util.Collections;
23+
import java.util.Comparator;
2424
import java.util.HashMap;
2525
import java.util.Iterator;
2626
import java.util.LinkedHashSet;
2727
import java.util.Map;
2828
import java.util.Map.Entry;
2929
import java.util.Set;
30+
import java.util.TreeMap;
3031
import java.util.regex.Matcher;
3132
import java.util.regex.Pattern;
3233

@@ -37,8 +38,6 @@
3738
import com.networknt.schema.walk.JsonSchemaWalker;
3839
import com.networknt.schema.walk.WalkListenerRunner;
3940

40-
import javax.xml.validation.Schema;
41-
4241
/**
4342
* This is the core of json constraint implementation. It parses json constraint
4443
* file and generates JsonValidators. The class is thread safe, once it is
@@ -80,7 +79,8 @@ public JsonSchema(ValidationContext validationContext, URI baseUri, JsonNode sch
8079
private JsonSchema(ValidationContext validationContext, String schemaPath, URI currentUri, JsonNode schemaNode,
8180
JsonSchema parent, boolean suppressSubSchemaRetrieval) {
8281
super(schemaPath, schemaNode, parent, null, suppressSubSchemaRetrieval,
83-
validationContext.getConfig() != null && validationContext.getConfig().isFailFast());
82+
validationContext.getConfig() != null && validationContext.getConfig().isFailFast(),
83+
validationContext.getConfig() != null ? validationContext.getConfig().getApplyDefaultsStrategy() : null);
8484
this.validationContext = validationContext;
8585
this.idKeyword = validationContext.getMetaSchema().getIdKeyword();
8686
this.currentUri = this.combineCurrentUriWithIds(currentUri, schemaNode);
@@ -208,7 +208,7 @@ private boolean nodeContainsRef(String ref, JsonNode node) {
208208
* used in {@link com.networknt.schema.walk.DefaultKeywordWalkListenerRunner} to derive the keyword.
209209
*/
210210
private Map<String, JsonValidator> read(JsonNode schemaNode) {
211-
Map<String, JsonValidator> validators = new HashMap<String, JsonValidator>();
211+
Map<String, JsonValidator> validators = new TreeMap<>(VALIDATOR_SORT);
212212
if (schemaNode.isBoolean()) {
213213
if (schemaNode.booleanValue()) {
214214
final String customMessage = getCustomMessage(schemaNode, "true");
@@ -239,6 +239,20 @@ private Map<String, JsonValidator> read(JsonNode schemaNode) {
239239
return validators;
240240
}
241241

242+
/**
243+
* A comparator that sorts validators, such such that 'properties' comes before 'required',
244+
* so that we can apply default values before validating required.
245+
*/
246+
private static Comparator<String> VALIDATOR_SORT = (lhs, rhs) -> {
247+
if (lhs.endsWith("/properties")) {
248+
return -1;
249+
}
250+
if (rhs.endsWith("/properties")) {
251+
return 1;
252+
}
253+
return lhs.compareTo(rhs);
254+
};
255+
242256
private String getCustomMessage(JsonNode schemaNode, String pname) {
243257
final JsonSchema parentSchema = getParentSchema();
244258
final JsonNode message = getMessageNode(schemaNode, parentSchema);
@@ -320,7 +334,7 @@ protected ValidationResult validateAndCollect(JsonNode jsonNode, JsonNode rootNo
320334
SchemaValidatorsConfig config = validationContext.getConfig();
321335
// Get the collector context from the thread local.
322336
CollectorContext collectorContext = getCollectorContext();
323-
// Valdiate.
337+
// Validate.
324338
Set<ValidationMessage> errors = validate(jsonNode, rootNode, at);
325339
// 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.
326340
if (config.doLoadCollectors()) {
@@ -374,7 +388,7 @@ public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at,
374388
JsonSchemaWalker jsonWalker = entry.getValue();
375389
String schemaPathWithKeyword = entry.getKey();
376390
try {
377-
// Call all the pre-walk listeners. If atleast one of the pre walk listeners
391+
// Call all the pre-walk listeners. If at least one of the pre walk listeners
378392
// returns SKIP, then skip the walk.
379393
if (keywordWalkListenerRunner.runPreWalkListeners(schemaPathWithKeyword,
380394
node,

src/main/java/com/networknt/schema/PropertiesValidator.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.networknt.schema;
1818

1919
import com.fasterxml.jackson.databind.JsonNode;
20+
import com.fasterxml.jackson.databind.node.ObjectNode;
2021
import com.networknt.schema.walk.DefaultPropertyWalkListenerRunner;
2122
import com.networknt.schema.walk.WalkListenerRunner;
2223
import org.slf4j.Logger;
@@ -52,7 +53,6 @@ public Set<ValidationMessage> validate(JsonNode node, JsonNode rootNode, String
5253
for (Map.Entry<String, JsonSchema> entry : schemas.entrySet()) {
5354
JsonSchema propertySchema = entry.getValue();
5455
JsonNode propertyNode = node.get(entry.getKey());
55-
5656
if (propertyNode != null) {
5757
// check whether this is a complex validator. save the state
5858
boolean isComplex = state.isComplexValidator();
@@ -102,6 +102,9 @@ public Set<ValidationMessage> validate(JsonNode node, JsonNode rootNode, String
102102
@Override
103103
public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at, boolean shouldValidateSchema) {
104104
HashSet<ValidationMessage> validationMessages = new LinkedHashSet<ValidationMessage>();
105+
if (applyDefaultsStrategy.shouldApplyPropertyDefaults()) {
106+
applyPropertyDefaults((ObjectNode) node);
107+
}
105108
if (shouldValidateSchema) {
106109
validationMessages.addAll(validate(node, rootNode, at));
107110
} else {
@@ -113,6 +116,21 @@ public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at,
113116
return validationMessages;
114117
}
115118

119+
private void applyPropertyDefaults(ObjectNode node) {
120+
for (Map.Entry<String, JsonSchema> entry : schemas.entrySet()) {
121+
JsonNode propertyNode = node.get(entry.getKey());
122+
123+
if (propertyNode == null || (applyDefaultsStrategy.shouldApplyPropertyDefaultsIfNull() && propertyNode.isNull())) {
124+
JsonSchema propertySchema = entry.getValue();
125+
JsonNode defaultNode = propertySchema.getSchemaNode().get("default");
126+
if (defaultNode != null && !defaultNode.isNull()) {
127+
// mutate the input json
128+
node.set(entry.getKey(), defaultNode);
129+
}
130+
}
131+
}
132+
}
133+
116134
private void walkSchema(Map.Entry<String, JsonSchema> entry, JsonNode node, JsonNode rootNode, String at,
117135
boolean shouldValidateSchema, Set<ValidationMessage> validationMessages, WalkListenerRunner propertyWalkListenerRunner) {
118136
JsonSchema propertySchema = entry.getValue();

src/main/java/com/networknt/schema/SchemaValidatorsConfig.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ public class SchemaValidatorsConfig {
3737
*/
3838
private boolean failFast;
3939

40+
/**
41+
* When set to true, walker sets nodes that are missing or NullNode to the default value, if any, and mutate the input json.
42+
*/
43+
private ApplyDefaultsStrategy applyDefaultsStrategy;
44+
4045
/**
4146
* When set to true, use ECMA-262 compatible validator
4247
*/
@@ -112,6 +117,14 @@ public boolean isFailFast() {
112117
return this.failFast;
113118
}
114119

120+
public void setApplyDefaultsStrategy(ApplyDefaultsStrategy applyDefaultsStrategy) {
121+
this.applyDefaultsStrategy = applyDefaultsStrategy;
122+
}
123+
124+
public ApplyDefaultsStrategy getApplyDefaultsStrategy() {
125+
return applyDefaultsStrategy;
126+
}
127+
115128
public Map<String, String> getUriMappings() {
116129
// return a copy of the mappings
117130
return new HashMap<String, String>(uriMappings);

0 commit comments

Comments
 (0)