From a61cf42e452629cff783b4ad175110faabae4f7b Mon Sep 17 00:00:00 2001 From: Justin Tay <49700559+justin-tay@users.noreply.github.com> Date: Wed, 7 Feb 2024 01:54:45 +0800 Subject: [PATCH] Improve vocabulary support (#953) --- .../com/networknt/schema/JsonMetaSchema.java | 25 +++- .../java/com/networknt/schema/JsonSchema.java | 4 + .../networknt/schema/JsonSchemaFactory.java | 19 ++- .../networknt/schema/UnionTypeValidator.java | 6 +- .../com/networknt/schema/Version202012.java | 3 +- .../com/networknt/schema/Vocabularies.java | 68 +++------ .../java/com/networknt/schema/Vocabulary.java | 133 +++++++++++++++++ .../com/networknt/schema/VocabularyTest.java | 135 ++++++++++++++++++ 8 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 src/main/java/com/networknt/schema/Vocabulary.java create mode 100644 src/test/java/com/networknt/schema/VocabularyTest.java diff --git a/src/main/java/com/networknt/schema/JsonMetaSchema.java b/src/main/java/com/networknt/schema/JsonMetaSchema.java index 06f9d69f3..6ecb6255e 100644 --- a/src/main/java/com/networknt/schema/JsonMetaSchema.java +++ b/src/main/java/com/networknt/schema/JsonMetaSchema.java @@ -168,10 +168,27 @@ public JsonMetaSchema build() { if (this.specification != null) { if (this.specification.getVersionFlagValue() >= SpecVersion.VersionFlag.V201909.getVersionFlagValue()) { if (!this.uri.equals(this.specification.getId())) { - String validation = Vocabularies.getVocabulary(specification, "validation"); - if (!this.vocabularies.getOrDefault(validation, false)) { - for (String keywordToRemove : Vocabularies.getKeywords("validation")) { - kwords.remove(keywordToRemove); + // The current design is such that the keyword map can contain things that aren't actually keywords + // This means need to remove what can't be found instead of creating from scratch + Map vocabularies = JsonSchemaFactory.checkVersion(this.specification) + .getInstance().getVocabularies(); + Set current = this.vocabularies.keySet(); + Map format = new HashMap<>(); + format.put(Vocabulary.V202012_FORMAT_ANNOTATION.getId(), Vocabulary.V202012_FORMAT_ASSERTION.getId()); + format.put(Vocabulary.V202012_FORMAT_ASSERTION.getId(), Vocabulary.V202012_FORMAT_ANNOTATION.getId()); + for (String vocabularyId : vocabularies.keySet()) { + if (!current.contains(vocabularyId)) { + String formatVocab = format.get(vocabularyId); + if (formatVocab != null) { + if (current.contains(formatVocab)) { + // Skip as the assertion and annotation keywords are the same + continue; + } + } + Vocabulary vocabulary = Vocabularies.getVocabulary(vocabularyId); + for (String keywordToRemove : vocabulary.getKeywords()) { + kwords.remove(keywordToRemove); + } } } } diff --git a/src/main/java/com/networknt/schema/JsonSchema.java b/src/main/java/com/networknt/schema/JsonSchema.java index 466f9498d..44b513971 100644 --- a/src/main/java/com/networknt/schema/JsonSchema.java +++ b/src/main/java/com/networknt/schema/JsonSchema.java @@ -30,6 +30,10 @@ import java.util.function.Consumer; /** + * Used for creating a schema with validators for validating inputs. This is + * created using {@link JsonSchemaFactory#getInstance(VersionFlag, Consumer)} + * and should be cached for performance. + *

* This is the core of json constraint implementation. It parses json constraint * file and generates JsonValidators. The class is thread safe, once it is * constructed, it can be used to validate multiple json data concurrently. diff --git a/src/main/java/com/networknt/schema/JsonSchemaFactory.java b/src/main/java/com/networknt/schema/JsonSchemaFactory.java index 4171a1002..6a3f47a69 100644 --- a/src/main/java/com/networknt/schema/JsonSchemaFactory.java +++ b/src/main/java/com/networknt/schema/JsonSchemaFactory.java @@ -19,7 +19,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.SpecVersion.VersionFlag; -import com.networknt.schema.resource.*; +import com.networknt.schema.resource.DefaultSchemaLoader; +import com.networknt.schema.resource.SchemaLoader; +import com.networknt.schema.resource.SchemaLoaders; +import com.networknt.schema.resource.SchemaMapper; +import com.networknt.schema.resource.SchemaMappers; import com.networknt.schema.serialization.JsonMapperFactory; import com.networknt.schema.serialization.YamlMapperFactory; @@ -30,7 +34,7 @@ import java.io.InputStream; import java.net.URI; import java.util.Collection; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -38,6 +42,12 @@ import java.util.concurrent.ConcurrentMap; import java.util.function.Consumer; +/** + * Factory for building {@link JsonSchema} instances. + *

+ * The factory should be typically be created using + * {@link #getInstance(VersionFlag, Consumer)}. + */ public class JsonSchemaFactory { private static final Logger logger = LoggerFactory .getLogger(JsonSchemaFactory.class); @@ -185,7 +195,7 @@ public static JsonSchemaFactory getInstance(SpecVersion.VersionFlag versionFlag) * be used if the input does not specify a $schema. * * @param versionFlag the default dialect - * @param customizer to customze the factory + * @param customizer to customize the factory * @return the factory */ public static JsonSchemaFactory getInstance(SpecVersion.VersionFlag versionFlag, @@ -364,12 +374,11 @@ protected JsonMetaSchema loadMetaSchema(String id, SchemaValidatorsConfig config // Process vocabularies JsonNode vocabulary = schema.getSchemaNode().get("$vocabulary"); if (vocabulary != null) { - builder.vocabularies(new HashMap<>()); + builder.vocabularies(new LinkedHashMap<>()); for(Entry vocabs : vocabulary.properties()) { builder.vocabulary(vocabs.getKey(), vocabs.getValue().booleanValue()); } } - } } return builder.build(); diff --git a/src/main/java/com/networknt/schema/UnionTypeValidator.java b/src/main/java/com/networknt/schema/UnionTypeValidator.java index 2b86ef1c4..4d7543b8f 100644 --- a/src/main/java/com/networknt/schema/UnionTypeValidator.java +++ b/src/main/java/com/networknt/schema/UnionTypeValidator.java @@ -34,7 +34,6 @@ public class UnionTypeValidator extends BaseJsonValidator implements JsonValidat private final List schemas = new ArrayList(); private final String error; - public UnionTypeValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath, JsonNode schemaNode, JsonSchema parentSchema, ValidationContext validationContext) { super(schemaLocation, evaluationPath, schemaNode, parentSchema, ValidatorTypeCode.UNION_TYPE, validationContext); StringBuilder errorBuilder = new StringBuilder(); @@ -105,4 +104,9 @@ public void preloadJsonSchema() { validator.preloadJsonSchema(); } } + + @Override + public String getKeyword() { + return "type"; + } } diff --git a/src/main/java/com/networknt/schema/Version202012.java b/src/main/java/com/networknt/schema/Version202012.java index 4ff920741..680cc3041 100644 --- a/src/main/java/com/networknt/schema/Version202012.java +++ b/src/main/java/com/networknt/schema/Version202012.java @@ -52,8 +52,7 @@ public JsonMetaSchema getInstance() { new NonValidationKeyword("contentSchema"), new NonValidationKeyword("examples"), new NonValidationKeyword("then"), - new NonValidationKeyword("else"), - new NonValidationKeyword("additionalItems") + new NonValidationKeyword("else") )) .vocabularies(VOCABULARY) .build(); diff --git a/src/main/java/com/networknt/schema/Vocabularies.java b/src/main/java/com/networknt/schema/Vocabularies.java index c43e10d99..6b86172cc 100644 --- a/src/main/java/com/networknt/schema/Vocabularies.java +++ b/src/main/java/com/networknt/schema/Vocabularies.java @@ -15,69 +15,43 @@ */ package com.networknt.schema; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Map; /** * Vocabularies. */ public class Vocabularies { - private static final Map> KEYWORDS_MAPPING; + private static final Map VALUES; static { - Map> mapping = new HashMap<>(); - List validation = new ArrayList<>(); - validation.add("type"); - validation.add("enum"); - validation.add("const"); + Map mapping = new HashMap<>(); + mapping.put(Vocabulary.V201909_CORE.getId(), Vocabulary.V201909_CORE); + mapping.put(Vocabulary.V201909_APPLICATOR.getId(), Vocabulary.V201909_APPLICATOR); + mapping.put(Vocabulary.V201909_VALIDATION.getId(), Vocabulary.V201909_VALIDATION); + mapping.put(Vocabulary.V201909_META_DATA.getId(), Vocabulary.V201909_META_DATA); + mapping.put(Vocabulary.V201909_FORMAT.getId(), Vocabulary.V201909_FORMAT); + mapping.put(Vocabulary.V201909_CONTENT.getId(), Vocabulary.V201909_CONTENT); - validation.add("multipleOf"); - validation.add("maximum"); - validation.add("exclusiveMaximum"); - validation.add("minimum"); - validation.add("exclusiveMinimum"); - - validation.add("maxLength"); - validation.add("minLength"); - validation.add("pattern"); + mapping.put(Vocabulary.V202012_CORE.getId(), Vocabulary.V202012_CORE); + mapping.put(Vocabulary.V202012_APPLICATOR.getId(), Vocabulary.V202012_APPLICATOR); + mapping.put(Vocabulary.V202012_UNEVALUATED.getId(), Vocabulary.V202012_UNEVALUATED); + mapping.put(Vocabulary.V202012_VALIDATION.getId(), Vocabulary.V202012_VALIDATION); + mapping.put(Vocabulary.V202012_META_DATA.getId(), Vocabulary.V202012_META_DATA); + mapping.put(Vocabulary.V202012_FORMAT_ANNOTATION.getId(), Vocabulary.V202012_FORMAT_ANNOTATION); + mapping.put(Vocabulary.V202012_FORMAT_ASSERTION.getId(), Vocabulary.V202012_FORMAT_ASSERTION); + mapping.put(Vocabulary.V202012_CONTENT.getId(), Vocabulary.V202012_CONTENT); - validation.add("maxItems"); - validation.add("minItems"); - validation.add("uniqueItems"); - validation.add("maxContains"); - validation.add("minContains"); - - validation.add("maxProperties"); - validation.add("minProperties"); - validation.add("required"); - validation.add("dependentRequired"); - - mapping.put("validation", validation); - - KEYWORDS_MAPPING = mapping; + VALUES = mapping; } /** - * Gets the keywords associated with a vocabulary. + * Gets the vocabulary given its id. * * @param vocabulary the vocabulary - * @return the keywords + * @return the vocabulary */ - public static List getKeywords(String vocabulary) { - return KEYWORDS_MAPPING.get(vocabulary); - } - - /** - * Gets the vocabulary IRI. - * - * @param specification the specification - * @param vocabulary the vocabulary - * @return the vocabulary IRI - */ - public static String getVocabulary(SpecVersion.VersionFlag specification, String vocabulary) { - String base = specification.getId().substring(0, specification.getId().lastIndexOf('/')); - return base + "/vocab/" + vocabulary; + public static Vocabulary getVocabulary(String vocabulary) { + return VALUES.get(vocabulary); } } diff --git a/src/main/java/com/networknt/schema/Vocabulary.java b/src/main/java/com/networknt/schema/Vocabulary.java new file mode 100644 index 000000000..1834124d5 --- /dev/null +++ b/src/main/java/com/networknt/schema/Vocabulary.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2024 the original author or authors. + * + * 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 java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Represents a vocabulary in meta schema. + *

+ * This contains the id and the related keywords. + */ +public class Vocabulary { + + // 2019-09 + public static final Vocabulary V201909_CORE = new Vocabulary("https://json-schema.org/draft/2019-09/vocab/core", + "$id", "$schema", "$anchor", "$ref", "$recursiveRef", "$recursiveAnchor", "$vocabulary", "$comment", + "$defs"); + public static final Vocabulary V201909_APPLICATOR = new Vocabulary( + "https://json-schema.org/draft/2019-09/vocab/applicator", "additionalItems", "unevaluatedItems", "items", + "contains", "additionalProperties", "unevaluatedProperties", "properties", "patternProperties", + "dependentSchemas", "propertyNames", "if", "then", "else", "allOf", "anyOf", "oneOf", "not"); + public static final Vocabulary V201909_VALIDATION = new Vocabulary( + "https://json-schema.org/draft/2019-09/vocab/validation", "multipleOf", "maximum", "exclusiveMaximum", + "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern", "maxItems", "minItems", "uniqueItems", + "maxContains", "minContains", "maxProperties", "minProperties", "required", "dependentRequired", "const", + "enum", "type"); + public static final Vocabulary V201909_META_DATA = new Vocabulary( + "https://json-schema.org/draft/2019-09/vocab/meta-data", "title", "description", "default", "deprecated", + "readOnly", "writeOnly", "examples"); + public static final Vocabulary V201909_FORMAT = new Vocabulary("https://json-schema.org/draft/2019-09/vocab/format", + "format"); + public static final Vocabulary V201909_CONTENT = new Vocabulary( + "https://json-schema.org/draft/2019-09/vocab/content", "contentMediaType", "contentEncoding", + "contentSchema"); + + // 2020-12 + public static final Vocabulary V202012_CORE = new Vocabulary("https://json-schema.org/draft/2020-12/vocab/core", + "$id", "$schema", "$ref", "$anchor", "$dynamicRef", "$dynamicAnchor", "$vocabulary", "$comment", "$defs"); + public static final Vocabulary V202012_APPLICATOR = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/applicator", "prefixItems", "items", "contains", + "additionalProperties", "properties", "patternProperties", "dependentSchemas", "propertyNames", "if", + "then", "else", "allOf", "anyOf", "oneOf", "not"); + public static final Vocabulary V202012_UNEVALUATED = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/unevaluated", "unevaluatedItems", "unevaluatedProperties"); + public static final Vocabulary V202012_VALIDATION = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/validation", "type", "const", "enum", "multipleOf", "maximum", + "exclusiveMaximum", "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern", "maxItems", + "minItems", "uniqueItems", "maxContains", "minContains", "maxProperties", "minProperties", "required", + "dependentRequired"); + public static final Vocabulary V202012_META_DATA = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/meta-data", "title", "description", "default", "deprecated", + "readOnly", "writeOnly", "examples"); + public static final Vocabulary V202012_FORMAT_ANNOTATION = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/format-annotation", "format"); + public static final Vocabulary V202012_FORMAT_ASSERTION = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/format-assertion", "format"); + public static final Vocabulary V202012_CONTENT = new Vocabulary( + "https://json-schema.org/draft/2020-12/vocab/content", "contentEncoding", "contentMediaType", + "contentSchema"); + + private final String id; + private final Set keywords; + + /** + * Constructor. + * + * @param id the id + * @param keywords the keywords + */ + public Vocabulary(String id, String... keywords) { + this.id = id; + this.keywords = new LinkedHashSet<>(); + for (String keyword : keywords) { + this.keywords.add(keyword); + } + } + + /** + * The id of the vocabulary. + * + * @return the id + */ + public String getId() { + return id; + } + + /** + * The keywords in the vocabulary. + * + * @return the keywords + */ + public Set getKeywords() { + return keywords; + } + + @Override + public int hashCode() { + return Objects.hash(id, keywords); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Vocabulary other = (Vocabulary) obj; + return Objects.equals(id, other.id) && Objects.equals(keywords, other.keywords); + } + + @Override + public String toString() { + return "Vocabulary [id=" + id + ", keywords=" + keywords + "]"; + } + +} diff --git a/src/test/java/com/networknt/schema/VocabularyTest.java b/src/test/java/com/networknt/schema/VocabularyTest.java new file mode 100644 index 000000000..907fd3b40 --- /dev/null +++ b/src/test/java/com/networknt/schema/VocabularyTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 the original author or authors. + * + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.networknt.schema.SpecVersion.VersionFlag; + +/** + * Tests for vocabulary support in meta schemas. + */ +public class VocabularyTest { + @Test + void noValidation() { + String metaSchemaData = "{\r\n" + + " \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\r\n" + + " \"$id\": \"https://www.example.com/no-validation-no-format/schema\",\r\n" + + " \"$vocabulary\": {\r\n" + + " \"https://www.example.com/vocab/validation\": true,\r\n" + + " \"https://json-schema.org/draft/2020-12/vocab/applicator\": true,\r\n" + + " \"https://json-schema.org/draft/2020-12/vocab/core\": true\r\n" + + " },\r\n" + + " \"allOf\": [\r\n" + + " { \"$ref\": \"https://json-schema.org/draft/2020-12/meta/applicator\" },\r\n" + + " { \"$ref\": \"https://json-schema.org/draft/2020-12/meta/core\" }\r\n" + + " ]\r\n" + + "}"; + String schemaData = "{\r\n" + + " \"$id\": \"https://schema/using/no/validation\",\r\n" + + " \"$schema\": \"https://www.example.com/no-validation-no-format/schema\",\r\n" + + " \"properties\": {\r\n" + + " \"badProperty\": false,\r\n" + + " \"numberProperty\": {\r\n" + + " \"minimum\": 10\r\n" + + " }\r\n" + + " }\r\n" + + "}"; + JsonSchema schema = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaLoaders(schemaLoaders -> schemaLoaders.schemas(Collections + .singletonMap("https://www.example.com/no-validation-no-format/schema", + metaSchemaData)))) + .getSchema(schemaData); + + String inputDataNoValidation = "{\r\n" + + " \"numberProperty\": 1\r\n" + + "}"; + + Set messages = schema.validate(inputDataNoValidation, InputFormat.JSON); + assertEquals(0, messages.size()); + + // Set validation vocab + schema = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaLoaders(schemaLoaders -> schemaLoaders.schemas(Collections + .singletonMap("https://www.example.com/no-validation-no-format/schema", + metaSchemaData.replace("https://www.example.com/vocab/validation", + Vocabulary.V202012_VALIDATION.getId()))))) + .getSchema(schemaData); + messages = schema.validate(inputDataNoValidation, InputFormat.JSON); + assertEquals(1, messages.size()); + assertEquals("minimum", messages.iterator().next().getType()); + } + + @Test + void noFormatValidation() { + String metaSchemaData = "{\r\n" + + " \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\r\n" + + " \"$id\": \"https://www.example.com/no-validation-no-format/schema\",\r\n" + + " \"$vocabulary\": {\r\n" + + " \"https://www.example.com/vocab/format\": true,\r\n" + + " \"https://json-schema.org/draft/2020-12/vocab/applicator\": true,\r\n" + + " \"https://json-schema.org/draft/2020-12/vocab/core\": true\r\n" + + " },\r\n" + + " \"allOf\": [\r\n" + + " { \"$ref\": \"https://json-schema.org/draft/2020-12/meta/applicator\" },\r\n" + + " { \"$ref\": \"https://json-schema.org/draft/2020-12/meta/core\" }\r\n" + + " ]\r\n" + + "}"; + String schemaData = "{\r\n" + + " \"$id\": \"https://schema/using/no/format\",\r\n" + + " \"$schema\": \"https://www.example.com/no-validation-no-format/schema\",\r\n" + + " \"properties\": {\r\n" + + " \"dateProperty\": {\r\n" + + " \"format\": \"date\"\r\n" + + " }\r\n" + + " }\r\n" + + "}"; + JsonSchema schema = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaLoaders(schemaLoaders -> schemaLoaders.schemas(Collections + .singletonMap("https://www.example.com/no-validation-no-format/schema", + metaSchemaData)))) + .getSchema(schemaData); + + String inputDataNoValidation = "{\r\n" + + " \"dateProperty\": \"hello\"\r\n" + + "}"; + + Set messages = schema.validate(inputDataNoValidation, InputFormat.JSON, + executionContext -> executionContext.getExecutionConfig().setFormatAssertionsEnabled(true)); + assertEquals(0, messages.size()); + + // Set format assertion vocab + schema = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaLoaders(schemaLoaders -> schemaLoaders.schemas(Collections + .singletonMap("https://www.example.com/no-validation-no-format/schema", + metaSchemaData.replace("https://www.example.com/vocab/format", + Vocabulary.V202012_FORMAT_ASSERTION.getId()))))) + .getSchema(schemaData); + messages = schema.validate(inputDataNoValidation, InputFormat.JSON); + assertEquals(1, messages.size()); + assertEquals("format", messages.iterator().next().getType()); + } +}