From 0e150641e19d0ff030b1321912183236a2205d70 Mon Sep 17 00:00:00 2001 From: Rafael Caricio Date: Sun, 24 Oct 2021 16:17:20 +0200 Subject: [PATCH] feat: Implement `prefixItems` keyword --- .github/workflows/build.yml | 5 +- CHANGELOG.md | 4 +- jsonschema/Cargo.toml | 1 + .../draft2020-12/meta/applicator.json | 48 ++++ .../draft2020-12/meta/content.json | 17 ++ .../meta_schemas/draft2020-12/meta/core.json | 51 ++++ .../draft2020-12/meta/format-annotation.json | 14 + .../draft2020-12/meta/meta-data.json | 37 +++ .../draft2020-12/meta/unevaluated.json | 15 + .../draft2020-12/meta/validation.json | 98 +++++++ .../meta_schemas/draft2020-12/schema.json | 58 ++++ jsonschema/src/compilation/options.rs | 83 ++++++ jsonschema/src/keywords/contains.rs | 50 ++-- jsonschema/src/keywords/items.rs | 110 +++++++- jsonschema/src/keywords/mod.rs | 1 + jsonschema/src/keywords/prefix_items.rs | 262 ++++++++++++++++++ jsonschema/src/schemas.rs | 41 ++- jsonschema/tests/test_suite.rs | 39 +++ 18 files changed, 899 insertions(+), 35 deletions(-) create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/applicator.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/content.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/core.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/format-annotation.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/meta-data.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/unevaluated.json create mode 100644 jsonschema/meta_schemas/draft2020-12/meta/validation.json create mode 100644 jsonschema/meta_schemas/draft2020-12/schema.json create mode 100644 jsonschema/src/keywords/prefix_items.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d5f8ce0..0e6b56f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,8 +38,9 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + draft: [draft201909, draft202012] - name: Test (stable) on ${{ matrix.os}} + name: Test ${{ matrix.draft }} (stable) on ${{ matrix.os}} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 @@ -51,7 +52,7 @@ jobs: profile: minimal toolchain: stable override: true - - run: cargo test --no-fail-fast --features draft201909 + - run: cargo test --no-fail-fast --features ${{ matrix.draft }} working-directory: ./jsonschema coverage: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cba6ec..211cad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Support for `prefixItems` keyword. [#303](https://github.com/Stranger6667/jsonschema-rs/pull/303) + ## [0.13.1] - 2021-10-28 ### Fixed @@ -14,7 +16,7 @@ - `uuid` format validator. [#266](https://github.com/Stranger6667/jsonschema-rs/issues/266) - `duration` format validator. [#265](https://github.com/Stranger6667/jsonschema-rs/issues/265) -- Collect annotations whilst evaulating schemas. [#262](https://github.com/Stranger6667/jsonschema-rs/issues/262) +- Collect annotations whilst evaluating schemas. [#262](https://github.com/Stranger6667/jsonschema-rs/issues/262) - Option to turn off processing of the `format` keyword. [#261](https://github.com/Stranger6667/jsonschema-rs/issues/261) - `basic` & `flag` output formatting styles. [#100](https://github.com/Stranger6667/jsonschema-rs/issues/100) - Support for `dependentRequired` & `dependentSchemas` keywords. [#286](https://github.com/Stranger6667/jsonschema-rs/issues/286) diff --git a/jsonschema/Cargo.toml b/jsonschema/Cargo.toml index 95c0050..fc71f32 100644 --- a/jsonschema/Cargo.toml +++ b/jsonschema/Cargo.toml @@ -19,6 +19,7 @@ name = "jsonschema" cli = ["structopt"] default = ["reqwest", "cli"] draft201909 = [] +draft202012 = [] reqwest-native-tls = ["reqwest/native-tls"] reqwest-native-tls-alpn = ["reqwest/native-tls-alpn"] reqwest-native-tls-vendored = ["reqwest/native-tls-vendored"] diff --git a/jsonschema/meta_schemas/draft2020-12/meta/applicator.json b/jsonschema/meta_schemas/draft2020-12/meta/applicator.json new file mode 100644 index 0000000..ca69923 --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/applicator.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true + }, + "$dynamicAnchor": "meta", + + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/content.json b/jsonschema/meta_schemas/draft2020-12/meta/content.json new file mode 100644 index 0000000..2f6e056 --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/content.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Content vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/core.json b/jsonschema/meta_schemas/draft2020-12/meta/core.json new file mode 100644 index 0000000..dfc092d --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/core.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "$dynamicAnchor": "meta", + + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/format-annotation.json b/jsonschema/meta_schemas/draft2020-12/meta/format-annotation.json new file mode 100644 index 0000000..51ef7ea --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/format-annotation.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true + }, + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/meta-data.json b/jsonschema/meta_schemas/draft2020-12/meta/meta-data.json new file mode 100644 index 0000000..05cbc22 --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/meta-data.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/meta-data": true + }, + "$dynamicAnchor": "meta", + + "title": "Meta-data vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/unevaluated.json b/jsonschema/meta_schemas/draft2020-12/meta/unevaluated.json new file mode 100644 index 0000000..5f62a3f --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/unevaluated.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true + }, + "$dynamicAnchor": "meta", + + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/meta/validation.json b/jsonschema/meta_schemas/draft2020-12/meta/validation.json new file mode 100644 index 0000000..606b87b --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/meta/validation.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/jsonschema/meta_schemas/draft2020-12/schema.json b/jsonschema/meta_schemas/draft2020-12/schema.json new file mode 100644 index 0000000..a816ea7 --- /dev/null +++ b/jsonschema/meta_schemas/draft2020-12/schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} diff --git a/jsonschema/src/compilation/options.rs b/jsonschema/src/compilation/options.rs index 1980aa2..a965754 100644 --- a/jsonschema/src/compilation/options.rs +++ b/jsonschema/src/compilation/options.rs @@ -24,6 +24,14 @@ lazy_static::lazy_static! { static ref DRAFT201909_FORMAT:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2019-09/meta/format.json")).expect("Valid schema!"); static ref DRAFT201909_META_DATA:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2019-09/meta/meta-data.json")).expect("Valid schema!"); static ref DRAFT201909_VALIDATION:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2019-09/meta/validation.json")).expect("Valid schema!"); + static ref DRAFT202012:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/schema.json")).expect("Valid schema!"); + static ref DRAFT202012_CORE:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/core.json")).expect("Valid schema!"); + static ref DRAFT202012_APPLICATOR:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/applicator.json")).expect("Valid schema!"); + static ref DRAFT202012_UNEVALUATED:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/unevaluated.json")).expect("Valid schema!"); + static ref DRAFT202012_VALIDATION:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/validation.json")).expect("Valid schema!"); + static ref DRAFT202012_META_DATA:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/meta-data.json")).expect("Valid schema!"); + static ref DRAFT202012_FORMAT_ANNOTATION:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/format-annotation.json")).expect("Valid schema!"); + static ref DRAFT202012_CONTENT:serde_json::Value = serde_json::from_str(include_str!("../../meta_schemas/draft2020-12/meta/content.json")).expect("Valid schema!"); static ref META_SCHEMAS: AHashMap> = { let mut store = AHashMap::with_capacity(3); @@ -70,6 +78,41 @@ lazy_static::lazy_static! { Arc::new(DRAFT201909_VALIDATION.clone()) ); } + #[cfg(feature = "draft202012")] + { + store.insert( + "https://json-schema.org/draft/2020-12/schema".to_string(), + Arc::new(DRAFT202012.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/core".to_string(), + Arc::new(DRAFT202012_CORE.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/applicator".to_string(), + Arc::new(DRAFT202012_APPLICATOR.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/unevaluated".to_string(), + Arc::new(DRAFT202012_UNEVALUATED.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/validation".to_string(), + Arc::new(DRAFT202012_VALIDATION.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/meta-data".to_string(), + Arc::new(DRAFT202012_META_DATA.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/format-annotation".to_string(), + Arc::new(DRAFT202012_FORMAT_ANNOTATION.clone()) + ); + store.insert( + "https://json-schema.org/draft/2020-12/meta/content".to_string(), + Arc::new(DRAFT202012_CONTENT.clone()) + ); + } store }; @@ -119,6 +162,46 @@ lazy_static::lazy_static! { .compile(&DRAFT201909) .expect(EXPECT_MESSAGE) ); + #[cfg(feature = "draft202012")] + store.insert( + schemas::Draft::Draft202012, + JSONSchema::options() + .without_schema_validation() + .with_document( + "https://json-schema.org/draft/2020-12/meta/applicator".to_string(), + DRAFT202012_APPLICATOR.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/core".to_string(), + DRAFT202012_CORE.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/applicator".to_string(), + DRAFT202012_APPLICATOR.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/unevaluated".to_string(), + DRAFT202012_UNEVALUATED.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/validation".to_string(), + DRAFT202012_VALIDATION.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/meta-data".to_string(), + DRAFT202012_META_DATA.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/format-annotation".to_string(), + DRAFT202012_FORMAT_ANNOTATION.clone() + ) + .with_document( + "https://json-schema.org/draft/2020-12/meta/content".to_string(), + DRAFT202012_CONTENT.clone() + ) + .compile(&DRAFT202012) + .expect(EXPECT_MESSAGE) + ); store }; } diff --git a/jsonschema/src/keywords/contains.rs b/jsonschema/src/keywords/contains.rs index 3a7a3df..56a1ee1 100644 --- a/jsonschema/src/keywords/contains.rs +++ b/jsonschema/src/keywords/contains.rs @@ -9,7 +9,7 @@ use crate::{ }; use serde_json::{Map, Value}; -#[cfg(feature = "draft201909")] +#[cfg(any(feature = "draft201909", feature = "draft202012"))] use super::helpers::map_get_u64; pub(crate) struct ContainsValidator { @@ -423,26 +423,36 @@ pub(crate) fn compile<'a>( Draft::Draft4 | Draft::Draft6 | Draft::Draft7 => { Some(ContainsValidator::compile(schema, context)) } - #[cfg(feature = "draft201909")] - Draft::Draft201909 => { - let min_contains = match map_get_u64(parent, context, "minContains").transpose() { - Ok(n) => n, - Err(err) => return Some(Err(err)), - }; - let max_contains = match map_get_u64(parent, context, "maxContains").transpose() { - Ok(n) => n, - Err(err) => return Some(Err(err)), - }; + #[cfg(all(feature = "draft201909", feature = "draft202012"))] + Draft::Draft201909 | Draft::Draft202012 => compile_contains(parent, schema, context), + #[cfg(all(feature = "draft201909", not(feature = "draft202012")))] + Draft::Draft201909 => compile_contains(parent, schema, context), + #[cfg(all(feature = "draft202012", not(feature = "draft201909")))] + Draft::Draft202012 => compile_contains(parent, schema, context), + } +} - match (min_contains, max_contains) { - (Some(min), Some(max)) => { - Some(MinMaxContainsValidator::compile(schema, context, min, max)) - } - (Some(min), None) => Some(MinContainsValidator::compile(schema, context, min)), - (None, Some(max)) => Some(MaxContainsValidator::compile(schema, context, max)), - (None, None) => Some(ContainsValidator::compile(schema, context)), - } - } +#[cfg(any(feature = "draft201909", feature = "draft202012"))] +#[inline] +fn compile_contains<'a>( + parent: &'a Map, + schema: &'a Value, + context: &CompilationContext, +) -> Option> { + let min_contains = match map_get_u64(parent, context, "minContains").transpose() { + Ok(n) => n, + Err(err) => return Some(Err(err)), + }; + let max_contains = match map_get_u64(parent, context, "maxContains").transpose() { + Ok(n) => n, + Err(err) => return Some(Err(err)), + }; + + match (min_contains, max_contains) { + (Some(min), Some(max)) => Some(MinMaxContainsValidator::compile(schema, context, min, max)), + (Some(min), None) => Some(MinContainsValidator::compile(schema, context, min)), + (None, Some(max)) => Some(MaxContainsValidator::compile(schema, context, max)), + (None, None) => Some(ContainsValidator::compile(schema, context)), } } diff --git a/jsonschema/src/keywords/items.rs b/jsonschema/src/keywords/items.rs index 1e40d8e..4ca9af9 100644 --- a/jsonschema/src/keywords/items.rs +++ b/jsonschema/src/keywords/items.rs @@ -75,6 +75,7 @@ impl core::fmt::Display for ItemsArrayValidator { pub(crate) struct ItemsObjectValidator { node: SchemaNode, } + impl ItemsObjectValidator { #[inline] pub(crate) fn compile<'a>( @@ -131,8 +132,9 @@ impl Validate for ItemsObjectValidator { let mut output: PartialApplication = results.into_iter().collect(); // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.2 // we must produce an annotation with a boolean value indicating whether the subschema - // was applied to any positions in the underlying array. As we have not yet implemented - // prefixItems this is true if there are any items in the instance. + // was applied to any positions in the underlying array. Since the struct + // `ItemsObjectValidator` is not used when prefixItems is defined, this is true if + // there are any items in the instance. let schema_was_applied = !items.is_empty(); output.annotate(serde_json::json! {schema_was_applied}.into()); output @@ -148,21 +150,111 @@ impl core::fmt::Display for ItemsObjectValidator { } } +pub(crate) struct ItemsObjectSkipPrefixValidator { + node: SchemaNode, + skip_prefix: usize, +} + +impl ItemsObjectSkipPrefixValidator { + #[inline] + pub(crate) fn compile<'a>( + schema: &'a Value, + skip_prefix: usize, + context: &CompilationContext, + ) -> CompilationResult<'a> { + let keyword_context = context.with_path("items"); + let node = compile_validators(schema, &keyword_context)?; + Ok(Box::new(ItemsObjectSkipPrefixValidator { + node, + skip_prefix, + })) + } +} + +impl Validate for ItemsObjectSkipPrefixValidator { + fn is_valid(&self, schema: &JSONSchema, instance: &Value) -> bool { + if let Value::Array(items) = instance { + items + .iter() + .skip(self.skip_prefix) + .all(|i| self.node.is_valid(schema, i)) + } else { + true + } + } + + #[allow(clippy::needless_collect)] + fn validate<'a, 'b>( + &self, + schema: &'a JSONSchema, + instance: &'b Value, + instance_path: &InstancePath, + ) -> ErrorIterator<'b> { + if let Value::Array(items) = instance { + let errors: Vec<_> = items + .iter() + .skip(self.skip_prefix) + .enumerate() + .flat_map(move |(idx, item)| { + self.node + .validate(schema, item, &instance_path.push(idx + self.skip_prefix)) + }) + .collect(); + Box::new(errors.into_iter()) + } else { + no_error() + } + } + + fn apply<'a>( + &'a self, + schema: &JSONSchema, + instance: &Value, + instance_path: &InstancePath, + ) -> PartialApplication<'a> { + if let Value::Array(items) = instance { + let mut results = Vec::with_capacity(items.len() - self.skip_prefix); + for (idx, item) in items.iter().skip(self.skip_prefix).enumerate() { + let path = instance_path.push(idx + self.skip_prefix); + results.push(self.node.apply_rooted(schema, item, &path)); + } + let mut output: PartialApplication = results.into_iter().collect(); + // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.2 + // we must produce an annotation with a boolean value indicating whether the subschema + // was applied to any positions in the underlying array. + let schema_was_applied = items.len() > self.skip_prefix; + output.annotate(serde_json::json! {schema_was_applied}.into()); + output + } else { + PartialApplication::valid_empty() + } + } +} + +impl core::fmt::Display for ItemsObjectSkipPrefixValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "items: {}", format_validators(self.node.validators())) + } +} + #[inline] pub(crate) fn compile<'a>( - _: &'a Map, + parent: &'a Map, schema: &'a Value, context: &CompilationContext, ) -> Option> { match schema { Value::Array(items) => Some(ItemsArrayValidator::compile(items, context)), - Value::Object(_) => Some(ItemsObjectValidator::compile(schema, context)), - Value::Bool(value) => { - if *value { - None - } else { - Some(ItemsObjectValidator::compile(schema, context)) + Value::Object(_) | Value::Bool(false) => { + #[cfg(feature = "draft202012")] + if let Some(Value::Array(prefix_items)) = parent.get("prefixItems") { + return Some(ItemsObjectSkipPrefixValidator::compile( + schema, + prefix_items.len(), + context, + )); } + Some(ItemsObjectValidator::compile(schema, context)) } _ => None, } diff --git a/jsonschema/src/keywords/mod.rs b/jsonschema/src/keywords/mod.rs index 8008374..a8af996 100644 --- a/jsonschema/src/keywords/mod.rs +++ b/jsonschema/src/keywords/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod not; pub(crate) mod one_of; pub(crate) mod pattern; pub(crate) mod pattern_properties; +pub(crate) mod prefix_items; pub(crate) mod properties; pub(crate) mod property_names; pub(crate) mod ref_; diff --git a/jsonschema/src/keywords/prefix_items.rs b/jsonschema/src/keywords/prefix_items.rs new file mode 100644 index 0000000..1947f2a --- /dev/null +++ b/jsonschema/src/keywords/prefix_items.rs @@ -0,0 +1,262 @@ +use crate::{ + compilation::{compile_validators, context::CompilationContext, JSONSchema}, + error::{no_error, ErrorIterator, ValidationError}, + paths::{InstancePath, JSONPointer}, + primitive_type::PrimitiveType, + schema_node::SchemaNode, + validator::{format_iter_of_validators, PartialApplication, Validate}, +}; +use serde_json::{Map, Value}; + +use super::CompilationResult; + +pub(crate) struct PrefixItemsValidator { + schemas: Vec, +} + +impl PrefixItemsValidator { + #[inline] + pub(crate) fn compile<'a>( + items: &'a [Value], + context: &CompilationContext, + ) -> CompilationResult<'a> { + let keyword_context = context.with_path("prefixItems"); + let mut schemas = Vec::with_capacity(items.len()); + for (idx, item) in items.iter().enumerate() { + let item_context = keyword_context.with_path(idx); + let validators = compile_validators(item, &item_context)?; + schemas.push(validators) + } + Ok(Box::new(PrefixItemsValidator { schemas })) + } +} + +impl Validate for PrefixItemsValidator { + fn is_valid(&self, schema: &JSONSchema, instance: &Value) -> bool { + if let Value::Array(items) = instance { + self.schemas + .iter() + .zip(items.iter()) + .all(|(n, i)| n.is_valid(schema, i)) + } else { + true + } + } + + #[allow(clippy::needless_collect)] + fn validate<'a, 'b>( + &self, + schema: &'a JSONSchema, + instance: &'b Value, + instance_path: &InstancePath, + ) -> ErrorIterator<'b> { + if let Value::Array(items) = instance { + let errors: Vec<_> = self + .schemas + .iter() + .zip(items.iter()) + .enumerate() + .flat_map(|(idx, (n, i))| n.validate(schema, i, &instance_path.push(idx))) + .collect(); + Box::new(errors.into_iter()) + } else { + no_error() + } + } + + fn apply<'a>( + &'a self, + schema: &JSONSchema, + instance: &Value, + instance_path: &InstancePath, + ) -> PartialApplication<'a> { + if let Value::Array(items) = instance { + if !items.is_empty() { + let validate_total = self.schemas.len(); + let mut results = Vec::with_capacity(validate_total); + let mut max_index_applied = 0; + for (idx, (schema_node, item)) in self.schemas.iter().zip(items.iter()).enumerate() + { + let path = instance_path.push(idx); + results.push(schema_node.apply_rooted(schema, item, &path)); + max_index_applied = idx; + } + // Per draft 2020-12 section https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.10.3.1.1 + // we must produce an annotation with the largest index of the underlying + // array which the subschema was applied. The value MAY be a boolean true if + // a subschema was applied to every index of the instance. + let schema_was_applied: Value = if results.len() == items.len() { + true.into() + } else { + max_index_applied.into() + }; + let mut output: PartialApplication = results.into_iter().collect(); + output.annotate(schema_was_applied.into()); + return output; + } + } + PartialApplication::valid_empty() + } +} + +impl core::fmt::Display for PrefixItemsValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "prefixItems: [{}]", + format_iter_of_validators(self.schemas.iter().map(SchemaNode::validators)) + ) + } +} + +#[inline] +pub(crate) fn compile<'a>( + _: &'a Map, + schema: &'a Value, + context: &CompilationContext, +) -> Option> { + if let Value::Array(items) = schema { + Some(PrefixItemsValidator::compile(items, context)) + } else { + Some(Err(ValidationError::single_type_error( + JSONPointer::default(), + context.clone().into_pointer(), + schema, + PrimitiveType::Array, + ))) + } +} + +#[cfg(all(test, feature = "draft202012"))] +mod tests { + use crate::compilation::JSONSchema; + use crate::tests_util; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case(&json!({"prefixItems": [{"type": "integer"}, {"maximum": 5}]}), &json!(["string"]), "/prefixItems/0/type")] + #[test_case(&json!({"prefixItems": [{"type": "integer"}, {"maximum": 5}]}), &json!([42, 42]), "/prefixItems/1/maximum")] + #[test_case(&json!({"prefixItems": [{"type": "integer"}, {"maximum": 5}], "items": {"type": "boolean"}}), &json!([42, 1, 42]), "/items/type")] + #[test_case(&json!({"prefixItems": [{"type": "integer"}, {"maximum": 5}], "items": {"type": "boolean"}}), &json!([42, 42, true]), "/prefixItems/1/maximum")] + fn schema_path(schema: &Value, instance: &Value, expected: &str) { + tests_util::assert_schema_path(schema, instance, expected) + } + + #[test_case(&json!({"prefixItems": [{"type": "integer"}, {"maximum": 5}]}), "prefixItems: [{type: integer}, {maximum: 5}]")] + fn debug_representation(schema: &Value, expected: &str) { + let compiled = JSONSchema::compile(schema).unwrap(); + assert_eq!( + format!("{:?}", compiled.node.validators().next().unwrap()), + expected + ); + } + + #[test_case{ + &json!({ + "type": "array", + "prefixItems": [ + { + "type": "string" + } + ] + }), + &json!{[]}, + &json!({ + "valid": true, + "annotations": [] + }); "valid prefixItems empty array" + }] + #[test_case{ + &json!({ + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }), + &json!{["string", 1]}, + &json!({ + "valid": true, + "annotations": [ + { + "keywordLocation": "/prefixItems", + "instanceLocation": "", + "annotations": true + }, + ] + }); "prefixItems valid items" + }] + #[test_case{ + &json!({ + "type": "array", + "prefixItems": [ + { + "type": "string" + } + ] + }), + &json!{["string", 1]}, + &json!({ + "valid": true, + "annotations": [ + { + "keywordLocation": "/prefixItems", + "instanceLocation": "", + "annotations": 0 + }, + ] + }); "prefixItems valid mixed items" + }] + #[test_case{ + &json!({ + "type": "array", + "items": { + "type": "number", + "annotation": "value" + }, + "prefixItems": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }), + &json!{["string", true, 2, 3]}, + &json!({ + "valid": true, + "annotations": [ + { + "keywordLocation": "/prefixItems", + "instanceLocation": "", + "annotations": 1 + }, + { + "keywordLocation": "/items", + "instanceLocation": "", + "annotations": true + }, + { + "annotations": {"annotation": "value" }, + "instanceLocation": "/2", + "keywordLocation": "/items" + }, + { + "annotations": {"annotation": "value" }, + "instanceLocation": "/3", + "keywordLocation": "/items" + } + ] + }); "valid prefixItems with mixed items" + }] + fn test_basic_output(schema_json: &Value, instance: &Value, expected_output: &Value) { + let schema = JSONSchema::options().compile(schema_json).unwrap(); + let output_json = serde_json::to_value(schema.apply(instance).basic()).unwrap(); + assert_eq!(&output_json, expected_output); + } +} diff --git a/jsonschema/src/schemas.rs b/jsonschema/src/schemas.rs index b753130..fb9c368 100644 --- a/jsonschema/src/schemas.rs +++ b/jsonschema/src/schemas.rs @@ -14,6 +14,10 @@ pub enum Draft { #[cfg(feature = "draft201909")] /// JSON Schema Draft 2019-09 Draft201909, + + #[cfg(feature = "draft202012")] + /// JSON Schema Draft 2020-12 + Draft202012, } impl Default for Draft { @@ -26,8 +30,12 @@ impl Draft { pub(crate) const fn validate_formats_by_default(self) -> bool { match self { Draft::Draft4 | Draft::Draft6 | Draft::Draft7 => true, - #[cfg(feature = "draft201909")] + #[cfg(all(feature = "draft201909", feature = "draft202012"))] + Draft::Draft201909 | Draft::Draft202012 => false, + #[cfg(all(feature = "draft201909", not(feature = "draft202012")))] Draft::Draft201909 => false, + #[cfg(all(feature = "draft202012", not(feature = "draft201909")))] + Draft::Draft202012 => false, } } } @@ -51,12 +59,16 @@ impl Draft { Draft::Draft6 | Draft::Draft7 => Some(keywords::const_::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::const_::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::const_::compile), }, "contains" => match self { Draft::Draft4 => None, Draft::Draft6 | Draft::Draft7 => Some(keywords::contains::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::contains::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::contains::compile), }, "contentMediaType" => match self { Draft::Draft7 | Draft::Draft6 => Some(keywords::content::compile_media_type), @@ -64,6 +76,8 @@ impl Draft { #[cfg(feature = "draft201909")] // Should be collected as an annotation Draft::Draft201909 => None, + #[cfg(feature = "draft202012")] + Draft::Draft202012 => None, }, "contentEncoding" => match self { Draft::Draft7 | Draft::Draft6 => Some(keywords::content::compile_content_encoding), @@ -71,11 +85,13 @@ impl Draft { #[cfg(feature = "draft201909")] // Should be collected as an annotation Draft::Draft201909 => None, + #[cfg(feature = "draft202012")] + Draft::Draft202012 => None, }, "dependencies" => Some(keywords::dependencies::compile), - #[cfg(feature = "draft201909")] + #[cfg(any(feature = "draft201909", feature = "draft202012"))] "dependentRequired" => Some(keywords::dependencies::compile_dependent_required), - #[cfg(feature = "draft201909")] + #[cfg(any(feature = "draft201909", feature = "draft202012"))] "dependentSchemas" => Some(keywords::dependencies::compile_dependent_schemas), "enum" => Some(keywords::enum_::compile), "exclusiveMaximum" => match self { @@ -83,12 +99,16 @@ impl Draft { Draft::Draft4 => None, #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::exclusive_maximum::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::exclusive_maximum::compile), }, "exclusiveMinimum" => match self { Draft::Draft7 | Draft::Draft6 => Some(keywords::exclusive_minimum::compile), Draft::Draft4 => None, #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::exclusive_minimum::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::exclusive_minimum::compile), }, "format" => Some(keywords::format::compile), "if" => match self { @@ -96,6 +116,8 @@ impl Draft { Draft::Draft6 | Draft::Draft4 => None, #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::if_::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::if_::compile), }, "items" => Some(keywords::items::compile), "maximum" => match self { @@ -103,6 +125,8 @@ impl Draft { Draft::Draft6 | Draft::Draft7 => Some(keywords::maximum::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::maximum::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::maximum::compile), }, "maxItems" => Some(keywords::max_items::compile), "maxLength" => Some(keywords::max_length::compile), @@ -112,6 +136,8 @@ impl Draft { Draft::Draft6 | Draft::Draft7 => Some(keywords::minimum::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::minimum::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::minimum::compile), }, "minItems" => Some(keywords::min_items::compile), "minLength" => Some(keywords::min_length::compile), @@ -121,12 +147,16 @@ impl Draft { "oneOf" => Some(keywords::one_of::compile), "pattern" => Some(keywords::pattern::compile), "patternProperties" => Some(keywords::pattern_properties::compile), + #[cfg(feature = "draft202012")] + "prefixItems" => Some(keywords::prefix_items::compile), "properties" => Some(keywords::properties::compile), "propertyNames" => match self { Draft::Draft4 => None, Draft::Draft6 | Draft::Draft7 => Some(keywords::property_names::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::property_names::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::property_names::compile), }, "required" => Some(keywords::required::compile), "type" => match self { @@ -134,6 +164,8 @@ impl Draft { Draft::Draft6 | Draft::Draft7 => Some(keywords::type_::compile), #[cfg(feature = "draft201909")] Draft::Draft201909 => Some(keywords::type_::compile), + #[cfg(feature = "draft202012")] + Draft::Draft202012 => Some(keywords::type_::compile), }, "uniqueItems" => Some(keywords::unique_items::compile), _ => None, @@ -145,6 +177,8 @@ impl Draft { #[inline] pub(crate) fn draft_from_url(url: &str) -> Option { match url { + #[cfg(feature = "draft202012")] + "https://json-schema.org/draft/2020-12/schema#" => Some(Draft::Draft202012), #[cfg(feature = "draft201909")] "https://json-schema.org/draft/2019-09/schema#" => Some(Draft::Draft201909), "http://json-schema.org/draft-07/schema#" => Some(Draft::Draft7), @@ -184,6 +218,7 @@ mod tests { use test_case::test_case; #[cfg_attr(feature = "draft201909", test_case(&json!({"$schema": "https://json-schema.org/draft/2019-09/schema#"}), Some(Draft::Draft201909)))] + #[cfg_attr(feature = "draft202012", test_case(&json!({"$schema": "https://json-schema.org/draft/2020-12/schema#"}), Some(Draft::Draft202012)))] #[test_case(&json!({"$schema": "http://json-schema.org/draft-07/schema#"}), Some(Draft::Draft7))] #[test_case(&json!({"$schema": "http://json-schema.org/draft-06/schema#"}), Some(Draft::Draft6))] #[test_case(&json!({"$schema": "http://json-schema.org/draft-04/schema#"}), Some(Draft::Draft4))] diff --git a/jsonschema/tests/test_suite.rs b/jsonschema/tests/test_suite.rs index 96b9c2f..2471850 100644 --- a/jsonschema/tests/test_suite.rs +++ b/jsonschema/tests/test_suite.rs @@ -37,6 +37,43 @@ use std::fs; r"unevaluatedItems_.+", r"unevaluatedProperties_.+", }))] +#[cfg_attr(feature = "draft202012", json_schema_test_suite("tests/suite", "draft2020-12", { + r"optional_format_idn_hostname_0_\d+", // https://github.com/Stranger6667/jsonschema-rs/issues/101 + r"format_\d+_6", // https://github.com/Stranger6667/jsonschema-rs/issues/261 + // These depend on the new `$defs` keyword (which is renamed from `definitions`) + r"id_0_[0-6]", + // Various types of new behavior used in the `$ref` context + "ref_5_1", + "ref_13_0", + "refRemote_4_0", + "refRemote_4_1", + "recursiveRef_0_3", + "recursiveRef_1_2", + "recursiveRef_1_4", + "recursiveRef_3_2", + "recursiveRef_3_4", + "recursiveRef_4_2", + "recursiveRef_4_4", + "recursiveRef_5_2", + "recursiveRef_6_2", + "recursiveRef_7_0", + "recursiveRef_7_1", + // New keywords & formats. + // https://github.com/Stranger6667/jsonschema-rs/issues/100 + r"anchor_.+", + r"defs_.+", + r"dynamicRef_.+", + r"uniqueItems_.+", + r"optional_format_duration_.+", // https://github.com/Stranger6667/jsonschema-rs/issues/265 + r"optional_format_uuid_.+", // https://github.com/Stranger6667/jsonschema-rs/issues/266 + r"optional_format_iri_.+", + r"optional_format_json_pointer_.+", + r"optional_format_relative_json_pointer_.+", + r"optional_format_uri_reference_.+", + r"optional_format_uri_template_.+", + r"unevaluatedItems_.+", + r"unevaluatedProperties_.+", +}))] fn test_draft(_server_address: &str, test_case: TestCase) { let draft_version = match test_case.draft_version.as_ref() { "draft4" => Draft::Draft4, @@ -44,6 +81,8 @@ fn test_draft(_server_address: &str, test_case: TestCase) { "draft7" => Draft::Draft7, #[cfg(feature = "draft201909")] "draft2019-09" => Draft::Draft201909, + #[cfg(feature = "draft202012")] + "draft2020-12" => Draft::Draft202012, _ => panic!("Unsupported draft"), };