Skip to content

Commit 5703036

Browse files
fduttonFaron Dutton
and
Faron Dutton
authored
Adds support for $recursiveAnchor and $recursiveRef (#835)
* Adds support for $recursiveAnchor and $recursiveRef Resolves #507 * Updates documentation on compliance with the standards. --------- Co-authored-by: Faron Dutton <[email protected]>
1 parent 4d51a58 commit 5703036

File tree

9 files changed

+201
-18
lines changed

9 files changed

+201
-18
lines changed

doc/compatibility.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
| $dynamicAnchor | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
1717
| $dynamicRef | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
1818
| $id | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
19-
| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
20-
| $recursiveRef | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
19+
| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
20+
| $recursiveRef | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
2121
| $ref | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
2222
| $vocabulary | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
2323
| additionalItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
@@ -59,13 +59,13 @@
5959
| prefixItems | 🚫 | 🚫 | 🚫 | 🚫 | 🟢 |
6060
| properties | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
6161
| propertyNames | 🚫 | 🟢 | 🟢 | 🟢 | 🟢 |
62-
| readOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
62+
| readOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |
6363
| required | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
6464
| type | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
6565
| unevaluatedItems | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
6666
| unevaluatedProperties | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
6767
| uniqueItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
68-
| writeOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
68+
| writeOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |
6969

7070
### Semantic Validation (Format)
7171

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

+46-7
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,18 @@ public CollectorContext(boolean disableUnevaluatedItems, boolean disableUnevalua
7171
* @return the previous, parent scope
7272
*/
7373
public Scope enterDynamicScope() {
74+
return enterDynamicScope(null);
75+
}
76+
77+
/**
78+
* Creates a new scope
79+
*
80+
* @param containingSchema the containing schema
81+
* @return the previous, parent scope
82+
*/
83+
public Scope enterDynamicScope(JsonSchema containingSchema) {
7484
Scope parent = this.dynamicScopes.peek();
75-
this.dynamicScopes.push(newScope());
85+
this.dynamicScopes.push(newScope(null != containingSchema ? containingSchema : parent.getContainingSchema()));
7686
return parent;
7787
}
7888

@@ -92,6 +102,28 @@ public Scope getDynamicScope() {
92102
return this.dynamicScopes.peek();
93103
}
94104

105+
public JsonSchema getOutermostSchema() {
106+
107+
JsonSchema context = getDynamicScope().getContainingSchema();
108+
if (null == context) {
109+
throw new IllegalStateException("Missing a root schema in the dynamic scope.");
110+
}
111+
112+
JsonSchema lexicalRoot = context.findLexicalRoot();
113+
if (lexicalRoot.isDynamicAnchor()) {
114+
Iterator<Scope> it = this.dynamicScopes.descendingIterator();
115+
while (it.hasNext()) {
116+
Scope scope = it.next();
117+
JsonSchema containingSchema = scope.getContainingSchema();
118+
if (null != containingSchema && containingSchema.isDynamicAnchor()) {
119+
return containingSchema;
120+
}
121+
}
122+
}
123+
124+
return context.findLexicalRoot();
125+
}
126+
95127
/**
96128
* Identifies which array items have been evaluated.
97129
*
@@ -204,16 +236,18 @@ void loadCollectors() {
204236

205237
}
206238

207-
private Scope newScope() {
208-
return new Scope(this.disableUnevaluatedItems, this.disableUnevaluatedProperties);
239+
private Scope newScope(JsonSchema containingSchema) {
240+
return new Scope(this.disableUnevaluatedItems, this.disableUnevaluatedProperties, containingSchema);
209241
}
210242

211243
private Scope newTopScope() {
212-
return new Scope(true, this.disableUnevaluatedItems, this.disableUnevaluatedProperties);
244+
return new Scope(true, this.disableUnevaluatedItems, this.disableUnevaluatedProperties, null);
213245
}
214246

215247
public static class Scope {
216248

249+
private final JsonSchema containingSchema;
250+
217251
/**
218252
* Used to track which array items have been evaluated.
219253
*/
@@ -226,12 +260,13 @@ public static class Scope {
226260

227261
private final boolean top;
228262

229-
Scope(boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties) {
230-
this(false, disableUnevaluatedItems, disableUnevaluatedProperties);
263+
Scope(boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties, JsonSchema containingSchema) {
264+
this(false, disableUnevaluatedItems, disableUnevaluatedProperties, containingSchema);
231265
}
232266

233-
Scope(boolean top, boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties) {
267+
Scope(boolean top, boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties, JsonSchema containingSchema) {
234268
this.top = top;
269+
this.containingSchema = containingSchema;
235270
this.evaluatedItems = newCollection(disableUnevaluatedItems);
236271
this.evaluatedProperties = newCollection(disableUnevaluatedProperties);
237272
}
@@ -266,6 +301,10 @@ public boolean isTop() {
266301
return this.top;
267302
}
268303

304+
public JsonSchema getContainingSchema() {
305+
return this.containingSchema;
306+
}
307+
269308
/**
270309
* Identifies which array items have been evaluated.
271310
*

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

+4
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ public static Builder builder(String uri, JsonMetaSchema blueprint) {
218218
.addFormats(formatKeyword.getFormats());
219219
}
220220

221+
public String getIdKeyword() {
222+
return this.idKeyword;
223+
}
224+
221225
public String readId(JsonNode schemaNode) {
222226
return readText(schemaNode, this.idKeyword);
223227
}

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

+34-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public class JsonSchema extends BaseJsonValidator {
4444
private Map<String, JsonValidator> validators;
4545
private final JsonMetaSchema metaSchema;
4646
private boolean validatorsLoaded = false;
47+
private boolean dynamicAnchor = false;
4748

4849
/**
4950
* This is the current uri of this schema. This uri could refer to the uri of this schema's file
@@ -55,6 +56,7 @@ public class JsonSchema extends BaseJsonValidator {
5556
* 'id' would still be able to specify an absolute uri.
5657
*/
5758
private URI currentUri;
59+
private boolean hasId = false;
5860
private JsonValidator requiredValidator = null;
5961
private TypeValidator typeValidator;
6062

@@ -222,6 +224,16 @@ public JsonNode getRefSchemaNode(String ref) {
222224
return node;
223225
}
224226

227+
// This represents the lexical scope
228+
JsonSchema findLexicalRoot() {
229+
JsonSchema ancestor = this;
230+
while (!ancestor.hasId) {
231+
if (null == ancestor.getParentSchema()) break;
232+
ancestor = ancestor.getParentSchema();
233+
}
234+
return ancestor;
235+
}
236+
225237
public JsonSchema findAncestor() {
226238
JsonSchema ancestor = this;
227239
if (this.getParentSchema() != null) {
@@ -255,6 +267,9 @@ private Map<String, JsonValidator> read(JsonNode schemaNode) {
255267
validators.put(getSchemaPath() + "/false", validator);
256268
}
257269
} else {
270+
271+
this.hasId = schemaNode.has(this.validationContext.getMetaSchema().getIdKeyword());
272+
258273
JsonValidator refValidator = null;
259274

260275
Iterator<String> pnames = schemaNode.fieldNames();
@@ -263,6 +278,20 @@ private Map<String, JsonValidator> read(JsonNode schemaNode) {
263278
JsonNode nodeToUse = pname.equals("if") ? schemaNode : schemaNode.get(pname);
264279
String customMessage = getCustomMessage(schemaNode, pname);
265280

281+
if ("$recursiveAnchor".equals(pname)) {
282+
if (!nodeToUse.isBoolean()) {
283+
throw new JsonSchemaException(
284+
ValidationMessage.of(
285+
"$recursiveAnchor",
286+
CustomErrorMessageType.of("internal.invalidRecursiveAnchor"),
287+
new MessageFormat("{0}: The value of a $recursiveAnchor must be a Boolean literal but is {1}"),
288+
schemaPath, schemaPath, nodeToUse.getNodeType().toString()
289+
)
290+
);
291+
}
292+
this.dynamicAnchor = nodeToUse.booleanValue();
293+
}
294+
266295
JsonValidator validator = this.validationContext.newValidator(getSchemaPath(), pname, nodeToUse, this, customMessage);
267296
if (validator != null) {
268297
validators.put(getSchemaPath() + "/" + pname, validator);
@@ -359,7 +388,7 @@ public Set<ValidationMessage> validate(JsonNode jsonNode, JsonNode rootNode, Str
359388
for (JsonValidator v : getValidators().values()) {
360389
Set<ValidationMessage> results = Collections.emptySet();
361390

362-
Scope parentScope = collectorContext.enterDynamicScope();
391+
Scope parentScope = collectorContext.enterDynamicScope(this);
363392
try {
364393
results = v.validate(jsonNode, rootNode, at);
365394
} finally {
@@ -606,4 +635,8 @@ public void initializeValidators() {
606635
}
607636
}
608637

638+
public boolean isDynamicAnchor() {
639+
return this.dynamicAnchor;
640+
}
641+
609642
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright (c) 2016 Network New Technologies Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.networknt.schema;
18+
19+
import com.fasterxml.jackson.databind.JsonNode;
20+
import com.networknt.schema.CollectorContext.Scope;
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
23+
24+
import java.text.MessageFormat;
25+
import java.util.*;
26+
27+
public class RecursiveRefValidator extends BaseJsonValidator {
28+
private static final Logger logger = LoggerFactory.getLogger(RecursiveRefValidator.class);
29+
30+
public RecursiveRefValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema, ValidationContext validationContext) {
31+
super(schemaPath, schemaNode, parentSchema, ValidatorTypeCode.RECURSIVE_REF, validationContext);
32+
33+
String refValue = schemaNode.asText();
34+
if (!"#".equals(refValue)) {
35+
throw new JsonSchemaException(
36+
ValidationMessage.of(
37+
ValidatorTypeCode.RECURSIVE_REF.getValue(),
38+
CustomErrorMessageType.of("internal.invalidRecursiveRef"),
39+
new MessageFormat("{0}: The value of a $recursiveRef must be '#' but is '{1}'"),
40+
schemaPath, schemaPath, refValue
41+
)
42+
);
43+
}
44+
}
45+
46+
@Override
47+
public Set<ValidationMessage> validate(JsonNode node, JsonNode rootNode, String at) {
48+
CollectorContext collectorContext = CollectorContext.getInstance();
49+
50+
Set<ValidationMessage> errors = new HashSet<>();
51+
52+
Scope parentScope = collectorContext.enterDynamicScope();
53+
try {
54+
debug(logger, node, rootNode, at);
55+
56+
JsonSchema schema = collectorContext.getOutermostSchema();
57+
if (null != schema) {
58+
// This is important because if we use same JsonSchemaFactory for creating multiple JSONSchema instances,
59+
// these schemas will be cached along with config. We have to replace the config for cached $ref references
60+
// with the latest config. Reset the config.
61+
schema.getValidationContext().setConfig(getParentSchema().getValidationContext().getConfig());
62+
errors = schema.validate(node, rootNode, at);
63+
}
64+
} finally {
65+
Scope scope = collectorContext.exitDynamicScope();
66+
if (errors.isEmpty()) {
67+
parentScope.mergeWith(scope);
68+
}
69+
}
70+
71+
return errors;
72+
}
73+
74+
@Override
75+
public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at, boolean shouldValidateSchema) {
76+
CollectorContext collectorContext = CollectorContext.getInstance();
77+
78+
Set<ValidationMessage> errors = new HashSet<>();
79+
80+
Scope parentScope = collectorContext.enterDynamicScope();
81+
try {
82+
debug(logger, node, rootNode, at);
83+
84+
JsonSchema schema = collectorContext.getOutermostSchema();
85+
if (null != schema) {
86+
// This is important because if we use same JsonSchemaFactory for creating multiple JSONSchema instances,
87+
// these schemas will be cached along with config. We have to replace the config for cached $ref references
88+
// with the latest config. Reset the config.
89+
schema.getValidationContext().setConfig(getParentSchema().getValidationContext().getConfig());
90+
errors = schema.walk(node, rootNode, at, shouldValidateSchema);
91+
}
92+
} finally {
93+
Scope scope = collectorContext.exitDynamicScope();
94+
if (shouldValidateSchema) {
95+
if (errors.isEmpty()) {
96+
parentScope.mergeWith(scope);
97+
}
98+
}
99+
}
100+
101+
return errors;
102+
}
103+
104+
}

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ enum VersionCode {
3232
MinV7(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
3333
MaxV201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V4, SpecVersion.VersionFlag.V6, SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909 }),
3434
MinV201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
35-
MinV202012(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V202012 });
35+
MinV202012(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V202012 }),
36+
V201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V201909 });
3637

3738
private final EnumSet<VersionFlag> versions;
3839

@@ -48,7 +49,6 @@ EnumSet<VersionFlag> getVersions() {
4849
}
4950
}
5051

51-
// NOTE: Missing error codes 1027
5252
public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
5353
ADDITIONAL_PROPERTIES("additionalProperties", "1001", AdditionalPropertiesValidator.class, VersionCode.AllVersions),
5454
ALL_OF("allOf", "1002", AllOfValidator.class, VersionCode.AllVersions),
@@ -94,6 +94,7 @@ public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
9494
PROPERTIES("properties", "1025", PropertiesValidator.class, VersionCode.AllVersions),
9595
PROPERTYNAMES("propertyNames", "1044", PropertyNamesValidator.class, VersionCode.MinV6),
9696
READ_ONLY("readOnly", "1032", ReadOnlyValidator.class, VersionCode.MinV7),
97+
RECURSIVE_REF("$recursiveRef", "1050", RecursiveRefValidator.class, VersionCode.V201909),
9798
REF("$ref", "1026", RefValidator.class, VersionCode.AllVersions),
9899
REQUIRED("required", "1028", RequiredValidator.class, VersionCode.AllVersions),
99100
TRUE("true", "1040", TrueValidator.class, VersionCode.MinV6),

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

+2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ public JsonMetaSchema getInstance() {
1818
.addKeywords(ValidatorTypeCode.getNonFormatKeywords(SpecVersion.VersionFlag.V201909))
1919
// keywords that may validly exist, but have no validation aspect to them
2020
.addKeywords(Arrays.asList(
21+
new NonValidationKeyword("$recursiveAnchor"),
2122
new NonValidationKeyword("$schema"),
23+
new NonValidationKeyword("$vocabulary"),
2224
new NonValidationKeyword("$id"),
2325
new NonValidationKeyword("title"),
2426
new NonValidationKeyword("description"),

src/test/java/com/networknt/schema/JsonSchemaTestSuiteTest.java

-4
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,11 @@ private void disableV202012Tests() {
7979

8080
private void disableV201909Tests() {
8181
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/anchor.json"), "Unsupported behavior");
82-
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/defs.json"), "Unsupported behavior");
8382
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/id.json"), "Unsupported behavior");
84-
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/recursiveRef.json"), "Unsupported behavior");
8583
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/vocabulary.json"), "Unsupported behavior");
8684
}
8785

8886
private void disableV7Tests() {
89-
this.disabled.put(Paths.get("src/test/suite/tests/draft7/anchor.json"), "Unsupported behavior");
90-
this.disabled.put(Paths.get("src/test/suite/tests/draft7/defs.json"), "Unsupported behavior");
9187
this.disabled.put(Paths.get("src/test/suite/tests/draft7/optional/content.json"), "Unsupported behavior");
9288
}
9389

src/test/suite/tests/draft2019-09/recursiveRef.json

+4
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@
348348
"$ref": "recursiveRef8_inner.json"
349349
}
350350
},
351+
"disabled": true,
352+
"reason": "Schema resources are currently unsupported. See #503",
351353
"tests": [
352354
{
353355
"description": "recurse to anyLeafNode - floats are allowed",
@@ -392,6 +394,8 @@
392394
"$ref": "main.json#/$defs/inner"
393395
}
394396
},
397+
"disabled": true,
398+
"reason": "Schema resources are currently unsupported. See #503",
395399
"tests": [
396400
{
397401
"description": "numeric node",

0 commit comments

Comments
 (0)