Skip to content

Adds support for $recursiveAnchor and $recursiveRef #835

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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