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 <faron.dutton@insightglobal.com>
diff --git a/doc/compatibility.md b/doc/compatibility.md
index 5ee2b31..292d807 100644
--- a/doc/compatibility.md
+++ b/doc/compatibility.md
@@ -16,8 +16,8 @@
| $dynamicAnchor | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
| $dynamicRef | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
| $id | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
-| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
-| $recursiveRef | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
+| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
+| $recursiveRef | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
| $ref | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
| $vocabulary | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
| additionalItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
@@ -59,13 +59,13 @@
| prefixItems | 🚫 | 🚫 | 🚫 | 🚫 | 🟢 |
| properties | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| propertyNames | 🚫 | 🟢 | 🟢 | 🟢 | 🟢 |
-| readOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
+| readOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |
| required | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| type | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| unevaluatedItems | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
| unevaluatedProperties | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
| uniqueItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
-| writeOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
+| writeOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |
### Semantic Validation (Format)
diff --git a/src/main/java/com/networknt/schema/CollectorContext.java b/src/main/java/com/networknt/schema/CollectorContext.java
index a2bb734..651eca6 100644
--- a/src/main/java/com/networknt/schema/CollectorContext.java
+++ b/src/main/java/com/networknt/schema/CollectorContext.java
@@ -71,8 +71,18 @@
* @return the previous, parent scope
*/
public Scope enterDynamicScope() {
+ return enterDynamicScope(null);
+ }
+
+ /**
+ * Creates a new scope
+ *
+ * @param containingSchema the containing schema
+ * @return the previous, parent scope
+ */
+ public Scope enterDynamicScope(JsonSchema containingSchema) {
Scope parent = this.dynamicScopes.peek();
- this.dynamicScopes.push(newScope());
+ this.dynamicScopes.push(newScope(null != containingSchema ? containingSchema : parent.getContainingSchema()));
return parent;
}
@@ -92,6 +102,28 @@
return this.dynamicScopes.peek();
}
+ public JsonSchema getOutermostSchema() {
+
+ JsonSchema context = getDynamicScope().getContainingSchema();
+ if (null == context) {
+ throw new IllegalStateException("Missing a root schema in the dynamic scope.");
+ }
+
+ JsonSchema lexicalRoot = context.findLexicalRoot();
+ if (lexicalRoot.isDynamicAnchor()) {
+ Iterator<Scope> it = this.dynamicScopes.descendingIterator();
+ while (it.hasNext()) {
+ Scope scope = it.next();
+ JsonSchema containingSchema = scope.getContainingSchema();
+ if (null != containingSchema && containingSchema.isDynamicAnchor()) {
+ return containingSchema;
+ }
+ }
+ }
+
+ return context.findLexicalRoot();
+ }
+
/**
* Identifies which array items have been evaluated.
*
@@ -204,16 +236,18 @@
}
- private Scope newScope() {
- return new Scope(this.disableUnevaluatedItems, this.disableUnevaluatedProperties);
+ private Scope newScope(JsonSchema containingSchema) {
+ return new Scope(this.disableUnevaluatedItems, this.disableUnevaluatedProperties, containingSchema);
}
private Scope newTopScope() {
- return new Scope(true, this.disableUnevaluatedItems, this.disableUnevaluatedProperties);
+ return new Scope(true, this.disableUnevaluatedItems, this.disableUnevaluatedProperties, null);
}
public static class Scope {
+ private final JsonSchema containingSchema;
+
/**
* Used to track which array items have been evaluated.
*/
@@ -226,12 +260,13 @@
private final boolean top;
- Scope(boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties) {
- this(false, disableUnevaluatedItems, disableUnevaluatedProperties);
+ Scope(boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties, JsonSchema containingSchema) {
+ this(false, disableUnevaluatedItems, disableUnevaluatedProperties, containingSchema);
}
- Scope(boolean top, boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties) {
+ Scope(boolean top, boolean disableUnevaluatedItems, boolean disableUnevaluatedProperties, JsonSchema containingSchema) {
this.top = top;
+ this.containingSchema = containingSchema;
this.evaluatedItems = newCollection(disableUnevaluatedItems);
this.evaluatedProperties = newCollection(disableUnevaluatedProperties);
}
@@ -266,6 +301,10 @@
return this.top;
}
+ public JsonSchema getContainingSchema() {
+ return this.containingSchema;
+ }
+
/**
* Identifies which array items have been evaluated.
*
diff --git a/src/main/java/com/networknt/schema/JsonMetaSchema.java b/src/main/java/com/networknt/schema/JsonMetaSchema.java
index b5ed0c7..83bae24 100644
--- a/src/main/java/com/networknt/schema/JsonMetaSchema.java
+++ b/src/main/java/com/networknt/schema/JsonMetaSchema.java
@@ -218,6 +218,10 @@
.addFormats(formatKeyword.getFormats());
}
+ public String getIdKeyword() {
+ return this.idKeyword;
+ }
+
public String readId(JsonNode schemaNode) {
return readText(schemaNode, this.idKeyword);
}
diff --git a/src/main/java/com/networknt/schema/JsonSchema.java b/src/main/java/com/networknt/schema/JsonSchema.java
index 7e5c018..423d54a 100644
--- a/src/main/java/com/networknt/schema/JsonSchema.java
+++ b/src/main/java/com/networknt/schema/JsonSchema.java
@@ -44,6 +44,7 @@
private Map<String, JsonValidator> validators;
private final JsonMetaSchema metaSchema;
private boolean validatorsLoaded = false;
+ private boolean dynamicAnchor = false;
/**
* This is the current uri of this schema. This uri could refer to the uri of this schema's file
@@ -55,6 +56,7 @@
* 'id' would still be able to specify an absolute uri.
*/
private URI currentUri;
+ private boolean hasId = false;
private JsonValidator requiredValidator = null;
private TypeValidator typeValidator;
@@ -222,6 +224,16 @@
return node;
}
+ // This represents the lexical scope
+ JsonSchema findLexicalRoot() {
+ JsonSchema ancestor = this;
+ while (!ancestor.hasId) {
+ if (null == ancestor.getParentSchema()) break;
+ ancestor = ancestor.getParentSchema();
+ }
+ return ancestor;
+ }
+
public JsonSchema findAncestor() {
JsonSchema ancestor = this;
if (this.getParentSchema() != null) {
@@ -255,6 +267,9 @@
validators.put(getSchemaPath() + "/false", validator);
}
} else {
+
+ this.hasId = schemaNode.has(this.validationContext.getMetaSchema().getIdKeyword());
+
JsonValidator refValidator = null;
Iterator<String> pnames = schemaNode.fieldNames();
@@ -263,6 +278,20 @@
JsonNode nodeToUse = pname.equals("if") ? schemaNode : schemaNode.get(pname);
String customMessage = getCustomMessage(schemaNode, pname);
+ if ("$recursiveAnchor".equals(pname)) {
+ if (!nodeToUse.isBoolean()) {
+ throw new JsonSchemaException(
+ ValidationMessage.of(
+ "$recursiveAnchor",
+ CustomErrorMessageType.of("internal.invalidRecursiveAnchor"),
+ new MessageFormat("{0}: The value of a $recursiveAnchor must be a Boolean literal but is {1}"),
+ schemaPath, schemaPath, nodeToUse.getNodeType().toString()
+ )
+ );
+ }
+ this.dynamicAnchor = nodeToUse.booleanValue();
+ }
+
JsonValidator validator = this.validationContext.newValidator(getSchemaPath(), pname, nodeToUse, this, customMessage);
if (validator != null) {
validators.put(getSchemaPath() + "/" + pname, validator);
@@ -359,7 +388,7 @@
for (JsonValidator v : getValidators().values()) {
Set<ValidationMessage> results = Collections.emptySet();
- Scope parentScope = collectorContext.enterDynamicScope();
+ Scope parentScope = collectorContext.enterDynamicScope(this);
try {
results = v.validate(jsonNode, rootNode, at);
} finally {
@@ -606,4 +635,8 @@
}
}
+ public boolean isDynamicAnchor() {
+ return this.dynamicAnchor;
+ }
+
}
diff --git a/src/main/java/com/networknt/schema/RecursiveRefValidator.java b/src/main/java/com/networknt/schema/RecursiveRefValidator.java
new file mode 100644
index 0000000..358562e
--- /dev/null
+++ b/src/main/java/com/networknt/schema/RecursiveRefValidator.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2016 Network New Technologies Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.networknt.schema;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.networknt.schema.CollectorContext.Scope;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.text.MessageFormat;
+import java.util.*;
+
+public class RecursiveRefValidator extends BaseJsonValidator {
+ private static final Logger logger = LoggerFactory.getLogger(RecursiveRefValidator.class);
+
+ public RecursiveRefValidator(String schemaPath, JsonNode schemaNode, JsonSchema parentSchema, ValidationContext validationContext) {
+ super(schemaPath, schemaNode, parentSchema, ValidatorTypeCode.RECURSIVE_REF, validationContext);
+
+ String refValue = schemaNode.asText();
+ if (!"#".equals(refValue)) {
+ throw new JsonSchemaException(
+ ValidationMessage.of(
+ ValidatorTypeCode.RECURSIVE_REF.getValue(),
+ CustomErrorMessageType.of("internal.invalidRecursiveRef"),
+ new MessageFormat("{0}: The value of a $recursiveRef must be '#' but is '{1}'"),
+ schemaPath, schemaPath, refValue
+ )
+ );
+ }
+ }
+
+ @Override
+ public Set<ValidationMessage> validate(JsonNode node, JsonNode rootNode, String at) {
+ CollectorContext collectorContext = CollectorContext.getInstance();
+
+ Set<ValidationMessage> errors = new HashSet<>();
+
+ Scope parentScope = collectorContext.enterDynamicScope();
+ try {
+ debug(logger, node, rootNode, at);
+
+ JsonSchema schema = collectorContext.getOutermostSchema();
+ if (null != schema) {
+ // This is important because if we use same JsonSchemaFactory for creating multiple JSONSchema instances,
+ // these schemas will be cached along with config. We have to replace the config for cached $ref references
+ // with the latest config. Reset the config.
+ schema.getValidationContext().setConfig(getParentSchema().getValidationContext().getConfig());
+ errors = schema.validate(node, rootNode, at);
+ }
+ } finally {
+ Scope scope = collectorContext.exitDynamicScope();
+ if (errors.isEmpty()) {
+ parentScope.mergeWith(scope);
+ }
+ }
+
+ return errors;
+ }
+
+ @Override
+ public Set<ValidationMessage> walk(JsonNode node, JsonNode rootNode, String at, boolean shouldValidateSchema) {
+ CollectorContext collectorContext = CollectorContext.getInstance();
+
+ Set<ValidationMessage> errors = new HashSet<>();
+
+ Scope parentScope = collectorContext.enterDynamicScope();
+ try {
+ debug(logger, node, rootNode, at);
+
+ JsonSchema schema = collectorContext.getOutermostSchema();
+ if (null != schema) {
+ // This is important because if we use same JsonSchemaFactory for creating multiple JSONSchema instances,
+ // these schemas will be cached along with config. We have to replace the config for cached $ref references
+ // with the latest config. Reset the config.
+ schema.getValidationContext().setConfig(getParentSchema().getValidationContext().getConfig());
+ errors = schema.walk(node, rootNode, at, shouldValidateSchema);
+ }
+ } finally {
+ Scope scope = collectorContext.exitDynamicScope();
+ if (shouldValidateSchema) {
+ if (errors.isEmpty()) {
+ parentScope.mergeWith(scope);
+ }
+ }
+ }
+
+ return errors;
+ }
+
+}
diff --git a/src/main/java/com/networknt/schema/ValidatorTypeCode.java b/src/main/java/com/networknt/schema/ValidatorTypeCode.java
index 66be963..f3a9616 100644
--- a/src/main/java/com/networknt/schema/ValidatorTypeCode.java
+++ b/src/main/java/com/networknt/schema/ValidatorTypeCode.java
@@ -32,7 +32,8 @@
MinV7(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
MaxV201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V4, SpecVersion.VersionFlag.V6, SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909 }),
MinV201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
- MinV202012(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V202012 });
+ MinV202012(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V202012 }),
+ V201909(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V201909 });
private final EnumSet<VersionFlag> versions;
@@ -48,7 +49,6 @@
}
}
-// NOTE: Missing error codes 1027
public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
ADDITIONAL_PROPERTIES("additionalProperties", "1001", AdditionalPropertiesValidator.class, VersionCode.AllVersions),
ALL_OF("allOf", "1002", AllOfValidator.class, VersionCode.AllVersions),
@@ -94,6 +94,7 @@
PROPERTIES("properties", "1025", PropertiesValidator.class, VersionCode.AllVersions),
PROPERTYNAMES("propertyNames", "1044", PropertyNamesValidator.class, VersionCode.MinV6),
READ_ONLY("readOnly", "1032", ReadOnlyValidator.class, VersionCode.MinV7),
+ RECURSIVE_REF("$recursiveRef", "1050", RecursiveRefValidator.class, VersionCode.V201909),
REF("$ref", "1026", RefValidator.class, VersionCode.AllVersions),
REQUIRED("required", "1028", RequiredValidator.class, VersionCode.AllVersions),
TRUE("true", "1040", TrueValidator.class, VersionCode.MinV6),
diff --git a/src/main/java/com/networknt/schema/Version201909.java b/src/main/java/com/networknt/schema/Version201909.java
index 8ccc85d..6410bd9 100644
--- a/src/main/java/com/networknt/schema/Version201909.java
+++ b/src/main/java/com/networknt/schema/Version201909.java
@@ -18,7 +18,9 @@
.addKeywords(ValidatorTypeCode.getNonFormatKeywords(SpecVersion.VersionFlag.V201909))
// keywords that may validly exist, but have no validation aspect to them
.addKeywords(Arrays.asList(
+ new NonValidationKeyword("$recursiveAnchor"),
new NonValidationKeyword("$schema"),
+ new NonValidationKeyword("$vocabulary"),
new NonValidationKeyword("$id"),
new NonValidationKeyword("title"),
new NonValidationKeyword("description"),
diff --git a/src/test/java/com/networknt/schema/JsonSchemaTestSuiteTest.java b/src/test/java/com/networknt/schema/JsonSchemaTestSuiteTest.java
index 68954dd..034cc1a 100644
--- a/src/test/java/com/networknt/schema/JsonSchemaTestSuiteTest.java
+++ b/src/test/java/com/networknt/schema/JsonSchemaTestSuiteTest.java
@@ -79,15 +79,11 @@
private void disableV201909Tests() {
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/anchor.json"), "Unsupported behavior");
- this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/defs.json"), "Unsupported behavior");
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/id.json"), "Unsupported behavior");
- this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/recursiveRef.json"), "Unsupported behavior");
this.disabled.put(Paths.get("src/test/suite/tests/draft2019-09/vocabulary.json"), "Unsupported behavior");
}
private void disableV7Tests() {
- this.disabled.put(Paths.get("src/test/suite/tests/draft7/anchor.json"), "Unsupported behavior");
- this.disabled.put(Paths.get("src/test/suite/tests/draft7/defs.json"), "Unsupported behavior");
this.disabled.put(Paths.get("src/test/suite/tests/draft7/optional/content.json"), "Unsupported behavior");
}
diff --git a/src/test/suite/tests/draft2019-09/recursiveRef.json b/src/test/suite/tests/draft2019-09/recursiveRef.json
index 22b47e7..600b4a7 100644
--- a/src/test/suite/tests/draft2019-09/recursiveRef.json
+++ b/src/test/suite/tests/draft2019-09/recursiveRef.json
@@ -348,6 +348,8 @@
"$ref": "recursiveRef8_inner.json"
}
},
+ "disabled": true,
+ "reason": "Schema resources are currently unsupported. See #503",
"tests": [
{
"description": "recurse to anyLeafNode - floats are allowed",
@@ -392,6 +394,8 @@
"$ref": "main.json#/$defs/inner"
}
},
+ "disabled": true,
+ "reason": "Schema resources are currently unsupported. See #503",
"tests": [
{
"description": "numeric node",