Repository: networknt/json-schema-validator
Pull request #625 (2022-11-21 14:45:17)

Add support for subschema references in getSchema(URI)
An attempt at a fix for #619. 


Patch:
diff --git a/pom.xml b/pom.xml
index 745d386..1f9c365 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <groupId>com.networknt</groupId>
     <artifactId>json-schema-validator</artifactId>
-    <version>1.0.73</version>
+    <version>1.0.74-SNAPSHOT</version>
     <packaging>bundle</packaging>
     <description>A json schema validator that supports draft v4, v6, v7, v2019-09 and v2020-12</description>
     <url>https://github.com/networknt/json-schema-validator</url>
@@ -170,7 +170,7 @@
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
-                <version>4.2.1</version>
+                <version>5.1.8</version>
                 <extensions>true</extensions>
                 <configuration>
                     <instructions>
diff --git a/src/main/java/com/networknt/schema/AllOfValidator.java b/src/main/java/com/networknt/schema/AllOfValidator.java
index cd3c3df..07da111 100644
--- a/src/main/java/com/networknt/schema/AllOfValidator.java
+++ b/src/main/java/com/networknt/schema/AllOfValidator.java
@@ -34,7 +34,7 @@ public class AllOfValidator extends BaseJsonValidator implements JsonValidator {
         int size = schemaNode.size();
         for (int i = 0; i < size; i++) {
             schemas.add(new JsonSchema(validationContext,
-                                       getValidatorType().getValue(),
+                            parentSchema.getSchemaPath() + "/" + getValidatorType().getValue(),
                                        parentSchema.getCurrentUri(),
                                        schemaNode.get(i),
                                        parentSchema));
diff --git a/src/main/java/com/networknt/schema/AnyOfValidator.java b/src/main/java/com/networknt/schema/AnyOfValidator.java
index 4bcf122..06775e5 100644
--- a/src/main/java/com/networknt/schema/AnyOfValidator.java
+++ b/src/main/java/com/networknt/schema/AnyOfValidator.java
@@ -37,7 +37,7 @@ public class AnyOfValidator extends BaseJsonValidator implements JsonValidator {
         int size = schemaNode.size();
         for (int i = 0; i < size; i++) {
             schemas.add(new JsonSchema(validationContext,
-                    getValidatorType().getValue(),
+                    parentSchema.getSchemaPath() + "/" + getValidatorType().getValue(),
                     parentSchema.getCurrentUri(),
                     schemaNode.get(i),
                     parentSchema));
diff --git a/src/main/java/com/networknt/schema/BaseJsonValidator.java b/src/main/java/com/networknt/schema/BaseJsonValidator.java
index 5100931..8e7306f 100644
--- a/src/main/java/com/networknt/schema/BaseJsonValidator.java
+++ b/src/main/java/com/networknt/schema/BaseJsonValidator.java
@@ -182,7 +182,7 @@ public abstract class BaseJsonValidator implements JsonValidator {
     }
 
     protected boolean isPartOfOneOfMultipleType() {
-        return parentSchema.schemaPath.equals(ValidatorTypeCode.ONE_OF.getValue());
+        return parentSchema.schemaPath.endsWith(ValidatorTypeCode.ONE_OF.getValue());
     }
 
     /* ********************** START OF OpenAPI 3.0.x DISCRIMINATOR METHODS ********************************* */
diff --git a/src/main/java/com/networknt/schema/JsonSchema.java b/src/main/java/com/networknt/schema/JsonSchema.java
index 2fa0831..db7137e 100644
--- a/src/main/java/com/networknt/schema/JsonSchema.java
+++ b/src/main/java/com/networknt/schema/JsonSchema.java
@@ -16,21 +16,23 @@
 
 package com.networknt.schema;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.networknt.schema.ValidationContext.DiscriminatorContext;
+import com.networknt.schema.utils.StringUtils;
+import com.networknt.schema.walk.DefaultKeywordWalkListenerRunner;
+import com.networknt.schema.walk.JsonSchemaWalker;
+import com.networknt.schema.walk.WalkListenerRunner;
+
 import java.io.UnsupportedEncodingException;
 import java.net.URI;
+import java.net.URISyntaxException;
 import java.net.URLDecoder;
 import java.util.*;
 import java.util.Map.Entry;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.networknt.schema.ValidationContext.DiscriminatorContext;
-import com.networknt.schema.walk.DefaultKeywordWalkListenerRunner;
-import com.networknt.schema.walk.JsonSchemaWalker;
-import com.networknt.schema.walk.WalkListenerRunner;
-
 /**
  * This is the core of json constraint implementation. It parses json constraint
  * file and generates JsonValidators. The class is thread safe, once it is
@@ -51,8 +53,7 @@ public class JsonSchema extends BaseJsonValidator {
      * This can be null. If it is null, then the creation of relative uris will fail. However, an absolute
      * 'id' would still be able to specify an absolute uri.
      */
-    private final URI currentUri;
-
+    private URI currentUri;
     private JsonValidator requiredValidator = null;
 
     private JsonValidator unevaluatedPropertiesValidator = null;
@@ -79,7 +80,10 @@ public class JsonSchema extends BaseJsonValidator {
               validationContext.getConfig() != null ? validationContext.getConfig().getApplyDefaultsStrategy() : null);
         this.validationContext = validationContext;
         this.metaSchema = validationContext.getMetaSchema();
-        this.currentUri = this.combineCurrentUriWithIds(currentUri, schemaNode);
+        this.currentUri = combineCurrentUriWithIds(currentUri, schemaNode);
+        if (uriRefersToSubschema(currentUri, schemaPath)) {
+            updateThisAsSubschema(currentUri);
+        }
         if (validationContext.getConfig() != null) {
             keywordWalkListenerRunner = new DefaultKeywordWalkListenerRunner(this.validationContext.getConfig().getKeywordWalkListenersMap());
             if (validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
@@ -118,6 +122,35 @@ public class JsonSchema extends BaseJsonValidator {
         return id.startsWith("#") && currentUri == null;
     }
 
+    private boolean uriRefersToSubschema(URI originalUri, String schemaPath) {
+        return originalUri != null
+            && StringUtils.isNotBlank(originalUri.getRawFragment())  // Original currentUri parameter has a fragment, so it refers to a subschema
+            && (StringUtils.isBlank(schemaPath) || "#".equals(schemaPath)); // We aren't already in a subschema
+    }
+
+    /**
+     * Creates a new parent schema from the current state and updates this object to refer to the subschema instead.
+     */
+    private void updateThisAsSubschema(URI originalUri) {
+        String fragment = "#" + originalUri.getFragment();
+        JsonNode fragmentSchemaNode = getRefSchemaNode(fragment);
+        if (fragmentSchemaNode == null) {
+            throw new JsonSchemaException("Fragment " + fragment + " cannot be resolved");
+        }
+        // We need to strip the fragment off of the new parent schema's currentUri, so that its constructor
+        // won't also end up in this method and get stuck in an infinite recursive loop.
+        URI currentUriWithoutFragment;
+        try {
+            currentUriWithoutFragment = new URI(currentUri.getScheme(), currentUri.getSchemeSpecificPart(), null);
+        } catch (URISyntaxException ex) {
+            throw new JsonSchemaException("Unable to create URI without fragment from " + currentUri + ": " + ex.getMessage());
+        }
+        this.parentSchema = new JsonSchema(validationContext, schemaPath, currentUriWithoutFragment, schemaNode, parentSchema);
+        this.schemaPath = fragment;
+        this.schemaNode = fragmentSchemaNode;
+        this.currentUri = combineCurrentUriWithIds(currentUri, fragmentSchemaNode);
+    }
+
     public URI getCurrentUri() {
         return this.currentUri;
     }
diff --git a/src/main/java/com/networknt/schema/JsonSchemaFactory.java b/src/main/java/com/networknt/schema/JsonSchemaFactory.java
index b6f55d6..147fe15 100644
--- a/src/main/java/com/networknt/schema/JsonSchemaFactory.java
+++ b/src/main/java/com/networknt/schema/JsonSchemaFactory.java
@@ -375,9 +375,9 @@ public class JsonSchemaFactory {
 
                 JsonSchema jsonSchema;
                 if (idMatchesSourceUri(jsonMetaSchema, schemaNode, schemaUri)) {
-					jsonSchema = new JsonSchema(
-							new ValidationContext(this.uriFactory, this.urnFactory, jsonMetaSchema, this, config),
-							mappedUri, schemaNode, true /* retrieved via id, resolving will not change anything */);
+                    jsonSchema = new JsonSchema(
+                        new ValidationContext(this.uriFactory, this.urnFactory, jsonMetaSchema, this, config),
+                        mappedUri, schemaNode, true /* retrieved via id, resolving will not change anything */);
                 } else {
                     final ValidationContext validationContext = createValidationContext(schemaNode);
                     validationContext.setConfig(config);
diff --git a/src/main/java/com/networknt/schema/OneOfValidator.java b/src/main/java/com/networknt/schema/OneOfValidator.java
index 277ee18..799dd7f 100644
--- a/src/main/java/com/networknt/schema/OneOfValidator.java
+++ b/src/main/java/com/networknt/schema/OneOfValidator.java
@@ -119,7 +119,7 @@ public class OneOfValidator extends BaseJsonValidator implements JsonValidator {
         int size = schemaNode.size();
         for (int i = 0; i < size; i++) {
             JsonNode childNode = schemaNode.get(i);
-            JsonSchema childSchema = new JsonSchema(validationContext, getValidatorType().getValue(), parentSchema.getCurrentUri(), childNode, parentSchema);
+            JsonSchema childSchema = new JsonSchema(validationContext,  parentSchema.getSchemaPath() + "/" + getValidatorType().getValue(), parentSchema.getCurrentUri(), childNode, parentSchema);
             schemas.add(new ShortcutValidator(childNode, parentSchema, validationContext, childSchema));
         }
         parseErrorCode(getValidatorType().getErrorCodeKey());


Test patch:
diff --git a/src/test/java/com/networknt/schema/Issue619Test.java b/src/test/java/com/networknt/schema/Issue619Test.java
new file mode 100644
index 0000000..9c092c3
--- /dev/null
+++ b/src/test/java/com/networknt/schema/Issue619Test.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2020 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 io.undertow.Undertow;
+import io.undertow.server.handlers.resource.FileResourceManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.net.URI;
+
+import static io.undertow.Handlers.resource;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class Issue619Test extends BaseJsonSchemaValidatorTest {
+
+    private JsonSchemaFactory factory;
+    private JsonNode one;
+    private JsonNode two;
+    private JsonNode three;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4);
+        one = getJsonNodeFromStringContent("1");
+        two = getJsonNodeFromStringContent("2");
+        three = getJsonNodeFromStringContent("3");
+    }
+
+    @Test
+    public void bundledSchemaLoadsAndValidatesCorrectly_Ref() {
+        JsonSchema referencingRootSchema = factory.getSchema("{ \"$ref\": \"resource:schema/issue619.json\" }");
+
+        assertTrue(referencingRootSchema.validate(one).isEmpty());
+        assertTrue(referencingRootSchema.validate(two).isEmpty());
+        assertFalse(referencingRootSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void bundledSchemaLoadsAndValidatesCorrectly_Uri() throws Exception {
+        JsonSchema rootSchema = factory.getSchema(new URI("resource:schema/issue619.json"));
+
+        assertTrue(rootSchema.validate(one).isEmpty());
+        assertTrue(rootSchema.validate(two).isEmpty());
+        assertFalse(rootSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriWithEmptyFragment_Ref() {
+        JsonSchema referencingRootSchema = factory.getSchema("{ \"$ref\": \"resource:schema/issue619.json#\" }");
+
+        assertTrue(referencingRootSchema.validate(one).isEmpty());
+        assertTrue(referencingRootSchema.validate(two).isEmpty());
+        assertFalse(referencingRootSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriWithEmptyFragment_Uri() throws Exception {
+        JsonSchema rootSchema = factory.getSchema(new URI("resource:schema/issue619.json#"));
+
+        assertTrue(rootSchema.validate(one).isEmpty());
+        assertTrue(rootSchema.validate(two).isEmpty());
+        assertFalse(rootSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriThatPointsToTwoShouldOnlyValidateTwo_Ref() {
+        JsonSchema referencingTwoSchema = factory.getSchema("{ \"$ref\": \"resource:schema/issue619.json#/definitions/two\" }");
+
+        assertFalse(referencingTwoSchema.validate(one).isEmpty());
+        assertTrue(referencingTwoSchema.validate(two).isEmpty());
+        assertFalse(referencingTwoSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriThatPointsToOneShouldOnlyValidateOne_Uri() throws Exception {
+        JsonSchema oneSchema = factory.getSchema(new URI("resource:schema/issue619.json#/definitions/one"));
+
+        assertTrue(oneSchema.validate(one).isEmpty());
+        assertFalse(oneSchema.validate(two).isEmpty());
+        assertFalse(oneSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriThatPointsToNodeThatInTurnReferencesOneShouldOnlyValidateOne_Ref() {
+        JsonSchema referencingTwoSchema = factory.getSchema("{ \"$ref\": \"resource:schema/issue619.json#/definitions/refToOne\" }");
+
+        assertTrue(referencingTwoSchema.validate(one).isEmpty());
+        assertFalse(referencingTwoSchema.validate(two).isEmpty());
+        assertFalse(referencingTwoSchema.validate(three).isEmpty());
+    }
+
+    @Test
+    public void uriThatPointsToNodeThatInTurnReferencesOneShouldOnlyValidateOne_Uri() throws Exception {
+        JsonSchema oneSchema = factory.getSchema(new URI("resource:schema/issue619.json#/definitions/refToOne"));
+
+        assertTrue(oneSchema.validate(one).isEmpty());
+        assertFalse(oneSchema.validate(two).isEmpty());
+        assertFalse(oneSchema.validate(three).isEmpty());
+    }
+    
+    @Test
+    public void uriThatPointsToSchemaWithIdThatHasDifferentUri_Ref() throws Exception {
+        runLocalServer(() -> {
+            JsonNode oneArray = getJsonNodeFromStringContent("[[1]]");
+            JsonNode textArray = getJsonNodeFromStringContent("[[\"a\"]]");
+
+            JsonSchema schemaWithIdFromRef = factory.getSchema("{ \"$ref\": \"resource:draft4/refRemote.json#/3/schema\" }");
+            assertTrue(schemaWithIdFromRef.validate(oneArray).isEmpty());
+            assertFalse(schemaWithIdFromRef.validate(textArray).isEmpty());
+        });
+    }
+
+    @Test
+    public void uriThatPointsToSchemaWithIdThatHasDifferentUri_Uri() throws Exception {
+        runLocalServer(() -> {
+            JsonNode oneArray = getJsonNodeFromStringContent("[[1]]");
+            JsonNode textArray = getJsonNodeFromStringContent("[[\"a\"]]");
+
+            JsonSchema schemaWithIdFromUri = factory.getSchema(new URI("resource:draft4/refRemote.json#/3/schema"));
+            assertTrue(schemaWithIdFromUri.validate(oneArray).isEmpty());
+            assertFalse(schemaWithIdFromUri.validate(textArray).isEmpty());
+        });
+    }
+
+    private interface ThrowingRunnable {
+        void run() throws Exception;
+    }
+    
+    private void runLocalServer(ThrowingRunnable actualTest) throws Exception {
+        Undertow server = Undertow.builder()
+            .addHttpListener(1234, "localhost")
+            .setHandler(resource(new FileResourceManager(
+                new File("./src/test/resources/remotes"), 100)))
+            .build();
+        try {
+            server.start();
+            
+            actualTest.run();
+            
+        } finally {
+            try {
+                Thread.sleep(100);
+            } catch (InterruptedException ignored) {
+                Thread.currentThread().interrupt();
+            }
+            server.stop();
+        }
+    }
+
+    @Test
+    public void uriThatPointsToSchemaThatDoesNotExistShouldFail_Ref() {
+        JsonSchema referencingNonexistentSchema = factory.getSchema("{ \"$ref\": \"resource:data/schema-that-does-not-exist.json#/definitions/something\" }");
+
+        assertThrows(JsonSchemaException.class, () -> referencingNonexistentSchema.validate(one));
+    }
+
+    @Test
+    public void uriThatPointsToSchemaThatDoesNotExistShouldFail_Uri() {
+        assertThrows(JsonSchemaException.class, () -> factory.getSchema(new URI("resource:data/schema-that-does-not-exist.json#/definitions/something")));
+    }
+
+    @Test
+    public void uriThatPointsToNodeThatDoesNotExistShouldFail_Ref() {
+        JsonSchema referencingNonexistentSchema = factory.getSchema("{ \"$ref\": \"resource:schema/issue619.json#/definitions/node-that-does-not-exist\" }");
+
+        assertThrows(JsonSchemaException.class, () -> referencingNonexistentSchema.validate(one));
+    }
+
+    @Test
+    public void uriThatPointsToNodeThatDoesNotExistShouldFail_Uri() {
+        assertThrows(JsonSchemaException.class, () -> factory.getSchema(new URI("resource:schema/issue619.json#/definitions/node-that-does-not-exist")));
+    }
+}
diff --git a/src/test/resources/schema/issue619.json b/src/test/resources/schema/issue619.json
new file mode 100644
index 0000000..69696bf
--- /dev/null
+++ b/src/test/resources/schema/issue619.json
@@ -0,0 +1,25 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "id": "resource:schema/issue619.json",
+  "oneOf": [
+    {
+      "$ref": "#/definitions/one"
+    },
+    {
+      "$ref": "#/definitions/two"
+    }
+  ],
+  "definitions": {
+    "one": {
+      "type": "integer",
+      "enum": [1]
+    },
+    "two": {
+      "type": "integer",
+      "enum": [2]
+    },
+    "refToOne": {
+      "$ref": "#/definitions/one"
+    }
+  }
+}
\ No newline at end of file