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",