Skip to content

Commit

Permalink
Adds support for $recursiveAnchor and $recursiveRef (#835)
Browse files Browse the repository at this point in the history
* Adds support for $recursiveAnchor and $recursiveRef

Resolves #507

* Updates documentation on compliance with the standards.

---------

Co-authored-by: Faron Dutton <[email protected]>
  • Loading branch information
fdutton and Faron Dutton authored Jul 3, 2023
1 parent 4d51a58 commit 5703036
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 18 deletions.
8 changes: 4 additions & 4 deletions doc/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
| $dynamicAnchor | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
| $dynamicRef | 🚫 | 🚫 | 🚫 | 🚫 | 🔴 |
| $id | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
| $recursiveRef | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
| $recursiveAnchor | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
| $recursiveRef | 🚫 | 🚫 | 🚫 | 🟢 | 🚫 |
| $ref | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
| $vocabulary | 🚫 | 🚫 | 🚫 | 🔴 | 🔴 |
| additionalItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
Expand Down Expand Up @@ -59,13 +59,13 @@
| prefixItems | 🚫 | 🚫 | 🚫 | 🚫 | 🟢 |
| properties | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| propertyNames | 🚫 | 🟢 | 🟢 | 🟢 | 🟢 |
| readOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
| readOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |
| required | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| type | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| unevaluatedItems | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
| unevaluatedProperties | 🚫 | 🚫 | 🚫 | 🟢 | 🟢 |
| uniqueItems | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| writeOnly | 🚫 | 🚫 | 🔴 | 🔴 | 🔴 |
| writeOnly | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |

### Semantic Validation (Format)

Expand Down
53 changes: 46 additions & 7 deletions src/main/java/com/networknt/schema/CollectorContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,18 @@ public CollectorContext(boolean disableUnevaluatedItems, boolean disableUnevalua
* @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;
}

Expand All @@ -92,6 +102,28 @@ public Scope getDynamicScope() {
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.
*
Expand Down Expand Up @@ -204,16 +236,18 @@ void loadCollectors() {

}

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.
*/
Expand All @@ -226,12 +260,13 @@ public static class Scope {

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);
}
Expand Down Expand Up @@ -266,6 +301,10 @@ public boolean isTop() {
return this.top;
}

public JsonSchema getContainingSchema() {
return this.containingSchema;
}

/**
* Identifies which array items have been evaluated.
*
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/networknt/schema/JsonMetaSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ public static Builder builder(String uri, JsonMetaSchema blueprint) {
.addFormats(formatKeyword.getFormats());
}

public String getIdKeyword() {
return this.idKeyword;
}

public String readId(JsonNode schemaNode) {
return readText(schemaNode, this.idKeyword);
}
Expand Down
35 changes: 34 additions & 1 deletion src/main/java/com/networknt/schema/JsonSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public class JsonSchema extends BaseJsonValidator {
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
Expand All @@ -55,6 +56,7 @@ public class JsonSchema extends BaseJsonValidator {
* 'id' would still be able to specify an absolute uri.
*/
private URI currentUri;
private boolean hasId = false;
private JsonValidator requiredValidator = null;
private TypeValidator typeValidator;

Expand Down Expand Up @@ -222,6 +224,16 @@ public JsonNode getRefSchemaNode(String ref) {
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) {
Expand Down Expand Up @@ -255,6 +267,9 @@ private Map<String, JsonValidator> read(JsonNode schemaNode) {
validators.put(getSchemaPath() + "/false", validator);
}
} else {

this.hasId = schemaNode.has(this.validationContext.getMetaSchema().getIdKeyword());

JsonValidator refValidator = null;

Iterator<String> pnames = schemaNode.fieldNames();
Expand All @@ -263,6 +278,20 @@ private Map<String, JsonValidator> read(JsonNode schemaNode) {
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);
Expand Down Expand Up @@ -359,7 +388,7 @@ public Set<ValidationMessage> validate(JsonNode jsonNode, JsonNode rootNode, Str
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 {
Expand Down Expand Up @@ -606,4 +635,8 @@ public void initializeValidators() {
}
}

public boolean isDynamicAnchor() {
return this.dynamicAnchor;
}

}
104 changes: 104 additions & 0 deletions src/main/java/com/networknt/schema/RecursiveRefValidator.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
5 changes: 3 additions & 2 deletions src/main/java/com/networknt/schema/ValidatorTypeCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ enum VersionCode {
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;

Expand All @@ -48,7 +49,6 @@ EnumSet<VersionFlag> getVersions() {
}
}

// 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),
Expand Down Expand Up @@ -94,6 +94,7 @@ public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
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),
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/networknt/schema/Version201909.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public JsonMetaSchema getInstance() {
.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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,11 @@ private void disableV202012Tests() {

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");
}

Expand Down
4 changes: 4 additions & 0 deletions src/test/suite/tests/draft2019-09/recursiveRef.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -392,6 +394,8 @@
"$ref": "main.json#/$defs/inner"
}
},
"disabled": true,
"reason": "Schema resources are currently unsupported. See #503",
"tests": [
{
"description": "numeric node",
Expand Down

0 comments on commit 5703036

Please sign in to comment.