Repository: networknt/json-schema-validator
Pull request #125 (2019-03-22 10:51:19)

feat: Add URL mappings
URL mappings allows public, normally internet accessible URLs (such as might be externally referenced in a schema) to be mapped to local URLs that might be on the filesystem, at a different external URL, or embedded within a JAR file.

This allows the JsonSchema validator to validate a schema with an external reference to validate against the local copy of that reference when the external reference is not available. Note that when using a mapping, the local copy is always used, and the external reference is not queried.

rhwood: This includes tests that the URL mappings work, both in terms of being schema valid, and by referencing a non-existent URL.

@stevehu two questions:
- [ ] Where should the schema for a JSON file containing mappings be published? 22efdfe uses `https://raw.githubusercontent.com/networknt/json-schema-validator/master/src/main/resources/url-mapping.schema.json` which, while being valid if this PR is merged from that commit without changes, feels like it should be published elsewhere (or use a different URL even if not published at that URL).
- [ ] Should the mapping file `src/test/resources/tests/url_mapping/url-mapping.json` be included in sources, and not just tests, as a usable mapping file to avoid queries against the Draft 4 schema and the mapping schema?

Fixes #124

Patch:
diff --git a/README.md b/README.md
index 522197b..43ab16f 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ This is an implementation of the [JSON Schema Core Draft v4](http://json-schema.
 [Subreddit](https://www.reddit.com/r/lightapi/) |
 [Youtube Channel](https://www.youtube.com/channel/UCHCRMWJVXw8iB7zKxF55Byw) |
 [Documentation](https://doc.networknt.com/library/json-overlay/) |
+[Javadocs](https://www.javadoc.io/doc/com.networknt/json-schema-validator) |
 [Contribution Guide](https://doc.networknt.com/contribute/) |
 
 [![Build Status](https://travis-ci.org/networknt/json-schema-validator.svg?branch=master)](https://travis-ci.org/networknt/json-schema-validator)
diff --git a/src/main/java/com/networknt/schema/JsonSchemaFactory.java b/src/main/java/com/networknt/schema/JsonSchemaFactory.java
index e09c361..01cf4c6 100644
--- a/src/main/java/com/networknt/schema/JsonSchemaFactory.java
+++ b/src/main/java/com/networknt/schema/JsonSchemaFactory.java
@@ -41,6 +41,7 @@ public class JsonSchemaFactory {
         private URLFetcher urlFetcher;
         private String defaultMetaSchemaURI;
         private Map<String, JsonMetaSchema> jsonMetaSchemas = new HashMap<String, JsonMetaSchema>();
+        private Map<URL, URL> urlMap = new HashMap<URL, URL>();
         
         public Builder objectMapper(ObjectMapper objectMapper) {
             this.objectMapper = objectMapper;
@@ -69,13 +70,19 @@ public class JsonSchemaFactory {
             return this;
         }
         
+        public Builder addUrlMappings(Map<URL, URL> map) {
+            this.urlMap.putAll(map);
+            return this;
+        }
+        
         public JsonSchemaFactory build() {
             // create builtin keywords with (custom) formats.
             return new JsonSchemaFactory(
                     objectMapper == null ? new ObjectMapper() : objectMapper, 
                     urlFetcher == null ? new StandardURLFetcher(): urlFetcher, 
                     defaultMetaSchemaURI, 
-                    jsonMetaSchemas
+                    jsonMetaSchemas,
+                    urlMap
             );
         }
     }
@@ -84,8 +91,9 @@ public class JsonSchemaFactory {
     private final URLFetcher urlFetcher;
     private final String defaultMetaSchemaURI;
     private final Map<String, JsonMetaSchema> jsonMetaSchemas;
+    private final Map<URL, URL> urlMap;
 
-    private JsonSchemaFactory(ObjectMapper mapper, URLFetcher urlFetcher, String defaultMetaSchemaURI, Map<String, JsonMetaSchema> jsonMetaSchemas) {
+    private JsonSchemaFactory(ObjectMapper mapper, URLFetcher urlFetcher, String defaultMetaSchemaURI, Map<String, JsonMetaSchema> jsonMetaSchemas, Map<URL, URL> urlMap) {
         if (mapper == null) {
             throw new IllegalArgumentException("ObjectMapper must not be null");
         }
@@ -101,10 +109,14 @@ public class JsonSchemaFactory {
         if (jsonMetaSchemas.get(defaultMetaSchemaURI) == null) {
             throw new IllegalArgumentException("Meta Schema for default Meta Schema URI must be provided");
         }
+        if (urlMap == null) {
+            throw new IllegalArgumentException("URL Mappings must not be null");
+        }
         this.mapper = mapper;
         this.defaultMetaSchemaURI = defaultMetaSchemaURI;
         this.urlFetcher = urlFetcher;
         this.jsonMetaSchemas = jsonMetaSchemas;
+        this.urlMap = urlMap;
     }
 
     /**
@@ -135,7 +147,8 @@ public class JsonSchemaFactory {
                 .addMetaSchemas(blueprint.jsonMetaSchemas.values())
                 .urlFetcher(blueprint.urlFetcher)
                 .defaultMetaSchemaURI(blueprint.defaultMetaSchemaURI)
-                .objectMapper(blueprint.mapper);
+                .objectMapper(blueprint.mapper)
+                .addUrlMappings(blueprint.urlMap);
     }
     
     private JsonSchema newJsonSchema(JsonNode schemaNode, SchemaValidatorsConfig config) {
@@ -191,8 +204,11 @@ public class JsonSchemaFactory {
     public JsonSchema getSchema(URL schemaURL, SchemaValidatorsConfig config) {
         try {
             InputStream inputStream = null;
+            Map<URL, URL> map = (config != null) ? config.getUrlMappings() : new HashMap<URL, URL>(urlMap);
+            map.putAll(urlMap);
+            URL mappedURL = map.getOrDefault(schemaURL, schemaURL);
             try {
-                inputStream = urlFetcher.fetch(schemaURL);
+                inputStream = urlFetcher.fetch(mappedURL);
                 JsonNode schemaNode = mapper.readTree(inputStream);
                 final JsonMetaSchema jsonMetaSchema = findMetaSchemaForSchema(schemaNode);
 
diff --git a/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java b/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java
index 6bbe2c4..7831316 100644
--- a/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java
+++ b/src/main/java/com/networknt/schema/SchemaValidatorsConfig.java
@@ -1,5 +1,9 @@
 package com.networknt.schema;
 
+import java.util.HashMap;
+import java.util.Map;
+import java.net.URL;
+
 public class SchemaValidatorsConfig {
     /**
      * when validate type, if TYPE_LOOSE = true, will try to convert string to different types to match the type defined in schema.
@@ -18,6 +22,13 @@ public class SchemaValidatorsConfig {
      */
     private boolean elementValidationError = false;
     
+    /**
+     * Map of public, normally internet accessible schema URLs to alternate locations; this allows for offline
+     * validation of schemas that refer to public URLs. This is merged with any mappings the {@link JsonSchemaFactory} 
+     * may have been built with.
+     */
+    private Map<URL, URL> urlMappings = new HashMap<URL, URL>();
+
     public boolean isTypeLoose() {
         return typeLoose;
     }
@@ -26,6 +37,14 @@ public class SchemaValidatorsConfig {
         this.typeLoose = typeLoose;
     }
 
+    public Map<URL, URL> getUrlMappings() {
+        return new HashMap<URL, URL>(urlMappings);
+    }
+
+    public void setUrlMappings(Map<URL, URL> urlMappings) {
+        this.urlMappings = urlMappings;
+    }
+
     public boolean isMissingNodeAsError() {
     	return missingNodeAsError;
     }
@@ -48,5 +67,6 @@ public class SchemaValidatorsConfig {
 
     private void loadDefaultConfig() {
         this.typeLoose = true;
+        this.urlMappings = new HashMap<URL, URL>();
     }
 }


Test patch:
diff --git a/src/test/java/com/networknt/schema/UrlMappingTest.java b/src/test/java/com/networknt/schema/UrlMappingTest.java
new file mode 100644
index 0000000..194928d
--- /dev/null
+++ b/src/test/java/com/networknt/schema/UrlMappingTest.java
@@ -0,0 +1,141 @@
+package com.networknt.schema;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.networknt.schema.JsonSchemaFactory.Builder;
+import com.networknt.schema.url.URLFactory;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+public class UrlMappingTest {
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * Validate that a JSON URL Mapping file containing the URL Mapping schema is
+     * schema valid.
+     * 
+     * @throws IOException if unable to parse the mapping file
+     */
+    @Test
+    public void testBuilderUrlMappingUrl() throws IOException {
+        URL mappings = URLFactory.toURL("resource:tests/url_mapping/url-mapping.json");
+        JsonMetaSchema draftV4 = JsonMetaSchema.getDraftV4();
+        Builder builder = JsonSchemaFactory.builder()
+                .defaultMetaSchemaURI(draftV4.getUri())
+                .addMetaSchema(draftV4)
+                .addUrlMappings(getUrlMappingsFromUrl(mappings));
+        JsonSchemaFactory instance = builder.build();
+        JsonSchema schema = instance.getSchema(new URL(
+                "https://raw.githubusercontent.com/networknt/json-schema-validator/master/src/test/resources/tests/url_mapping/url-mapping.schema.json"));
+        assertEquals(0, schema.validate(mapper.readTree(mappings)).size());
+    }
+
+    /**
+     * Validate that local URL is used when attempting to get a schema that is not
+     * available publicly. Use the URL http://example.com/invalid/schema/url to use
+     * a public URL that returns a 404 Not Found. The locally mapped schema is a
+     * valid, but empty schema.
+     * 
+     * @throws IOException if unable to parse the mapping file
+     */
+    @Test
+    public void testBuilderExampleMappings() throws IOException {
+        JsonSchemaFactory instance = JsonSchemaFactory.getInstance();
+        URL example = new URL("http://example.com/invalid/schema/url");
+        // first test that attempting to use example URL throws an error
+        try {
+            JsonSchema schema = instance.getSchema(example);
+            schema.validate(mapper.createObjectNode());
+            fail("Expected exception not thrown");
+        } catch (JsonSchemaException ex) {
+            Throwable cause = ex.getCause();
+            if (!(cause instanceof FileNotFoundException || cause instanceof UnknownHostException)) {
+                fail("Unexpected cause for JsonSchemaException");
+            }
+            // passing, so do nothing
+        } catch (Exception ex) {
+            fail("Unexpected exception thrown");
+        }
+        URL mappings = URLFactory.toURL("resource:tests/url_mapping/invalid-schema-url.json");
+        JsonMetaSchema draftV4 = JsonMetaSchema.getDraftV4();
+        Builder builder = JsonSchemaFactory.builder()
+                .defaultMetaSchemaURI(draftV4.getUri())
+                .addMetaSchema(draftV4)
+                .addUrlMappings(getUrlMappingsFromUrl(mappings));
+        instance = builder.build();
+        JsonSchema schema = instance.getSchema(example);
+        assertEquals(0, schema.validate(mapper.createObjectNode()).size());
+    }
+
+    /**
+     * Validate that a JSON URL Mapping file containing the URL Mapping schema is
+     * schema valid.
+     * 
+     * @throws IOException if unable to parse the mapping file
+     */
+    @Test
+    public void testValidatorConfigUrlMappingUrl() throws IOException {
+        JsonSchemaFactory instance = JsonSchemaFactory.getInstance();
+        URL mappings = URLFactory.toURL("resource:tests/url_mapping/url-mapping.json");
+        SchemaValidatorsConfig config = new SchemaValidatorsConfig();
+        config.setUrlMappings(getUrlMappingsFromUrl(mappings));
+        JsonSchema schema = instance.getSchema(new URL(
+                "https://raw.githubusercontent.com/networknt/json-schema-validator/master/src/test/resources/tests/url_mapping/url-mapping.schema.json"),
+                config);
+        assertEquals(0, schema.validate(mapper.readTree(mappings)).size());
+    }
+
+    /**
+     * Validate that local URL is used when attempting to get a schema that is not
+     * available publicly. Use the URL http://example.com/invalid/schema/url to use
+     * a public URL that returns a 404 Not Found. The locally mapped schema is a
+     * valid, but empty schema.
+     * 
+     * @throws IOException if unable to parse the mapping file
+     */
+    @Test
+    public void testValidatorConfigExampleMappings() throws IOException {
+        JsonSchemaFactory instance = JsonSchemaFactory.getInstance();
+        SchemaValidatorsConfig config = new SchemaValidatorsConfig();
+        URL example = new URL("http://example.com/invalid/schema/url");
+        // first test that attempting to use example URL throws an error
+        try {
+            JsonSchema schema = instance.getSchema(example, config);
+            schema.validate(mapper.createObjectNode());
+            fail("Expected exception not thrown");
+        } catch (JsonSchemaException ex) {
+            Throwable cause = ex.getCause();
+            if (!(cause instanceof FileNotFoundException || cause instanceof UnknownHostException)) {
+                fail("Unexpected cause for JsonSchemaException");
+            }
+            // passing, so do nothing
+        } catch (Exception ex) {
+            fail("Unexpected exception thrown");
+        }
+        URL mappings = URLFactory.toURL("resource:tests/url_mapping/invalid-schema-url.json");
+        config.setUrlMappings(getUrlMappingsFromUrl(mappings));
+        JsonSchema schema = instance.getSchema(example, config);
+        assertEquals(0, schema.validate(mapper.createObjectNode()).size());
+    }
+
+    private Map<URL, URL> getUrlMappingsFromUrl(URL url) throws MalformedURLException, IOException {
+        HashMap<URL, URL> map = new HashMap<URL, URL>();
+        for (JsonNode mapping : mapper.readTree(url)) {
+            map.put(URLFactory.toURL(mapping.get("publicURL").asText()),
+                    URLFactory.toURL(mapping.get("localURL").asText()));
+        }
+        return map;
+    }
+}
diff --git a/src/test/resources/tests/url_mapping/example-schema.json b/src/test/resources/tests/url_mapping/example-schema.json
new file mode 100644
index 0000000..0a1cbc0
--- /dev/null
+++ b/src/test/resources/tests/url_mapping/example-schema.json
@@ -0,0 +1,3 @@
+{
+    "$schema": "http://json-schema.org/draft-04/schema#"
+}
diff --git a/src/test/resources/tests/url_mapping/invalid-schema-url.json b/src/test/resources/tests/url_mapping/invalid-schema-url.json
new file mode 100644
index 0000000..8793df9
--- /dev/null
+++ b/src/test/resources/tests/url_mapping/invalid-schema-url.json
@@ -0,0 +1,10 @@
+[
+    {
+        "publicURL": "http://json-schema.org/draft-04/schema#",
+        "localURL": "resource:/draftv4.schema.json"
+    },
+    {
+        "publicURL": "http://example.com/invalid/schema/url",
+        "localURL": "resource:/tests/url_mapping/example-schema.json"
+    }
+]
diff --git a/src/test/resources/tests/url_mapping/url-mapping.json b/src/test/resources/tests/url_mapping/url-mapping.json
new file mode 100644
index 0000000..efd7e6b
--- /dev/null
+++ b/src/test/resources/tests/url_mapping/url-mapping.json
@@ -0,0 +1,10 @@
+[
+    {
+        "publicURL": "http://json-schema.org/draft-04/schema#",
+        "localURL": "resource:/draftv4.schema.json" 
+    },
+    {
+        "publicURL": "https://raw.githubusercontent.com/networknt/json-schema-validator/master/src/test/resources/tests/url_mapping/url-mapping.schema.json",
+        "localURL": "resource:/tests/url_mapping/url-mapping.schema.json"
+    }
+]
diff --git a/src/test/resources/tests/url_mapping/url-mapping.schema.json b/src/test/resources/tests/url_mapping/url-mapping.schema.json
new file mode 100644
index 0000000..5d43cd9
--- /dev/null
+++ b/src/test/resources/tests/url_mapping/url-mapping.schema.json
@@ -0,0 +1,25 @@
+{
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "title": "json-schema-validator-url-mapping",
+    "type": "array",
+    "description": "Data portion of message from JMRI to client for type \"node\"",
+    "items": {
+        "type": "object",
+        "uniqueItems": true,
+        "properties": {
+            "publicURL": {
+                "type": "string",
+                "description": "Public, presumably internet-accessible, URL for schema"
+            },
+            "localURL": {
+                "type": "string",
+                "description": "Local URL for schema that will be used when a schema references the public URL"
+            }
+        },
+        "additionalProperties": false,
+        "required": [
+            "publicURL",
+            "localURL"
+        ]
+    }
+}