diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3a858ab..d94ea18 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,3 +44,6 @@ jobs: - name: Run tests (OPA Conformance) run: >- cargo test -r --test opa --frozen --features opa-testutil,serde_json/arbitrary_precision -- $(tr '\n' ' ' < tests/opa.passing) + - name: Run tests (Azure Policy) + run: >- + cargo test --frozen --features azure_policy diff --git a/Cargo.lock b/Cargo.lock index b68ed6a..21abc3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,9 +48,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -78,22 +78,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -171,9 +171,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.31" +version = "1.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" dependencies = [ "shlex", ] @@ -480,9 +480,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -869,7 +869,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1145,9 +1145,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1511,7 +1511,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -1579,7 +1579,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", ] [[package]] @@ -1588,14 +1597,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] @@ -1604,48 +1630,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -1739,9 +1813,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", diff --git a/Cargo.toml b/Cargo.toml index 11be622..6cf5588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ default = ["full-opa", "arc"] arc = ["scientific/arc"] ast = [] -azure_policy = [] +azure_policy = ["dep:jsonschema"] base64 = ["dep:data-encoding"] base64url = ["dep:data-encoding"] coverage = [] diff --git a/src/lib.rs b/src/lib.rs index 3af030d..8c40dff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,8 @@ mod lexer; mod number; mod parser; mod scheduler; +#[cfg(feature = "azure_policy")] +mod schema; mod utils; mod value; diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..0304829 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,1033 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// There are two type systems of interest: +/// 1. JSON Schema used by Azure Policy for some of its metadata. +/// 2. Bicep's type system generated from Azure API swagger files. +/// https://github.com/Azure/bicep-types/blob/main/src/Bicep.Types/ConcreteTypes +/// +/// JSON Schema is standardized and well documented, with good tooling support. +/// JSON Schema is quite flexible. The following schema: +/// { +/// "allOf": [ +/// { +/// "properties": { +/// "name": {"type": "string" } +/// }, +/// "required": ["name"] +/// }, +/// { +/// "properties": { +/// "age": {"type": "integer" } +/// }, +/// "required": ["age"] +/// }, +/// { +/// "minLength": 5 +/// } +/// ] +/// } +/// +/// expresses the constraint that if a value happens to be an object, then it must have a string field `name`, +/// and also an integer field 'age'. If it happens to be a string, it must have a minimum length of 5. +/// There are also different ways to express the same contraint. +/// +/// Such flexibility is not needed for our use cases as shown by Bicep's type system which only allows a subset of +/// the constraints expressible in JSON Schema yet represents Azure Resources. Note that Bicep's type system models +/// some JSON schema concepts such as `oneOf` differently. +/// +/// For Regorus' type system, we will use a subset of JSON Schema that is needed to support Azure Policy. +/// This subset is initially derived from the Bicep type system, but has a few other JSON Schema concepts like +/// `enum`, `const` that are needed for Azure Policy. Additional JSON Schema features will be supported as needed. +/// This approach is consistent with Azure Policy's use of JSON Schema for metadata. +/// We can also potentially reuse the type schemas (https://github.com/Azure/bicep-types-az) +/// that the Bicep team generates from Azure API swagger files, using a custom deserializer to convert them to our type system. +/// +/// Here is the mapping between Bicep's type system and JSON Schema: +/// +/// AnyType +/// Bicep: { "$type": "AnyType" } +/// JSON Schema: {} +/// +/// BooleanType +/// Bicep: { "$type": "BooleanType" } +/// JSON Schema: { "type": "boolean" } +/// +/// NullType +/// Bicep: { "$type": "NullType" } +/// JSON Schema: { "type": "null" } +/// +/// IntegerType +/// Bicep: { "$type": "IntegerType", "minValue": X, "maxValue": Y } +/// JSON Schema: { "type": "integer", "minimum": X, "maximum": Y } +/// +/// NumberType (no Bicep equivalent) +/// Bicep: No equivalent +/// JSON Schema: { "type": "number", "minimum": X, "maximum": Y } +/// +/// StringType +/// Bicep: { +/// "$type": "StringType", +/// "minLength": X, +/// "maxLength": Y, +// "pattern": "..." +/// } +/// JSON Schema: { +/// "type": "string", +/// "minLength": X, +/// "maxLength": Y, +/// "pattern": "..." +/// } +/// +/// Integer Constant (no Bicep equivalent) +/// Bicep: No equivalent +/// JSON Schema: { "const": 5 } +/// +/// UnionType +/// Bicep: { "$type": "UnionType", "elements": [...] } +/// JSON Schema: { "enum": [...] } or { "anyOf": [...] } +/// +/// Enum with inline values (no Bicep equivalent) +/// Bicep: No equivalent +/// JSON Schema: { "enum": [8, 10] } +/// +/// ObjectType +/// Bicep: { +/// "$type": "ObjectType", +/// "name": "Test.Rp1/testType1", +/// "properties": { +/// "id": { +/// "type": { "$ref": "#/2" }, +/// "flags": 10, +/// "description": "The resource id" +/// } +/// }, +/// "additionalProperties": { "$ref": "#/3" } +/// } +/// JSON Schema: { +/// "type": "object", +/// "properties": { +/// "id": { +/// "type": "integer", +/// "description": "The resource id" +/// } +/// }, +/// "required": ["id"], +/// "additionalProperties": { "type": "boolean" } +/// } +/// +/// DiscriminatedObjectType +/// Bicep: { +/// "$type": "DiscriminatedObjectType", +/// "name": "Microsoft.Security/settings", +/// "discriminator": "kind", +/// "baseProperties": { +/// "name": { +/// "type": { "$ref": "#/5" }, +/// "flags": 9, +/// "description": "The resource name" +/// } +/// }, +/// "elements": { +/// "ASubObject": { "$ref": "#/9" }, +/// "BSubObject": { "$ref": "#/13" } +/// } +/// } +/// JSON Schema: { +/// "type": "object", +/// "properties": { +/// "name": { +/// "type": "string", +/// "description": "The resource name" +/// }, +/// "kind": { +/// "description": "The kind of the resource", +/// "enum": ["ASubObject", "BSubObject"] +/// } +/// }, +/// "allOf": [ +/// { +/// "if": { +/// "properties": { +/// "kind": { "const": "ASubObject" } +/// } +/// }, +/// "then": { +/// "properties": { +/// "apropertyA": { +/// "type": "string", +/// "description": "Property A of ASubObject" +/// } +/// }, +/// "required": ["apropertyA"] +/// } +/// }, +/// { +/// "if": { +/// "properties": { +/// "kind": { "const": "BSubObject" } +/// } +/// }, +/// "then": { +/// "properties": { +/// "bpropertyB": { +/// "type": "string", +/// "description": "Property B of BSubObject" +/// } +/// }, +/// "required": ["bpropertyB"] +/// } +/// } +/// ] +/// } +/// +/// The type system is implemented with the following principles: +/// - Types are immutable and can be shared safely across threads, allowing parallel schema validation using the same type. +/// - Any unsupported JSON Schema feature should raise an error during type creation. Otherwise, the user will not know whether +/// parts of their schema are ignored or not. +/// - Leverage serde as much as possible for serialization and deserialization, avoiding custom serialization logic. +/// +/// We use a Rust enum to represent the type system, with each variant representing a different type. In each variant, +/// we list the properties that are relevant to that type, using `Option` for properties that are not required. +/// `deny_unknown_fields` is used to ensure that any unsupported fields in the JSON Schema will raise an error during deserialization. +/// Some properties like `description` are duplicated in each variant, since `deny_unknown_fields` cannot be used with `#[serde(flatten)]` +/// which would have allowed us to refactor the common properties into a single struct to avoid duplication. +use alloc::collections::BTreeMap; +use serde::{Deserialize, Deserializer}; + +use crate::{format, Box, Rc, Value, Vec}; + +type String = Rc; + +mod meta; + +/// A schema represents a type definition that can be used for validation. +/// +/// `Schema` is a lightweight wrapper around a [`Type`] that provides reference counting +/// for efficient sharing and cloning. It serves as the primary interface for working +/// with type definitions in the Regorus type system. +/// +/// # Usage +/// +/// Schemas are typically created by deserializing from JSON Schema format: +/// +/// ```rust +/// use serde_json::json; +/// +/// // Create a schema from JSON +/// let schema_json = json!({ +/// "type": "object", +/// "properties": { +/// "name": { "type": "string" }, +/// "age": { "type": "integer", "minimum": 0 } +/// }, +/// "required": ["name"] +/// }); +/// +/// let schema: Schema = serde_json::from_value(schema_json).unwrap(); +/// ``` +/// +/// # Supported Schema Features +/// +/// The schema system supports a subset of JSON Schema features needed for Azure Policy +/// and other use cases: +/// +/// - **Basic types**: `any`, `null`, `boolean`, `integer`, `number`, `string` +/// - **Complex types**: `array`, `set`, `object` +/// - **Value constraints**: `enum`, `const` +/// - **Composition**: `anyOf` for union types +/// - **String constraints**: `minLength`, `maxLength`, `pattern` +/// - **Numeric constraints**: `minimum`, `maximum` +/// - **Array constraints**: `minItems`, `maxItems` +/// - **Object features**: `properties`, `required`, `additionalProperties` +/// - **Discriminated unions**: via `allOf` with conditional schemas +/// # Thread Safety +/// +/// While `Schema` itself is not `Send` or `Sync` due to the use of `Rc`, it can be +/// safely shared within a single thread and cloned efficiently. For multi-threaded +/// scenarios, consider wrapping in `Arc` if needed. +/// +/// # Examples +/// +/// ## Simple String Schema +/// ```rust +/// let schema = json!({ "type": "string", "minLength": 1 }); +/// let parsed: Schema = serde_json::from_value(schema).unwrap(); +/// ``` +/// +/// ## Complex Object Schema +/// ```rust +/// let schema = json!({ +/// "type": "object", +/// "properties": { +/// "users": { +/// "type": "array", +/// "items": { +/// "type": "object", +/// "properties": { +/// "id": { "type": "integer" }, +/// "email": { "type": "string", "pattern": "^[^@]+@[^@]+$" } +/// }, +/// "required": ["id", "email"] +/// } +/// } +/// } +/// }); +/// let parsed: Schema = serde_json::from_value(schema).unwrap(); +/// ``` +/// +/// ## Union Types with anyOf +/// ```rust +/// let schema = json!({ +/// "anyOf": [ +/// { "type": "string" }, +/// { "type": "integer", "minimum": 0 } +/// ] +/// }); +/// let parsed: Schema = serde_json::from_value(schema).unwrap(); +/// ``` +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Schema { + t: Rc, +} + +#[allow(dead_code)] +impl Schema { + fn new(t: Type) -> Self { + Schema { t: Rc::new(t) } + } + + /// Returns a reference to the underlying type definition. + fn as_type(&self) -> &Type { + &self.t + } + + /// Parse a JSON Schema document into a `Schema` instance. + /// Provides better error messages than `serde_json::from_value`. + fn from_serde_json_value( + schema: serde_json::Value, + ) -> Result> { + let meta_schema_validation_result = meta::validate_schema_detailed(&schema); + let result = serde_json::from_value::(schema) + .map_err(|e| format!("Failed to parse schema: {e}"))?; + if let Err(errors) = meta_schema_validation_result { + return Err(format!("Schema validation failed: {}", errors.join("\n")).into()); + } + Ok(result) + } + + /// Parse a JSON Schema document from a string into a `Schema` instance. + /// Provides better error messages than `serde_json::from_str`. + fn from_json_str(s: &str) -> Result> { + let value: serde_json::Value = + serde_json::from_str(s).map_err(|e| format!("Failed to parse schema: {e}"))?; + Self::from_serde_json_value(value) + } +} + +impl<'de> Deserialize<'de> for Schema { + /// Deserializes a JSON Schema into a `Schema` instance. + /// + /// This method handles the deserialization of JSON Schema documents into Regorus' + /// internal type system. It supports two main formats: + /// + /// 1. **Regular typed schemas** - Standard JSON Schema with a `type` field + /// 2. **Union schemas** - Schemas using `anyOf` to represent union types + /// + /// # Supported JSON Schema Formats + /// + /// ## Regular Type Schemas + /// + /// Standard JSON Schema documents with a `type` field are deserialized directly: + /// + /// ```json + /// { + /// "type": "string", + /// "minLength": 1, + /// "maxLength": 100 + /// } + /// ``` + /// + /// ```json + /// { + /// "type": "object", + /// "properties": { + /// "name": { "type": "string" }, + /// "age": { "type": "integer", "minimum": 0 } + /// }, + /// "required": ["name"] + /// } + /// ``` + /// + /// ## Union Type Schemas with anyOf + /// + /// Schemas using `anyOf` are converted to `Type::AnyOf` variants: + /// + /// ```json + /// { + /// "anyOf": [ + /// { "type": "string" }, + /// { "type": "integer", "minimum": 0 }, + /// { "type": "null" } + /// ] + /// } + /// ``` + /// + /// # Error Handling + /// + /// This method will return a deserialization error if: + /// + /// - The JSON contains unknown/unsupported fields (due to `deny_unknown_fields`) + /// - The JSON structure doesn't match any supported schema format + /// - Individual type definitions within the schema are invalid + /// - Required fields are missing (e.g., `type` field for regular schemas) + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let v: serde_json::Value = Deserialize::deserialize(deserializer)?; + if v.get("anyOf").is_some() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[serde(rename_all = "camelCase")] + struct AnyOf { + #[serde(rename = "anyOf")] + variants: Rc>, + } + let any_of: AnyOf = Deserialize::deserialize(v) + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + return Ok(Schema::new(Type::AnyOf(any_of.variants))); + } + + if v.get("const").is_some() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[serde(rename_all = "camelCase")] + struct Const { + #[serde(rename = "const")] + value: Value, + description: Option, + } + let const_schema: Const = Deserialize::deserialize(v) + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + return Ok(Schema::new(Type::Const { + description: const_schema.description, + value: const_schema.value, + })); + } + if v.get("enum").is_some() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + #[serde(rename_all = "camelCase")] + struct Enum { + #[serde(rename = "enum")] + values: Rc>, + description: Option, + } + let enum_schema: Enum = Deserialize::deserialize(v) + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + return Ok(Schema::new(Type::Enum { + description: enum_schema.description, + values: enum_schema.values, + })); + } + + let t: Type = + Deserialize::deserialize(v).map_err(|e| serde::de::Error::custom(format!("{e}")))?; + Ok(Schema::new(t)) + } +} + +#[derive(Debug, Clone, Deserialize)] +// Use `type` when deserializing to discriminate between different types. +#[serde(tag = "type")] +// match JSON Schema casing. +#[serde(rename_all = "camelCase")] +// Raise error if unsupported fields are encountered. +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +pub enum Type { + /// Represents a type that can accept any JSON value. + /// + /// # Example + /// ```json + /// { + /// "type": "any", + /// "description": "Accepts any JSON value", + /// "default": "fallback_value" + /// } + /// ``` + Any { + description: Option, + default: Option, + }, + + /// Represents a 64-bit signed integer type with optional range constraints. + /// + /// # Example + /// ```json + /// { + /// "type": "integer", + /// "description": "A whole number", + /// "minimum": 0, + /// "maximum": 100, + /// "default": 50 + /// } + /// ``` + Integer { + description: Option, + // In Bicep's type system, this is called minValue. + minimum: Option, + // In Bicep's type system, this is called maxValue. + maximum: Option, + default: Option, + }, + + /// Represents a 64-bit floating-point number type with optional range constraints. + /// + /// # Example + /// ```json + /// { + /// "type": "number", + /// "description": "A numeric value", + /// "minimum": 0.0, + /// "maximum": 1.0, + /// "default": 0.5 + /// } + /// ``` + Number { + description: Option, + minimum: Option, + maximum: Option, + default: Option, + }, + + /// Represents a boolean type that accepts `true` or `false` values. + /// + /// # Example + /// ```json + /// { + /// "type": "boolean", + /// "description": "A true/false value", + /// "default": false + /// } + /// ``` + Boolean { + description: Option, + default: Option, + }, + + /// Represents the null type that only accepts JSON `null` values. + /// + /// # Example + /// ```json + /// { + /// "type": "null", + /// "description": "A null value" + /// } + /// ``` + Null { description: Option }, + + /// Represents a string type with optional length and pattern constraints. + /// + /// # Example + /// ```json + /// { + /// "type": "string", + /// "description": "Email address", + /// "minLength": 1, + /// "maxLength": 100, + /// "pattern": "^[^@]+@[^@]+\\.[^@]+$", + /// } + /// ``` + #[serde(rename_all = "camelCase")] + String { + description: Option, + min_length: Option, + max_length: Option, + pattern: Option, + default: Option, + }, + + /// Represents an array type with a specified item type and optional size constraints. + /// + /// # Example + /// ```json + /// { + /// "type": "array", + /// "description": "A list of items", + /// "items": { "type": "string" }, + /// "minItems": 1, + /// "maxItems": 10, + /// "default": ["item1", "item2"] + /// } + /// ``` + #[serde(rename_all = "camelCase")] + Array { + description: Option, + items: Schema, + // In Bicep's type system, this is called minLength. + min_items: Option, + // In Bicep's type system, this is called maxLength. + max_items: Option, + default: Option, + }, + + /// Represents an object type with defined properties and optional constraints. + /// + /// The `Object` type accepts JSON objects with specified properties, required fields, + /// and optional additional properties schema. It can also support discriminated + /// unions through the `allOf` mechanism. + /// + /// # Examples + /// + /// ## Basic Object + /// ```json + /// { + /// "type": "object", + /// "properties": { + /// "name": { "type": "string" }, + /// "age": { "type": "integer" } + /// }, + /// "required": ["name"] + /// } + /// ``` + /// + /// ## Object with Additional Properties + /// ```json + /// { + /// "type": "object", + /// "properties": { + /// "core_field": { "type": "string" } + /// }, + /// "additionalProperties": { "type": "any" }, + /// "description": "Extensible configuration object" + /// } + /// ``` + /// + /// ## Discriminated Subobjects (Polymorphic Types) + /// + /// Discriminated subobjects allow modeling polymorphic types where the structure + /// depends on a discriminator field value. This is useful for representing different + /// types of resources, messages, or configurations that share common base properties + /// but have type-specific additional properties. + /// + /// ```json + /// { + /// "type": "object", + /// "description": "Azure resource definition", + /// "properties": { + /// "name": { + /// "type": "string", + /// "description": "Resource name" + /// }, + /// "location": { + /// "type": "string", + /// "description": "Azure region" + /// }, + /// "type": { + /// "type": "string", + /// "description": "Resource type discriminator" + /// } + /// }, + /// "required": ["name", "location", "type"], + /// "allOf": [ + /// { + /// "if": { + /// "properties": { + /// "type": { "const": "Microsoft.Compute/virtualMachines" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "vmSize": { + /// "type": "string", + /// "description": "Virtual machine size" + /// }, + /// "osType": { + /// "type": "enum", + /// "values": ["Windows", "Linux"] + /// }, + /// "imageReference": { + /// "type": "object", + /// "properties": { + /// "publisher": { "type": "string" }, + /// "offer": { "type": "string" }, + /// "sku": { "type": "string" } + /// }, + /// "required": ["publisher", "offer", "sku"] + /// } + /// }, + /// "required": ["vmSize", "osType", "imageReference"] + /// } + /// }, + /// { + /// "if": { + /// "properties": { + /// "type": { "const": "Microsoft.Storage/storageAccounts" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "accountType": { + /// "type": "enum", + /// "values": ["Standard_LRS", "Standard_GRS", "Premium_LRS"] + /// }, + /// "encryption": { + /// "type": "object", + /// "properties": { + /// "services": { + /// "type": "object", + /// "properties": { + /// "blob": { "type": "boolean" }, + /// "file": { "type": "boolean" } + /// } + /// } + /// } + /// } + /// }, + /// "required": ["accountType"] + /// } + /// }, + /// { + /// "if": { + /// "properties": { + /// "type": { "const": "Microsoft.Network/virtualNetworks" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "addressSpace": { + /// "type": "object", + /// "properties": { + /// "addressPrefixes": { + /// "type": "array", + /// "items": { "type": "string" }, + /// "minItems": 1 + /// } + /// }, + /// "required": ["addressPrefixes"] + /// }, + /// "subnets": { + /// "type": "array", + /// "items": { + /// "type": "object", + /// "properties": { + /// "name": { "type": "string" }, + /// "addressPrefix": { "type": "string" } + /// }, + /// "required": ["name", "addressPrefix"] + /// } + /// } + /// }, + /// "required": ["addressSpace"] + /// } + /// } + /// ] + /// } + /// ``` + /// + /// ## Discriminated Subobject Structure + /// + /// When using discriminated subobjects: + /// + /// 1. **Base Properties**: Common properties shared by all variants (defined in the main `properties`) + /// 2. **Discriminator Field**: A property that determines which variant applies (e.g., `"kind"` field) + /// 3. **Variant-Specific Properties**: Additional properties that only apply to specific discriminator values + /// 4. **Conditional Schema**: Each `allOf` entry uses `if`/`then` to conditionally apply variant-specific schemas + /// + /// ## Message Type Example + /// + /// ```json + /// { + /// "type": "object", + /// "description": "Polymorphic message types", + /// "properties": { + /// "id": { "type": "string" }, + /// "timestamp": { "type": "integer" }, + /// "messageType": { "type": "string" } + /// }, + /// "required": ["id", "timestamp", "messageType"], + /// "allOf": [ + /// { + /// "if": { + /// "properties": { + /// "messageType": { "const": "text" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "content": { "type": "string", "minLength": 1 }, + /// "formatting": { + /// "type": "enum", + /// "values": ["plain", "markdown", "html"] + /// } + /// }, + /// "required": ["content"] + /// } + /// }, + /// { + /// "if": { + /// "properties": { + /// "messageType": { "const": "image" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "imageUrl": { "type": "string" }, + /// "altText": { "type": "string" }, + /// "width": { "type": "integer", "minimum": 1 }, + /// "height": { "type": "integer", "minimum": 1 } + /// }, + /// "required": ["imageUrl"] + /// } + /// }, + /// { + /// "if": { + /// "properties": { + /// "messageType": { "const": "file" } + /// } + /// }, + /// "then": { + /// "properties": { + /// "filename": { "type": "string" }, + /// "fileSize": { "type": "integer", "minimum": 0 }, + /// "mimeType": { "type": "string" }, + /// "downloadUrl": { "type": "string" } + /// }, + /// "required": ["filename", "fileSize", "downloadUrl"] + /// } + /// } + /// ] + /// } + /// ``` + /// + /// This structure ensures that: + /// - All messages have `id`, `timestamp`, and `messageType` fields + /// - Text messages additionally require `content` and may have `formatting` + /// - Image messages require `imageUrl` and may specify dimensions + #[serde(rename_all = "camelCase")] + Object { + description: Option, + #[serde(default)] + properties: Rc>, + #[serde(default)] + required: Option>>, + #[serde(default = "additional_properties_default")] + #[serde(deserialize_with = "additional_properties_deserialize")] + additional_properties: Option, + + // This is a required property in Bicep's type system. There is not direct equivalent in JSON Schema. + // However, JSON Schema simply allows any schema to have a `name` property. + name: Option, + + default: Option, + + #[serde(rename = "allOf")] + discriminated_subobject: Option>, + // Bicep property attributes like `readOnly`, `writeOnly` are not needed in Regorus' type system. + }, + + /// Represents a union type that accepts values matching any of the specified schemas. + /// + /// The `AnyOf` type creates a union where a value is valid if it matches at least + /// one of the provided schemas. This is useful for optional types, alternative + /// formats, or polymorphic data structures. + /// + /// # JSON Schema Format + /// + /// ```json + /// { + /// "anyOf": [ + /// { "type": "string" }, + /// { "type": "integer", "minimum": 0 }, + /// { "type": "null" } + /// ] + /// } + /// ``` + /// + /// AnyOf deserialization is handled by the `Schema` deserializer. + /// This is because, unlike other variant which can be distingished by the `type` field, + /// `anyOf` does not have a `type` field. Instead, it is a top-level field that contains an array of schemas. + #[serde(skip)] + AnyOf(Rc>), + + /// Represents a constant type that accepts only a single specific value. + /// + /// The `Const` type accepts only the exact value specified. This is useful for + /// literal values, discriminator fields, or when only one specific value is valid. + /// + /// # Example + /// + /// ```json + /// { + /// "type": "const", + /// "description": "A single constant value", + /// "value": "specific_value" + /// } + /// ``` + #[serde(skip)] + Const { + description: Option, + value: Value, + }, + + /// Represents an enumeration type with a fixed set of allowed values. + /// + /// The `Enum` type accepts only values that are explicitly listed in the values array. + /// Values can be of any JSON type (strings, numbers, booleans, objects, arrays, null). + /// + /// # Example + /// ```json + /// { + /// "type": "enum", + /// "description": "A predefined set of values", + /// "values": ["value1", "value2", 42, true, null] + /// } + /// ``` + #[serde(skip)] + Enum { + description: Option, + values: Rc>, + }, + + /// Specific to Rego. Needed for representing type of expressions involving sets. + #[serde(skip)] + Set { + description: Option, + items: Schema, + default: Option, + }, +} + +// By default any additional properties are allowed. +fn additional_properties_default() -> Option { + // Default is to allow additional properties of any type. + Some(Schema::new(Type::Any { + description: None, + default: None, + })) +} + +fn additional_properties_deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if let Some(b) = value.as_bool() { + if b { + // If additionalProperties is true, it means any type is allowed. + return Ok(Some(Schema::new(Type::Any { + description: None, + default: None, + }))); + } else { + // If additionalProperties is false, no additional properties are allowed. + return Ok(None); + } + } + + let schema: Schema = Deserialize::deserialize(value.clone()) + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + Ok(Some(schema)) +} + +// A subobject is just like an object, but it cannot have a `discriminated_subobject` property. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct Subobject { + pub description: Option, + pub properties: Rc>, + pub required: Option>>, + #[serde(default = "additional_properties_default")] + #[serde(deserialize_with = "additional_properties_deserialize")] + pub additional_properties: Option, + // This is a required property in Bicep's type system. There is not direct equivalent in JSON Schema. + // However, JSON Schema simply allows any schema to have a `name` property. + pub name: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DiscriminatedSubobject { + pub discriminator: String, + pub variants: Rc>, +} + +mod discriminated_subobject { + use super::*; + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct DiscriminatorValue { + #[serde(rename = "const")] + pub value: String, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct DiscriminatorValueSpecification { + pub properties: BTreeMap, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct IfThen { + #[serde(rename = "if")] + pub discriminator_spec: DiscriminatorValueSpecification, + #[serde(rename = "then")] + pub subobject: Subobject, + } +} + +impl<'de> Deserialize<'de> for DiscriminatedSubobject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let ifthens: Vec = Deserialize::deserialize(deserializer)?; + if ifthens.is_empty() { + return Err(serde::de::Error::custom( + "DiscriminatedSubobject must have at least one variant", + )); + } + let mut discriminator = None; + let mut variants = BTreeMap::new(); + for variant in ifthens.into_iter() { + if variant.discriminator_spec.properties.len() != 1 { + return Err(serde::de::Error::custom( + "DiscriminatedSubobject discriminator must have exactly one property", + )); + } + if let Some((d, v)) = variant.discriminator_spec.properties.into_iter().next() { + if let Some(discriminator) = &discriminator { + if d != *discriminator { + return Err(serde::de::Error::custom( + "DiscriminatedSubobject must have a single discriminator property", + )); + } + } else { + discriminator = Some(d.clone()); + } + variants.insert(v.value, variant.subobject); + } else { + return Err(serde::de::Error::custom( + "DiscriminatedSubobject discriminator must have exactly one property", + )); + } + } + + Ok(DiscriminatedSubobject { + discriminator: discriminator.ok_or_else(|| { + serde::de::Error::custom( + "DiscriminatedSubobject must have a discriminator property", + ) + })?, + variants: Rc::new(variants), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/schema/meta.rs b/src/schema/meta.rs new file mode 100644 index 0000000..7443583 --- /dev/null +++ b/src/schema/meta.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![allow(dead_code)] +use crate::*; +use lazy_static::lazy_static; + +const META_SCHEMA: &str = include_str!("meta.schema.json"); + +lazy_static! { + /// Lazy static JSON Schema validator for the Regorus meta-schema. + /// This validator is initialized once and can be used to validate + /// any schema definition against the Regorus meta-schema format. + static ref META_SCHEMA_VALIDATOR: jsonschema::Validator = { + let meta_schema_json: serde_json::Value = serde_json::from_str(META_SCHEMA) + .expect("META_SCHEMA should be valid JSON"); + + jsonschema::validator_for(&meta_schema_json) + .expect("META_SCHEMA should be a valid JSON Schema") + }; +} + +pub fn get_meta_schema() -> &'static str { + META_SCHEMA +} + +/// Validates a schema definition against the Regorus meta-schema. +/// Returns true if the schema is valid, false otherwise. +pub fn validate_schema(schema: &serde_json::Value) -> bool { + META_SCHEMA_VALIDATOR.is_valid(schema) +} + +/// Validates a schema definition against the Regorus meta-schema. +/// Returns Ok(()) if valid, or Err with validation errors if invalid. +pub fn validate_schema_detailed(schema: &serde_json::Value) -> Result<(), Vec> { + if let jsonschema::BasicOutput::Invalid(errors) = META_SCHEMA_VALIDATOR.apply(schema).basic() { + let msgs: alloc::collections::BTreeSet = errors + .iter() + .map(|e| format!("{}: {}", e.instance_location(), e.error_description())) + .collect(); + let msgs: Vec = msgs.into_iter().collect(); + return Err(msgs); + } + + Ok(()) +} + +/// Validates a schema definition from a JSON string. +/// Returns true if the schema is valid, false otherwise. +pub fn validate_schema_str(schema_str: &str) -> bool { + match serde_json::from_str::(schema_str) { + Ok(schema) => validate_schema(&schema), + Err(_) => false, // Invalid JSON + } +} + +#[cfg(test)] +mod tests; diff --git a/src/schema/meta.schema.json b/src/schema/meta.schema.json new file mode 100644 index 0000000..4978346 --- /dev/null +++ b/src/schema/meta.schema.json @@ -0,0 +1,376 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/regorus/schema/meta-schema", + "title": "Regorus Schema Meta-Schema", + "description": "JSON Schema describing the structure of Regorus schema definitions", + "type": "object", + "$defs": { + "schemaCore": { + "oneOf": [ + { + "description": "Union type schema using anyOf", + "type": "object", + "properties": { + "anyOf": { + "type": "array", + "description": "Array of schemas where at least one must match", + "items": { + "$ref": "#/$defs/schemaCore" + }, + "minItems": 0 + } + }, + "required": [ + "anyOf" + ], + "additionalProperties": false + }, + { + "description": "Constant value schema", + "type": "object", + "properties": { + "const": { + "description": "The exact value that must match" + }, + "description": { + "type": "string", + "description": "Human-readable description of the schema" + } + }, + "required": [ + "const" + ], + "additionalProperties": false + }, + { + "description": "Enumeration schema", + "type": "object", + "properties": { + "enum": { + "type": "array", + "description": "Array of allowed values", + "minItems": 0 + }, + "description": { + "type": "string", + "description": "Human-readable description of the schema" + } + }, + "required": [ + "enum" + ], + "additionalProperties": false + }, + { + "description": "Any type schema", + "type": "object", + "properties": { + "type": { + "const": "any" + }, + "description": { + "type": "string" + }, + "default": true + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Integer type schema", + "type": "object", + "properties": { + "type": { + "const": "integer" + }, + "description": { + "type": "string" + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + }, + "default": true + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Number type schema", + "type": "object", + "properties": { + "type": { + "const": "number" + }, + "description": { + "type": "string" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "default": true + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Boolean type schema", + "type": "object", + "properties": { + "type": { + "const": "boolean" + }, + "description": { + "type": "string" + }, + "default": true + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Null type schema", + "type": "object", + "properties": { + "type": { + "const": "null" + }, + "description": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "String type schema", + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "description": { + "type": "string" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "pattern": { + "type": "string" + }, + "default": true + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Array type schema", + "type": "object", + "properties": { + "type": { + "const": "array" + }, + "description": { + "type": "string" + }, + "items": { + "$ref": "#/$defs/schemaCore", + "description": "Schema for array items" + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "default": true + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "description": "Set type schema", + "type": "object", + "properties": { + "type": { + "const": "set" + }, + "description": { + "type": "string" + }, + "items": { + "$ref": "#/$defs/schemaCore", + "description": "Schema for set items" + }, + "default": true + }, + "required": [ + "type", + "items" + ], + "additionalProperties": false + }, + { + "description": "Object type schema", + "type": "object", + "properties": { + "type": { + "const": "object" + }, + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "description": "Object property definitions", + "additionalProperties": { + "$ref": "#/$defs/schemaCore" + }, + "default": {} + }, + "required": { + "type": "array", + "description": "Required property names", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "additionalProperties": { + "oneOf": [ + { + "type": "boolean", + "description": "Whether additional properties are allowed (true) or not (false)" + }, + { + "$ref": "#/$defs/schemaCore", + "description": "Schema for additional properties" + } + ] + }, + "name": { + "type": "string", + "description": "Optional name for the object type" + }, + "default": true, + "allOf": { + "type": "array", + "description": "Discriminated subobject definitions", + "items": { + "$ref": "#/$defs/discriminatedSubobject" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "discriminatedSubobject": { + "type": "object", + "properties": { + "if": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "const": { + "type": "string" + } + }, + "required": [ + "const" + ], + "additionalProperties": false + } + }, + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "properties" + ], + "additionalProperties": false + }, + "then": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schemaCore" + }, + "default": {} + }, + "required": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "additionalProperties": { + "oneOf": [ + { + "type": "boolean", + "description": "Whether additional properties are allowed (true) or not (false)" + }, + { + "$ref": "#/$defs/schemaCore", + "description": "Schema for additional properties" + } + ] + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "if", + "then" + ], + "additionalProperties": false + } + }, + "$ref": "#/$defs/schemaCore" +} \ No newline at end of file diff --git a/src/schema/meta/tests.rs b/src/schema/meta/tests.rs new file mode 100644 index 0000000..8ac23d8 --- /dev/null +++ b/src/schema/meta/tests.rs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use serde_json::json; + +#[test] +fn test_get_meta_schema() { + let meta_schema = get_meta_schema(); + assert!(!meta_schema.is_empty()); + assert!(meta_schema.contains("$schema")); +} + +#[test] +fn test_validate_valid_schema() { + let valid_schema = json!({ + "type": "string", + "description": "A simple string" + }); + + assert!(validate_schema(&valid_schema)); + assert!(validate_schema_detailed(&valid_schema).is_ok()); +} + +#[test] +fn test_validate_valid_anyof_schema() { + let valid_anyof_schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }); + + assert!(validate_schema(&valid_anyof_schema)); + assert!(validate_schema_detailed(&valid_anyof_schema).is_ok()); +} + +#[test] +fn test_validate_valid_const_schema() { + let valid_const_schema = json!({ + "const": "hello", + "description": "A constant value" + }); + + assert!(validate_schema(&valid_const_schema)); + assert!(validate_schema_detailed(&valid_const_schema).is_ok()); +} + +#[test] +fn test_validate_valid_enum_schema() { + let valid_enum_schema = json!({ + "enum": ["red", "green", "blue"], + "description": "Color enumeration" + }); + + assert!(validate_schema(&valid_enum_schema)); + assert!(validate_schema_detailed(&valid_enum_schema).is_ok()); +} + +#[test] +fn test_validate_invalid_schema() { + let invalid_schema = json!({ + "invalidField": "this should not be allowed" + }); + + assert!(!validate_schema(&invalid_schema)); + assert!(validate_schema_detailed(&invalid_schema).is_err()); +} + +#[test] +fn test_validate_schema_str() { + let valid_schema_str = r#"{"type": "string"}"#; + let invalid_schema_str = r#"{"invalidField": true}"#; + let malformed_json = r#"{"invalid": json"#; + + assert!(validate_schema_str(valid_schema_str)); + assert!(!validate_schema_str(invalid_schema_str)); + assert!(!validate_schema_str(malformed_json)); +} + +#[test] +fn test_validate_complex_object_schema() { + let complex_schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer", "minimum": 0 } + }, + "required": ["name"], + }); + + assert!(validate_schema(&complex_schema)); + assert!(validate_schema_detailed(&complex_schema).is_ok()); +} + +#[test] +fn test_validate_detailed_error_messages() { + let invalid_schema = json!({ + "type": "object", + "unknownProperty": true + }); + + let result = validate_schema_detailed(&invalid_schema); + assert!(result.is_err()); + + let errors = result.unwrap_err(); + assert!(!errors.is_empty()); + // The error should mention the unknown property + assert!(errors.iter().any(|err| err.contains("unknownProperty"))); +} + +#[test] +fn test_validator_is_lazy_initialized() { + // This test ensures the lazy static validator is working + // Multiple calls should use the same validator instance + let schema1 = json!({"type": "string"}); + let schema2 = json!({"type": "integer"}); + + assert!(validate_schema(&schema1)); + assert!(validate_schema(&schema2)); +} + +#[test] +fn test_validate_array_schema() { + let array_schema = json!({ + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 10 + }); + + assert!(validate_schema(&array_schema)); + assert!(validate_schema_detailed(&array_schema).is_ok()); +} + +#[test] +fn test_validate_discriminated_subobject_schema() { + let discriminated_schema = json!({ + "type": "object", + "properties": { + "kind": { "type": "string" } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "user" } } + }, + "then": { + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + } + ] + }); + + assert!(validate_schema(&discriminated_schema)); + assert!(validate_schema_detailed(&discriminated_schema).is_ok()); +} diff --git a/src/schema/tests.rs b/src/schema/tests.rs new file mode 100644 index 0000000..3f4085c --- /dev/null +++ b/src/schema/tests.rs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod azure; +mod suite; diff --git a/src/schema/tests/azure.rs b/src/schema/tests/azure.rs new file mode 100644 index 0000000..023f57a --- /dev/null +++ b/src/schema/tests/azure.rs @@ -0,0 +1,1266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::*; + +#[test] +fn test_azure_storage_account_schema() { + let schema = r#"{ + "type": "object", + "description": "Azure Storage Account resource schema", + "properties": { + "apiVersion": { + "type": "string", + "description": "The API version for the resource" + }, + "name": { + "type": "string", + "description": "The name of the storage account", + "minLength": 3, + "maxLength": 24, + "pattern": "^[a-z0-9]+$" + }, + "location": { + "type": "string", + "description": "The Azure region where the resource is deployed" + }, + "type": { + "const": "Microsoft.Storage/storageAccounts" + }, + "kind": { + "enum": ["Storage", "StorageV2", "BlobStorage", "FileStorage", "BlockBlobStorage"], + "description": "The kind of storage account" + }, + "sku": { + "type": "object", + "description": "The SKU of the storage account", + "properties": { + "name": { + "enum": ["Standard_LRS", "Standard_GRS", "Standard_RAGRS", "Standard_ZRS", "Premium_LRS", "Premium_ZRS", "Standard_GZRS", "Standard_RAGZRS"] + }, + "tier": { + "enum": ["Standard", "Premium"] + } + }, + "required": ["name"] + }, + "properties": { + "type": "object", + "description": "Storage account properties", + "properties": { + "accessTier": { + "enum": ["Hot", "Cool", "Archive"], + "description": "Access tier for blob storage" + }, + "allowBlobPublicAccess": { + "type": "boolean", + "description": "Allow or disallow public access to all blobs or containers", + "default": false + }, + "minimumTlsVersion": { + "enum": ["TLS1_0", "TLS1_1", "TLS1_2"], + "description": "Set the minimum TLS version to be permitted on requests" + }, + "networkAcls": { + "type": "object", + "description": "Network access control rules", + "properties": { + "defaultAction": { + "enum": ["Allow", "Deny"] + }, + "ipRules": { + "type": "array", + "description": "IP address rules", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "IP address or CIDR block" + }, + "action": { + "const": "Allow" + } + }, + "required": ["value"] + } + }, + "virtualNetworkRules": { + "type": "array", + "description": "Virtual network rules", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Resource ID of a subnet" + }, + "action": { + "const": "Allow" + }, + "state": { + "enum": ["provisioning", "deprovisioning", "succeeded", "failed", "networkSourceDeleted"] + } + }, + "required": ["id"] + } + } + }, + "required": ["defaultAction"] + } + } + }, + "tags": { + "type": "object", + "description": "Resource tags", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["apiVersion", "name", "location", "type", "kind", "sku"] + }"#; + + let schema = Schema::from_json_str(schema).unwrap(); + + match schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + // Verify core properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("type")); + assert!(properties.contains_key("kind")); + assert!(properties.contains_key("sku")); + + // Verify name constraints + match properties.get("name").unwrap().as_type() { + Type::String { + min_length, + max_length, + pattern, + .. + } => { + assert_eq!(min_length, &Some(3)); + assert_eq!(max_length, &Some(24)); + assert!(pattern.is_some()); + } + _ => panic!("Expected name to be Type::String"), + } + + // Verify type is constant + match properties.get("type").unwrap().as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from("Microsoft.Storage/storageAccounts")); + } + _ => panic!("Expected type to be Type::Const"), + } + + // Verify kind enum + match properties.get("kind").unwrap().as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 5); + assert!(values.contains(&Value::from("StorageV2"))); + } + _ => panic!("Expected kind to be Type::Enum"), + } + + // Verify required fields + let req = required.as_ref().expect("required should be present"); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"type".into())); + assert!(req.contains(&"kind".into())); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_azure_virtual_machine_schema() { + let schema = r#"{ + "type": "object", + "description": "Azure Virtual Machine resource schema", + "properties": { + "apiVersion": { + "type": "string", + "description": "The API version for the resource" + }, + "name": { + "type": "string", + "description": "The name of the virtual machine", + "minLength": 1, + "maxLength": 64 + }, + "location": { + "type": "string", + "description": "The Azure region where the resource is deployed" + }, + "type": { + "const": "Microsoft.Compute/virtualMachines" + }, + "properties": { + "type": "object", + "description": "Virtual machine properties", + "properties": { + "hardwareProfile": { + "type": "object", + "description": "Hardware settings for the virtual machine", + "properties": { + "vmSize": { + "enum": [ + "Standard_B1s", "Standard_B1ms", "Standard_B2s", "Standard_B2ms", + "Standard_D2s_v3", "Standard_D4s_v3", "Standard_D8s_v3", + "Standard_F2s_v2", "Standard_F4s_v2", "Standard_F8s_v2" + ], + "description": "The virtual machine size" + } + }, + "required": ["vmSize"] + }, + "storageProfile": { + "type": "object", + "description": "Storage settings for the virtual machine", + "properties": { + "imageReference": { + "type": "object", + "description": "The image reference", + "properties": { + "publisher": { + "type": "string", + "description": "The image publisher" + }, + "offer": { + "type": "string", + "description": "The image offer" + }, + "sku": { + "type": "string", + "description": "The image SKU" + }, + "version": { + "type": "string", + "description": "The image version", + "default": "latest" + } + }, + "required": ["publisher", "offer", "sku"] + }, + "osDisk": { + "type": "object", + "description": "Operating system disk settings", + "properties": { + "name": { + "type": "string", + "description": "The disk name" + }, + "caching": { + "enum": ["None", "ReadOnly", "ReadWrite"], + "description": "Disk caching type" + }, + "createOption": { + "enum": ["FromImage", "Empty", "Attach"], + "description": "How the disk should be created" + }, + "managedDisk": { + "type": "object", + "description": "Managed disk settings", + "properties": { + "storageAccountType": { + "enum": ["Standard_LRS", "Premium_LRS", "StandardSSD_LRS", "UltraSSD_LRS"], + "description": "Storage account type for managed disk" + } + }, + "required": ["storageAccountType"] + } + }, + "required": ["createOption"] + }, + "dataDisks": { + "type": "array", + "description": "Data disk settings", + "items": { + "type": "object", + "properties": { + "lun": { + "type": "integer", + "description": "Logical unit number", + "minimum": 0, + "maximum": 63 + }, + "name": { + "type": "string", + "description": "The disk name" + }, + "caching": { + "enum": ["None", "ReadOnly", "ReadWrite"], + "description": "Disk caching type" + }, + "createOption": { + "enum": ["FromImage", "Empty", "Attach"], + "description": "How the disk should be created" + }, + "diskSizeGB": { + "type": "integer", + "description": "Disk size in GB", + "minimum": 1, + "maximum": 32767 + } + }, + "required": ["lun", "createOption"] + } + } + }, + "required": ["imageReference", "osDisk"] + }, + "osProfile": { + "type": "object", + "description": "Operating system settings", + "properties": { + "computerName": { + "type": "string", + "description": "Computer name for the VM", + "maxLength": 15 + }, + "adminUsername": { + "type": "string", + "description": "Administrator username" + }, + "adminPassword": { + "type": "string", + "description": "Administrator password" + }, + "customData": { + "type": "string", + "description": "Base64 encoded custom data" + }, + "windowsConfiguration": { + "type": "object", + "description": "Windows-specific configuration", + "properties": { + "provisionVMAgent": { + "type": "boolean", + "description": "Provision VM agent", + "default": true + }, + "enableAutomaticUpdates": { + "type": "boolean", + "description": "Enable automatic updates", + "default": true + } + } + }, + "linuxConfiguration": { + "type": "object", + "description": "Linux-specific configuration", + "properties": { + "disablePasswordAuthentication": { + "type": "boolean", + "description": "Disable password authentication", + "default": false + } + } + } + }, + "required": ["computerName", "adminUsername"] + }, + "networkProfile": { + "type": "object", + "description": "Network settings for the virtual machine", + "properties": { + "networkInterfaces": { + "type": "array", + "description": "Network interfaces for the VM", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Resource ID of the network interface" + }, + "primary": { + "type": "boolean", + "description": "Whether this is the primary network interface", + "default": false + } + }, + "required": ["id"] + }, + "minItems": 1 + } + }, + "required": ["networkInterfaces"] + } + }, + "required": ["hardwareProfile", "storageProfile", "osProfile", "networkProfile"] + }, + "tags": { + "type": "object", + "description": "Resource tags", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["apiVersion", "name", "location", "type", "properties"] + }"#; + + let s = Schema::from_json_str(schema).unwrap(); + match s.as_type() { + Type::Object { properties, .. } => { + // Verify core VM properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("type")); + assert!(properties.contains_key("properties")); + + // Verify VM type constant + match properties.get("type").unwrap().as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from("Microsoft.Compute/virtualMachines")); + } + _ => panic!("Expected type to be Type::Const"), + } + + // Verify nested properties structure + match properties.get("properties").unwrap().as_type() { + Type::Object { + properties: vm_props, + required: vm_req, + .. + } => { + assert!(vm_props.contains_key("hardwareProfile")); + assert!(vm_props.contains_key("storageProfile")); + assert!(vm_props.contains_key("osProfile")); + assert!(vm_props.contains_key("networkProfile")); + + let vm_required = vm_req + .as_ref() + .expect("VM properties required should be present"); + assert!(vm_required.contains(&"hardwareProfile".into())); + assert!(vm_required.contains(&"storageProfile".into())); + assert!(vm_required.contains(&"osProfile".into())); + assert!(vm_required.contains(&"networkProfile".into())); + } + _ => panic!("Expected properties to be Type::Object"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_azure_resource_polymorphic_schema() { + // This demonstrates a polymorphic Azure resource schema using discriminated subobjects + let schema = r#"{ + "type": "object", + "description": "Polymorphic Azure resource schema", + "properties": { + "apiVersion": { + "type": "string", + "description": "The API version for the resource" + }, + "name": { + "type": "string", + "description": "The resource name", + "minLength": 1, + "maxLength": 260 + }, + "location": { + "type": "string", + "description": "The Azure region where the resource is deployed" + }, + "type": { + "type": "string", + "description": "The resource type" + }, + "tags": { + "type": "object", + "description": "Resource tags", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["apiVersion", "name", "location", "type"], + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "Microsoft.Storage/storageAccounts" } + } + }, + "then": { + "properties": { + "kind": { + "enum": ["Storage", "StorageV2", "BlobStorage"], + "description": "Storage account kind" + }, + "sku": { + "type": "object", + "properties": { + "name": { + "enum": ["Standard_LRS", "Standard_GRS", "Premium_LRS"] + } + }, + "required": ["name"] + }, + "properties": { + "type": "object", + "properties": { + "accessTier": { + "enum": ["Hot", "Cool", "Archive"] + }, + "supportsHttpsTrafficOnly": { + "type": "boolean", + "default": true + } + } + } + }, + "required": ["kind", "sku"] + } + }, + { + "if": { + "properties": { + "type": { "const": "Microsoft.Compute/virtualMachines" } + } + }, + "then": { + "properties": { + "properties": { + "type": "object", + "properties": { + "hardwareProfile": { + "type": "object", + "properties": { + "vmSize": { + "enum": ["Standard_B1s", "Standard_D2s_v3", "Standard_F2s_v2"] + } + }, + "required": ["vmSize"] + }, + "osProfile": { + "type": "object", + "properties": { + "computerName": { + "type": "string", + "maxLength": 15 + }, + "adminUsername": { + "type": "string" + } + }, + "required": ["computerName", "adminUsername"] + } + }, + "required": ["hardwareProfile", "osProfile"] + } + }, + "required": ["properties"] + } + }, + { + "if": { + "properties": { + "type": { "const": "Microsoft.Network/virtualNetworks" } + } + }, + "then": { + "properties": { + "properties": { + "type": "object", + "properties": { + "addressSpace": { + "type": "object", + "properties": { + "addressPrefixes": { + "type": "array", + "items": { + "type": "string", + "description": "CIDR block for the virtual network" + }, + "minItems": 1 + } + }, + "required": ["addressPrefixes"] + }, + "subnets": { + "type": "array", + "description": "Subnets in the virtual network", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Subnet name" + }, + "properties": { + "type": "object", + "properties": { + "addressPrefix": { + "type": "string", + "description": "CIDR block for the subnet" + }, + "networkSecurityGroup": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Resource ID of the NSG" + } + }, + "required": ["id"] + } + }, + "required": ["addressPrefix"] + } + }, + "required": ["name", "properties"] + } + } + }, + "required": ["addressSpace"] + } + }, + "required": ["properties"] + } + }, + { + "if": { + "properties": { + "type": { "const": "Microsoft.Web/sites" } + } + }, + "then": { + "properties": { + "kind": { + "enum": ["app", "functionapp", "api", "mobileapp"], + "description": "Web app kind" + }, + "properties": { + "type": "object", + "properties": { + "serverFarmId": { + "type": "string", + "description": "Resource ID of the App Service plan" + }, + "httpsOnly": { + "type": "boolean", + "description": "HTTPS only traffic", + "default": false + }, + "siteConfig": { + "type": "object", + "properties": { + "appSettings": { + "type": "array", + "description": "Application settings", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Setting name" + }, + "value": { + "type": "string", + "description": "Setting value" + } + }, + "required": ["name", "value"] + } + }, + "netFrameworkVersion": { + "enum": ["v4.0", "v6.0", "v7.0", "v8.0"], + "description": ".NET Framework version" + } + } + } + }, + "required": ["serverFarmId"] + } + }, + "required": ["kind", "properties"] + } + } + ] + }"#; + + let s = Schema::from_json_str(schema).unwrap(); + match s.as_type() { + Type::Object { + discriminated_subobject, + properties, + required, + .. + } => { + // Verify base properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("type")); + assert!(properties.contains_key("apiVersion")); + + let req = required.as_ref().expect("required should be present"); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"location".into())); + assert!(req.contains(&"type".into())); + assert!(req.contains(&"apiVersion".into())); + + // Verify discriminated subobject + let dso = discriminated_subobject + .as_ref() + .expect("should have discriminated_subobject"); + assert_eq!(dso.discriminator.as_ref(), "type"); + assert_eq!(dso.variants.len(), 4); + + // Verify storage account variant + let storage_variant = dso + .variants + .get("Microsoft.Storage/storageAccounts") + .expect("storage account variant"); + assert!(storage_variant.properties.contains_key("kind")); + assert!(storage_variant.properties.contains_key("sku")); + let storage_req = storage_variant.required.as_ref().expect("storage required"); + assert!(storage_req.contains(&"kind".into())); + assert!(storage_req.contains(&"sku".into())); + + // Verify VM variant + let vm_variant = dso + .variants + .get("Microsoft.Compute/virtualMachines") + .expect("VM variant"); + assert!(vm_variant.properties.contains_key("properties")); + let vm_req = vm_variant.required.as_ref().expect("VM required"); + assert!(vm_req.contains(&"properties".into())); + + // Verify VNet variant + let vnet_variant = dso + .variants + .get("Microsoft.Network/virtualNetworks") + .expect("VNet variant"); + assert!(vnet_variant.properties.contains_key("properties")); + + // Verify Web App variant + let webapp_variant = dso + .variants + .get("Microsoft.Web/sites") + .expect("Web App variant"); + assert!(webapp_variant.properties.contains_key("kind")); + assert!(webapp_variant.properties.contains_key("properties")); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_azure_key_vault_schema() { + let schema = r#"{ + "type": "object", + "description": "Azure Key Vault resource schema", + "properties": { + "apiVersion": { + "type": "string", + "description": "The API version for the resource" + }, + "name": { + "type": "string", + "description": "The name of the key vault", + "minLength": 3, + "maxLength": 24, + "pattern": "^[a-zA-Z0-9-]+$" + }, + "location": { + "type": "string", + "description": "The Azure region where the resource is deployed" + }, + "type": { + "const": "Microsoft.KeyVault/vaults" + }, + "properties": { + "type": "object", + "description": "Key vault properties", + "properties": { + "tenantId": { + "type": "string", + "description": "The Azure Active Directory tenant ID", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "sku": { + "type": "object", + "description": "SKU details for the key vault", + "properties": { + "family": { + "const": "A" + }, + "name": { + "enum": ["standard", "premium"], + "description": "SKU name" + } + }, + "required": ["family", "name"] + }, + "accessPolicies": { + "type": "array", + "description": "Access policies for the key vault", + "items": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "description": "The Azure Active Directory tenant ID" + }, + "objectId": { + "type": "string", + "description": "The object ID of a user, service principal or security group" + }, + "permissions": { + "type": "object", + "description": "Permissions the identity has for keys, secrets and certificates", + "properties": { + "keys": { + "type": "array", + "description": "Permissions to keys", + "items": { + "enum": [ + "encrypt", "decrypt", "wrapKey", "unwrapKey", "sign", "verify", + "get", "list", "create", "update", "import", "delete", "backup", + "restore", "recover", "purge", "release", "rotate", "getrotationpolicy", "setrotationpolicy" + ] + } + }, + "secrets": { + "type": "array", + "description": "Permissions to secrets", + "items": { + "enum": ["get", "list", "set", "delete", "backup", "restore", "recover", "purge"] + } + }, + "certificates": { + "type": "array", + "description": "Permissions to certificates", + "items": { + "enum": [ + "get", "list", "delete", "create", "import", "update", "managecontacts", + "getissuers", "listissuers", "setissuers", "deleteissuers", "manageissuers", + "recover", "purge", "backup", "restore" + ] + } + } + } + } + }, + "required": ["tenantId", "objectId", "permissions"] + } + }, + "enabledForDeployment": { + "type": "boolean", + "description": "Property to specify whether Azure Virtual Machines are permitted to retrieve certificates", + "default": false + }, + "enabledForDiskEncryption": { + "type": "boolean", + "description": "Property to specify whether Azure Disk Encryption is permitted to retrieve secrets", + "default": false + }, + "enabledForTemplateDeployment": { + "type": "boolean", + "description": "Property to specify whether Azure Resource Manager is permitted to retrieve secrets", + "default": false + }, + "enableSoftDelete": { + "type": "boolean", + "description": "Property to specify whether the soft delete functionality is enabled", + "default": true + }, + "softDeleteRetentionInDays": { + "type": "integer", + "description": "The number of days that items should be retained for once soft-deleted", + "minimum": 7, + "maximum": 90, + "default": 90 + }, + "enablePurgeProtection": { + "type": "boolean", + "description": "Property specifying whether protection against purge is enabled", + "default": false + }, + "networkAcls": { + "type": "object", + "description": "Rules governing the accessibility of the key vault from specific network locations", + "properties": { + "bypass": { + "enum": ["AzureServices", "None"], + "description": "Tells what traffic can bypass network rules" + }, + "defaultAction": { + "enum": ["Allow", "Deny"], + "description": "The default action when no rule from ipRules and from virtualNetworkRules match" + }, + "ipRules": { + "type": "array", + "description": "The list of IP address rules", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "An IPv4 address range in CIDR notation" + } + }, + "required": ["value"] + } + }, + "virtualNetworkRules": { + "type": "array", + "description": "The list of virtual network rules", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Full resource id of a vnet subnet" + }, + "ignoreMissingVnetServiceEndpoint": { + "type": "boolean", + "description": "Property to specify whether NRP will ignore the check if parent subnet has serviceEndpoints configured", + "default": false + } + }, + "required": ["id"] + } + } + }, + "required": ["defaultAction"] + } + }, + "required": ["tenantId", "sku", "accessPolicies"] + }, + "tags": { + "type": "object", + "description": "Resource tags", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["apiVersion", "name", "location", "type", "properties"] + }"#; + + let s = Schema::from_json_str(schema).unwrap(); + match s.as_type() { + Type::Object { + properties, + required, + .. + } => { + // Verify Key Vault specific properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("type")); + assert!(properties.contains_key("properties")); + + // Verify name constraints + match properties.get("name").unwrap().as_type() { + Type::String { + min_length, + max_length, + pattern, + .. + } => { + assert_eq!(min_length, &Some(3)); + assert_eq!(max_length, &Some(24)); + assert!(pattern.is_some()); + } + _ => panic!("Expected name to be Type::String"), + } + + // Verify type constant + match properties.get("type").unwrap().as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from("Microsoft.KeyVault/vaults")); + } + _ => panic!("Expected type to be Type::Const"), + } + + // Verify complex nested properties structure + match properties.get("properties").unwrap().as_type() { + Type::Object { + properties: kv_props, + required: kv_req, + .. + } => { + assert!(kv_props.contains_key("tenantId")); + assert!(kv_props.contains_key("sku")); + assert!(kv_props.contains_key("accessPolicies")); + + let kv_required = kv_req + .as_ref() + .expect("Key Vault properties required should be present"); + assert!(kv_required.contains(&"tenantId".into())); + assert!(kv_required.contains(&"sku".into())); + assert!(kv_required.contains(&"accessPolicies".into())); + + // Verify access policies array structure + match kv_props.get("accessPolicies").unwrap().as_type() { + Type::Array { items, .. } => match items.as_type() { + Type::Object { + properties: policy_props, + .. + } => { + assert!(policy_props.contains_key("tenantId")); + assert!(policy_props.contains_key("objectId")); + assert!(policy_props.contains_key("permissions")); + } + _ => panic!("Expected access policy items to be Type::Object"), + }, + _ => panic!("Expected accessPolicies to be Type::Array"), + } + } + _ => panic!("Expected properties to be Type::Object"), + } + + // Verify required fields + let req = required.as_ref().expect("required should be present"); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"type".into())); + assert!(req.contains(&"properties".into())); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_azure_app_service_plan_schema() { + let schema = r#"{ + "type": "object", + "description": "Azure App Service Plan resource schema", + "properties": { + "apiVersion": { + "type": "string", + "description": "The API version for the resource" + }, + "name": { + "type": "string", + "description": "The name of the App Service plan", + "minLength": 1, + "maxLength": 40 + }, + "location": { + "type": "string", + "description": "The Azure region where the resource is deployed" + }, + "type": { + "const": "Microsoft.Web/serverfarms" + }, + "sku": { + "type": "object", + "description": "SKU description of the App Service plan", + "properties": { + "name": { + "enum": ["F1", "D1", "B1", "B2", "B3", "S1", "S2", "S3", "P1", "P2", "P3", "P1V2", "P2V2", "P3V2", "P1V3", "P2V3", "P3V3"], + "description": "Name of the SKU" + }, + "tier": { + "enum": ["Free", "Shared", "Basic", "Standard", "Premium", "PremiumV2", "PremiumV3"], + "description": "Service tier of the SKU" + }, + "size": { + "enum": ["F1", "D1", "B1", "B2", "B3", "S1", "S2", "S3", "P1", "P2", "P3", "P1V2", "P2V2", "P3V2", "P1V3", "P2V3", "P3V3"], + "description": "Size specifier of the SKU" + }, + "family": { + "type": "string", + "description": "Family code of the SKU" + }, + "capacity": { + "type": "integer", + "description": "Current number of instances assigned to the resource", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["name"] + }, + "kind": { + "enum": ["app", "linux", "functionapp", "elastic"], + "description": "Kind of resource" + }, + "properties": { + "type": "object", + "description": "App Service plan properties", + "properties": { + "workerTierName": { + "type": "string", + "description": "Target worker tier assigned to the App Service plan" + }, + "hostingEnvironmentProfile": { + "type": "object", + "description": "Specification for the App Service Environment", + "properties": { + "id": { + "type": "string", + "description": "Resource ID of the App Service Environment" + } + }, + "required": ["id"] + }, + "perSiteScaling": { + "type": "boolean", + "description": "If true, apps assigned to this App Service plan can be scaled independently", + "default": false + }, + "elasticScaleEnabled": { + "type": "boolean", + "description": "ServerFarm supports ElasticScale. Apps in this plan will scale as if the ServerFarm was ElasticPremium sku", + "default": false + }, + "maximumElasticWorkerCount": { + "type": "integer", + "description": "Maximum number of total workers allowed for this ElasticScaleEnabled App Service Plan", + "minimum": 1, + "maximum": 100 + }, + "isSpot": { + "type": "boolean", + "description": "If true, this App Service Plan owns spot instances", + "default": false + }, + "spotExpirationTime": { + "type": "string", + "description": "The time when the server farm expires. Valid only if it is a spot server farm" + }, + "freeOfferExpirationTime": { + "type": "string", + "description": "The time when the server farm free offer expires" + }, + "reserved": { + "type": "boolean", + "description": "If Linux app service plan true, false otherwise", + "default": false + }, + "isXenon": { + "type": "boolean", + "description": "Obsolete: Hyper-V sandbox", + "default": false + }, + "hyperV": { + "type": "boolean", + "description": "If Hyper-V container app service plan true, false otherwise", + "default": false + }, + "targetWorkerCount": { + "type": "integer", + "description": "Scaling worker count", + "minimum": 0, + "maximum": 100 + }, + "targetWorkerSizeId": { + "type": "integer", + "description": "Scaling worker size ID", + "minimum": 0, + "maximum": 2 + }, + "kubeEnvironmentProfile": { + "type": "object", + "description": "Specification for the Kubernetes Environment", + "properties": { + "id": { + "type": "string", + "description": "Resource ID of the Kubernetes Environment" + } + }, + "required": ["id"] + } + } + }, + "tags": { + "type": "object", + "description": "Resource tags", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["apiVersion", "name", "location", "type", "sku"] + }"#; + + let s = Schema::from_json_str(schema).unwrap(); + match s.as_type() { + Type::Object { + properties, + required, + .. + } => { + // Verify App Service Plan specific properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("type")); + assert!(properties.contains_key("sku")); + assert!(properties.contains_key("kind")); + + // Verify type constant + match properties.get("type").unwrap().as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from("Microsoft.Web/serverfarms")); + } + _ => panic!("Expected type to be Type::Const"), + } + + // Verify SKU structure + match properties.get("sku").unwrap().as_type() { + Type::Object { + properties: sku_props, + required: sku_req, + .. + } => { + assert!(sku_props.contains_key("name")); + assert!(sku_props.contains_key("tier")); + assert!(sku_props.contains_key("capacity")); + + // Verify SKU name enum + match sku_props.get("name").unwrap().as_type() { + Type::Enum { values, .. } => { + assert!(values.contains(&Value::from("B1"))); + assert!(values.contains(&Value::from("S1"))); + assert!(values.contains(&Value::from("P1V3"))); + } + _ => panic!("Expected SKU name to be Type::Enum"), + } + + // Verify SKU tier enum + match sku_props.get("tier").unwrap().as_type() { + Type::Enum { values, .. } => { + assert!(values.contains(&Value::from("Basic"))); + assert!(values.contains(&Value::from("Standard"))); + assert!(values.contains(&Value::from("PremiumV3"))); + } + _ => panic!("Expected SKU tier to be Type::Enum"), + } + + let sku_required = sku_req.as_ref().expect("SKU required should be present"); + assert!(sku_required.contains(&"name".into())); + } + _ => panic!("Expected sku to be Type::Object"), + } + + // Verify kind enum + match properties.get("kind").unwrap().as_type() { + Type::Enum { values, .. } => { + assert!(values.contains(&Value::from("app"))); + assert!(values.contains(&Value::from("linux"))); + assert!(values.contains(&Value::from("functionapp"))); + } + _ => panic!("Expected kind to be Type::Enum"), + } + + // Verify required fields + let req = required.as_ref().expect("required should be present"); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"type".into())); + assert!(req.contains(&"sku".into())); + } + _ => panic!("Expected Type::Object"), + } +} diff --git a/src/schema/tests/suite.rs b/src/schema/tests/suite.rs new file mode 100644 index 0000000..0bec536 --- /dev/null +++ b/src/schema/tests/suite.rs @@ -0,0 +1,2775 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::*; +use crate::{format, vec}; +use serde_json::json; + +#[test] +fn test_deserialize_any() { + let schema = json!({ + "type": "any", + "description": "any type", + "default": 42 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Any { + description, + default, + } => { + assert_eq!(description.as_deref(), Some("any type")); + assert_eq!(default, &Some(Value::from(42))); + } + _ => panic!("Expected Type::Any"), + } +} + +#[test] +fn test_deserialize_integer_all_fields() { + let schema = json!({ + "type": "integer", + "description": "an integer", + "minimum": 1, + "maximum": 10, + "default": 5 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("an integer")); + assert_eq!(minimum, &Some(1)); + assert_eq!(maximum, &Some(10)); + assert_eq!(default, &Some(Value::from(5))); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_no_minimum() { + let schema = json!({ + "type": "integer", + "description": "no min", + "maximum": 100, + "default": 50 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("no min")); + assert_eq!(minimum, &None); + assert_eq!(maximum, &Some(100)); + assert_eq!(default, &Some(Value::from(50))); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_no_maximum() { + let schema = json!({ + "type": "integer", + "description": "no max", + "minimum": -10, + "default": 0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("no max")); + assert_eq!(minimum, &Some(-10)); + assert_eq!(maximum, &None); + assert_eq!(default, &Some(Value::from(0))); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_only_required() { + let schema = json!({ + "type": "integer" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description, &None); + assert_eq!(minimum, &None); + assert_eq!(maximum, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_minimum_equals_maximum() { + let schema = json!({ + "type": "integer", + "minimum": 5, + "maximum": 5 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(5)); + assert_eq!(maximum, &Some(5)); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_negative_minimum_and_maximum() { + let schema = json!({ + "type": "integer", + "minimum": -100, + "maximum": -1 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(-100)); + assert_eq!(maximum, &Some(-1)); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_zero_minimum() { + let schema = json!({ + "type": "integer", + "minimum": 0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(0)); + assert_eq!(maximum, &None); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_large_values() { + let schema = json!({ + "type": "integer", + "minimum": i64::MIN, + "maximum": i64::MAX + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(i64::MIN)); + assert_eq!(maximum, &Some(i64::MAX)); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_integer_invalid_minimum_type() { + let schema = json!({ + "type": "integer", + "minimum": "not_an_integer" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_integer_invalid_maximum_type() { + let schema = json!({ + "type": "integer", + "maximum": [1, 2, 3] + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_integer_minimum_greater_than_maximum() { + let schema = json!({ + "type": "integer", + "minimum": 10, + "maximum": 5 + }); + // This will not error at deserialization, but you may want to add validation logic elsewhere. + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Integer { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(10)); + assert_eq!(maximum, &Some(5)); + } + _ => panic!("Expected Type::Integer"), + } +} + +#[test] +fn test_deserialize_number_all_fields() { + let schema = json!({ + "type": "number", + "description": "a number", + "minimum": 1.5, + "maximum": 10.5, + "default": 5.5 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("a number")); + assert_eq!(minimum, &Some(1.5)); + assert_eq!(maximum, &Some(10.5)); + assert_eq!(default, &Some(Value::from(5.5))); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_no_minimum() { + let schema = json!({ + "type": "number", + "description": "no min", + "maximum": 100.0, + "default": 50.0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("no min")); + assert_eq!(minimum, &None); + assert_eq!(maximum, &Some(100.0)); + assert_eq!(default, &Some(Value::from(50.0))); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_no_maximum() { + let schema = json!({ + "type": "number", + "description": "no max", + "minimum": -10.0, + "default": 0.0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description.as_deref(), Some("no max")); + assert_eq!(minimum, &Some(-10.0)); + assert_eq!(maximum, &None); + assert_eq!(default, &Some(Value::from(0.0))); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_only_required() { + let schema = json!({ + "type": "number" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description, &None); + assert_eq!(minimum, &None); + assert_eq!(maximum, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_minimum_equals_maximum() { + let schema = json!({ + "type": "number", + "minimum": 5.5, + "maximum": 5.5 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(5.5)); + assert_eq!(maximum, &Some(5.5)); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_negative_minimum_and_maximum() { + let schema = json!({ + "type": "number", + "minimum": -100.25, + "maximum": -1.75 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(-100.25)); + assert_eq!(maximum, &Some(-1.75)); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_zero_minimum() { + let schema = json!({ + "type": "number", + "minimum": 0.0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(0.0)); + assert_eq!(maximum, &None); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_large_values() { + let schema = json!({ + "type": "number", + "minimum": f64::MIN, + "maximum": f64::MAX + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(f64::MIN)); + assert_eq!(maximum, &Some(f64::MAX)); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_number_invalid_minimum_type() { + let schema = json!({ + "type": "number", + "minimum": "not_a_number" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_number_invalid_maximum_type() { + let schema = json!({ + "type": "number", + "maximum": [1, 2, 3] + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_number_minimum_greater_than_maximum() { + let schema = json!({ + "type": "number", + "minimum": 10.0, + "maximum": 5.0 + }); + // This will not error at deserialization, but you may want to add validation logic elsewhere. + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Number { + minimum, maximum, .. + } => { + assert_eq!(minimum, &Some(10.0)); + assert_eq!(maximum, &Some(5.0)); + } + _ => panic!("Expected Type::Number"), + } +} + +#[test] +fn test_deserialize_boolean_all_fields() { + let schema = json!({ + "type": "boolean", + "description": "a boolean", + "default": true + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Boolean { + description, + default, + } => { + assert_eq!(description.as_deref(), Some("a boolean")); + assert_eq!(default, &Some(Value::from(true))); + } + _ => panic!("Expected Type::Boolean"), + } +} + +#[test] +fn test_deserialize_boolean_no_description() { + let schema = json!({ + "type": "boolean", + "default": false + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Boolean { + description, + default, + } => { + assert_eq!(description, &None); + assert_eq!(default, &Some(Value::from(false))); + } + _ => panic!("Expected Type::Boolean"), + } +} + +#[test] +fn test_deserialize_boolean_no_default() { + let schema = json!({ + "type": "boolean", + "description": "no default" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Boolean { + description, + default, + } => { + assert_eq!(description.as_deref(), Some("no default")); + assert_eq!(default, &None); + } + _ => panic!("Expected Type::Boolean"), + } +} + +#[test] +fn test_deserialize_boolean_only_required() { + let schema = json!({ + "type": "boolean" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Boolean { + description, + default, + } => { + assert_eq!(description, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected Type::Boolean"), + } +} + +#[test] +fn test_deserialize_null_all_fields() { + let schema = json!({ + "type": "null", + "description": "a null" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Null { description } => { + assert_eq!(description.as_deref(), Some("a null")); + } + _ => panic!("Expected Type::Null"), + } +} + +#[test] +fn test_deserialize_null_no_description() { + let schema = json!({ + "type": "null" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Null { description } => { + assert_eq!(description, &None); + } + _ => panic!("Expected Type::Null"), + } +} + +#[test] +fn test_deserialize_string_all_fields() { + let schema = json!({ + "type": "string", + "description": "a string", + "minLength": 2, + "maxLength": 10, + "pattern": "^abc", + "default": "abc" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { + description, + min_length, + max_length, + pattern, + default, + } => { + assert_eq!(description.as_deref(), Some("a string")); + assert_eq!(min_length, &Some(2)); + assert_eq!(max_length, &Some(10)); + assert_eq!(pattern.as_deref(), Some("^abc")); + assert_eq!(default, &Some(Value::from("abc"))); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_no_min_max_pattern() { + let schema = json!({ + "type": "string", + "description": "no min/max/pattern", + "default": "foo" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { + description, + min_length, + max_length, + pattern, + default, + } => { + assert_eq!(description.as_deref(), Some("no min/max/pattern")); + assert_eq!(min_length, &None); + assert_eq!(max_length, &None); + assert_eq!(pattern, &None); + assert_eq!(default, &Some(Value::from("foo"))); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_only_required() { + let schema = json!({ + "type": "string" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { + description, + min_length, + max_length, + pattern, + default, + } => { + assert_eq!(description, &None); + assert_eq!(min_length, &None); + assert_eq!(max_length, &None); + assert_eq!(pattern, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_min_equals_max() { + let schema = json!({ + "type": "string", + "minLength": 5, + "maxLength": 5 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { + min_length, + max_length, + .. + } => { + assert_eq!(min_length, &Some(5)); + assert_eq!(max_length, &Some(5)); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_zero_min_length() { + let schema = json!({ + "type": "string", + "minLength": 0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { min_length, .. } => { + assert_eq!(min_length, &Some(0)); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_invalid_min_length_type() { + let schema = json!({ + "type": "string", + "minLength": "not_a_number" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_string_invalid_max_length_type() { + let schema = json!({ + "type": "string", + "maxLength": [1, 2, 3] + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_string_empty_pattern() { + let schema = json!({ + "type": "string", + "pattern": "" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { pattern, .. } => { + assert_eq!(pattern.as_deref(), Some("")); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_unicode_pattern() { + let schema = json!({ + "type": "string", + "pattern": "^[\\u4e00-\\u9fa5]+$" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { pattern, .. } => { + assert_eq!(pattern.as_deref(), Some("^[\\u4e00-\\u9fa5]+$")); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_large_min_max_length() { + let schema = json!({ + "type": "string", + "minLength": usize::MAX, + "maxLength": usize::MAX + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::String { + min_length, + max_length, + .. + } => { + assert_eq!(min_length, &Some(usize::MAX)); + assert_eq!(max_length, &Some(usize::MAX)); + } + _ => panic!("Expected Type::String"), + } +} + +#[test] +fn test_deserialize_string_invalid_pattern_type() { + let schema = json!({ + "type": "string", + "pattern": 123 + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_array_only_required() { + let schema = json!({ + "type": "array", + "items": { "type": "integer" } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + description, + items, + min_items, + max_items, + default, + } => { + assert_eq!(description, &None); + assert_eq!(min_items, &None); + assert_eq!(max_items, &None); + assert_eq!(default, &None); + // Assert items fields individually + match items.as_type() { + Type::Integer { + description, + minimum, + maximum, + default, + } => { + assert_eq!(description, &None); + assert_eq!(minimum, &None); + assert_eq!(maximum, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected items to be Type::Integer"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_equals_max() { + let schema = json!({ + "type": "array", + "items": { "type": "boolean" }, + "minItems": 3, + "maxItems": 3 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(3)); + assert_eq!(max_items, &Some(3)); + match items.as_type() { + Type::Boolean { + description, + default, + } => { + assert_eq!(description, &None); + assert_eq!(default, &None); + } + _ => panic!("Expected items to be Type::Boolean"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_zero_min_items() { + let schema = json!({ + "type": "array", + "items": { "type": "null" }, + "minItems": 0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, items, .. + } => { + assert_eq!(min_items, &Some(0)); + match items.as_type() { + Type::Null { description } => { + assert_eq!(description, &None); + } + _ => panic!("Expected items to be Type::Null"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_greater_than_max() { + let schema = json!({ + "type": "array", + "items": { "type": "string" }, + "minItems": 10, + "maxItems": 5 + }); + // This will not error at deserialization, but you may want to add validation logic elsewhere. + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(10)); + assert_eq!(max_items, &Some(5)); + match items.as_type() { + Type::String { .. } => {} + _ => panic!("Expected items to be Type::String"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_large_min_max() { + let schema = json!({ + "type": "array", + "items": { "type": "number" }, + "minItems": usize::MAX, + "maxItems": usize::MAX + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(usize::MAX)); + assert_eq!(max_items, &Some(usize::MAX)); + match items.as_type() { + Type::Number { .. } => {} + _ => panic!("Expected items to be Type::Number"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_invalid_min_items_type() { + let schema = json!({ + "type": "array", + "items": { "type": "string" }, + "minItems": "not_a_number" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_array_invalid_max_items_type() { + let schema = json!({ + "type": "array", + "items": { "type": "string" }, + "maxItems": [1, 2, 3] + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_array_invalid_items_type() { + let schema = json!({ + "type": "array", + "items": 123 + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_array_items_with_fields() { + let schema = json!({ + "type": "array", + "items": { + "type": "string", + "description": "inner string", + "minLength": 1, + "maxLength": 8, + "pattern": "foo", + "default": "bar" + }, + "minItems": 1, + "maxItems": 2, + "description": "outer array", + "default": ["bar"] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + description, + items, + min_items, + max_items, + default, + } => { + assert_eq!(description.as_deref(), Some("outer array")); + assert_eq!(min_items, &Some(1)); + assert_eq!(max_items, &Some(2)); + assert_eq!( + default, + &Some(Value::Array(Rc::new(vec![Value::from("bar")]))) + ); + match items.as_type() { + Type::String { + description, + min_length, + max_length, + pattern, + default, + } => { + assert_eq!(description.as_deref(), Some("inner string")); + assert_eq!(min_length, &Some(1)); + assert_eq!(max_length, &Some(8)); + assert_eq!(pattern.as_deref(), Some("foo")); + assert_eq!(default, &Some(Value::from("bar"))); + } + _ => panic!("Expected items to be Type::String"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_items_none_max_items_some() { + let schema = json!({ + "type": "array", + "items": { "type": "string" }, + "maxItems": 7 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &None); + assert_eq!(max_items, &Some(7)); + match items.as_type() { + Type::String { .. } => {} + _ => panic!("Expected items to be Type::String"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_items_some_max_items_none() { + let schema = json!({ + "type": "array", + "items": { "type": "boolean" }, + "minItems": 2 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(2)); + assert_eq!(max_items, &None); + match items.as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected items to be Type::Boolean"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_items_zero_max_items_zero() { + let schema = json!({ + "type": "array", + "items": { "type": "null" }, + "minItems": 0, + "maxItems": 0 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(0)); + assert_eq!(max_items, &Some(0)); + match items.as_type() { + Type::Null { .. } => {} + _ => panic!("Expected items to be Type::Null"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_min_items_greater_than_max_items() { + let schema = json!({ + "type": "array", + "items": { "type": "number" }, + "minItems": 5, + "maxItems": 2 + }); + // This is a corner case: minItems > maxItems, should not error at deserialization. + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(5)); + assert_eq!(max_items, &Some(2)); + match items.as_type() { + Type::Number { .. } => {} + _ => panic!("Expected items to be Type::Number"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_array_items_with_nested_array() { + let schema = json!({ + "type": "array", + "items": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 1, + "maxItems": 2 + }, + "minItems": 2, + "maxItems": 3 + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Array { + min_items, + max_items, + items, + .. + } => { + assert_eq!(min_items, &Some(2)); + assert_eq!(max_items, &Some(3)); + match items.as_type() { + Type::Array { + min_items: inner_min, + max_items: inner_max, + items: inner_items, + .. + } => { + assert_eq!(inner_min, &Some(1)); + assert_eq!(inner_max, &Some(2)); + match inner_items.as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected nested items to be Type::Integer"), + } + } + _ => panic!("Expected items to be Type::Array"), + } + } + _ => panic!("Expected Type::Array"), + } +} + +#[test] +fn test_deserialize_object_all_fields() { + let schema = json!({ + "type": "object", + "description": "an object", + "properties": { + "foo": { "type": "string" }, + "bar": { "type": "integer" } + }, + "required": ["foo"], + "additionalProperties": { "type": "boolean" }, + "name": "MyObject", + "default": { "foo": "abc", "bar": 42 } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + description, + properties, + required, + additional_properties, + name, + default, + discriminated_subobject, + } => { + assert_eq!(description.as_deref(), Some("an object")); + assert_eq!(name.as_deref(), Some("MyObject")); + assert_eq!( + default, + &Some(Value::Object(Rc::new( + [ + ("foo".into(), Value::from("abc")), + ("bar".into(), Value::from(42)) + ] + .iter() + .cloned() + .collect() + ))) + ); + // properties + assert_eq!(properties.len(), 2); + match properties.get("foo").unwrap().as_type() { + Type::String { .. } => {} + _ => panic!("Expected foo to be Type::String"), + } + match properties.get("bar").unwrap().as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected bar to be Type::Integer"), + } + // required + let req = required.as_ref().expect("required should be present"); + assert_eq!(req.len(), 1); + assert_eq!(req[0].as_ref(), "foo"); + // additional_properties + match additional_properties.as_ref().unwrap().as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected additionalProperties to be Type::Boolean"), + } + // discriminated_subobject + assert!(discriminated_subobject.is_none()); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_only_required() { + let schema = json!({ + "type": "object", + "properties": {} + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + description, + properties, + required, + additional_properties, + name, + default, + discriminated_subobject, + } => { + assert_eq!(description, &None); + assert_eq!(properties.len(), 0); + assert!(required.is_none()); + assert!(matches!( + additional_properties.as_ref().unwrap().as_type(), + Type::Any { .. } + )); + assert!(name.is_none()); + assert!(default.is_none()); + assert!(discriminated_subobject.is_none()); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_with_required_and_additional_properties() { + let schema = json!({ + "type": "object", + "properties": { + "x": { "type": "number" } + }, + "required": ["x"], + "additionalProperties": { "type": "string" } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + properties, + required, + additional_properties, + .. + } => { + assert_eq!(properties.len(), 1); + match properties.get("x").unwrap().as_type() { + Type::Number { .. } => {} + _ => panic!("Expected x to be Type::Number"), + } + let req = required.as_ref().expect("required should be present"); + assert_eq!(req.len(), 1); + assert_eq!(req[0].as_ref(), "x"); + match additional_properties.as_ref().unwrap().as_type() { + Type::String { .. } => {} + _ => panic!("Expected additionalProperties to be Type::String"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_with_discriminated_subobject() { + let schema = json!({ + "type": "object", + "properties": {}, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "foo" } } }, + "then": { + "properties": { "foo": { "type": "string" } }, + "required": ["foo"] + } + }, + { + "if": { "properties": { "kind": { "const": "bar" } } }, + "then": { + "properties": { "bar": { "type": "integer" } }, + "required": ["bar"] + } + } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + discriminated_subobject, + .. + } => { + let dso = discriminated_subobject + .as_ref() + .expect("should have discriminated_subobject"); + assert_eq!(dso.discriminator.as_ref(), "kind"); + assert_eq!(dso.variants.len(), 2); + let foo_variant = dso.variants.get("foo").expect("foo variant"); + assert!(foo_variant.properties.contains_key("foo")); + let bar_variant = dso.variants.get("bar").expect("bar variant"); + assert!(bar_variant.properties.contains_key("bar")); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_invalid_properties_type() { + let schema = json!({ + "type": "object", + "properties": 123 + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_object_invalid_required_type() { + let schema = json!({ + "type": "object", + "properties": {}, + "required": "not_an_array" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_object_invalid_additional_properties_type() { + let schema = json!({ + "type": "object", + "properties": {}, + "additionalProperties": 123 + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_object_empty_properties_and_required() { + let schema = json!({ + "type": "object", + "properties": {}, + "required": [] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert_eq!(properties.len(), 0); + let req = required.as_ref().expect("required should be present"); + assert!(req.is_empty()); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_required_not_in_properties() { + let schema = json!({ + "type": "object", + "properties": { "x": { "type": "string" } }, + "required": ["y"] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert_eq!(properties.len(), 1); + let req = required.as_ref().expect("required should be present"); + assert_eq!(req.len(), 1); + assert_eq!(req[0].as_ref(), "y"); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_additional_properties_none() { + let schema = json!({ + "type": "object", + "properties": { "foo": { "type": "string" } } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + additional_properties, + .. + } => { + // When no additionalProperties field is specified, the default behavior + // is to allow additional properties of any type + match additional_properties.as_ref().unwrap().as_type() { + Type::Any { .. } => {} + _ => panic!("Expected additionalProperties to default to Type::Any"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_additional_properties_false() { + let schema = json!({ + "type": "object", + "properties": { "foo": { "type": "string" } }, + "additionalProperties": false + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + additional_properties, + .. + } => { + // When additionalProperties is false, no additional properties are allowed + assert!( + additional_properties.is_none(), + "Expected additionalProperties to be None when set to false" + ); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_additional_properties_true() { + let schema = json!({ + "type": "object", + "properties": { "foo": { "type": "string" } }, + "additionalProperties": true + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + additional_properties, + .. + } => { + // When additionalProperties is true, additional properties of any type are allowed + match additional_properties.as_ref().unwrap().as_type() { + Type::Any { .. } => {} + _ => panic!("Expected additionalProperties to be Type::Any when set to true"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_name_field_only() { + let schema = json!({ + "type": "object", + "properties": {}, + "name": "OnlyName" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { name, .. } => { + assert_eq!(name.as_deref(), Some("OnlyName")); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_default_empty_object() { + let schema = json!({ + "type": "object", + "properties": {}, + "default": {} + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { default, .. } => { + assert_eq!(default, &Some(Value::Object(Rc::new(BTreeMap::new())))); + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_with_empty_discriminated_subobject() { + let schema = json!({ + "type": "object", + "properties": {}, + "allOf": [] + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_object_nested_2_levels() { + let schema = json!({ + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { "type": "string" } + } + } + } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { properties, .. } => { + let level1 = properties.get("level1").expect("level1 property"); + match level1.as_type() { + Type::Object { + properties: props2, .. + } => { + let level2 = props2.get("level2").expect("level2 property"); + match level2.as_type() { + Type::String { .. } => {} + _ => panic!("Expected level2 to be Type::String"), + } + } + _ => panic!("Expected level1 to be Type::Object"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_object_nested_3_levels() { + let schema = json!({ + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": { + "b": { + "type": "object", + "properties": { + "c": { "type": "integer" } + } + } + } + } + } + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { properties, .. } => { + let a = properties.get("a").expect("a property"); + match a.as_type() { + Type::Object { + properties: props_b, + .. + } => { + let b = props_b.get("b").expect("b property"); + match b.as_type() { + Type::Object { + properties: props_c, + .. + } => { + let c = props_c.get("c").expect("c property"); + match c.as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected c to be Type::Integer"), + } + } + _ => panic!("Expected b to be Type::Object"), + } + } + _ => panic!("Expected a to be Type::Object"), + } + } + _ => panic!("Expected Type::Object"), + } +} + +#[test] +fn test_deserialize_enum_all_fields() { + let schema = json!({ + "enum": ["foo", "bar", 42, true, null], + "description": "an enum" + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { + description, + values, + } => { + assert_eq!(description.as_deref(), Some("an enum")); + assert_eq!(values.len(), 5); + assert_eq!(values[0], Value::from("foo")); + assert_eq!(values[1], Value::from("bar")); + assert_eq!(values[2], Value::from(42)); + assert_eq!(values[3], Value::from(true)); + assert_eq!(values[4], Value::Null); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_only_required() { + let schema = json!({ + "enum": ["a", "b"] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { + description, + values, + } => { + assert_eq!(description, &None); + assert_eq!(values.len(), 2); + assert_eq!(values[0], Value::from("a")); + assert_eq!(values[1], Value::from("b")); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_empty_values() { + let schema = json!({ + "enum": [], + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert!(values.is_empty()); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_single_value() { + let schema = json!({ + "enum": [123] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 1); + assert_eq!(values[0], Value::from(123)); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_mixed_types() { + let schema = json!({ + "enum": ["x", 1, false, null, {"foo": "bar"}, [1,2,3]] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 6); + assert_eq!(values[0], Value::from("x")); + assert_eq!(values[1], Value::from(1)); + assert_eq!(values[2], Value::from(false)); + assert_eq!(values[3], Value::Null); + // Object and array types + match &values[4] { + Value::Object(obj) => { + assert_eq!(obj.get(&Value::from("foo")), Some(&Value::from("bar"))) + } + _ => panic!("Expected object in enum values"), + } + match &values[5] { + Value::Array(arr) => { + assert_eq!(arr.len(), 3); + assert_eq!(arr[0], Value::from(1)); + assert_eq!(arr[1], Value::from(2)); + assert_eq!(arr[2], Value::from(3)); + } + _ => panic!("Expected array in enum values"), + } + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_invalid_values_type() { + let schema = json!({ + "enum": "not_an_array" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_enum_with_duplicate_values() { + let schema = json!({ + "enum": ["dup", "dup", 1, 1, null, null] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 6); + assert_eq!(values[0], Value::from("dup")); + assert_eq!(values[1], Value::from("dup")); + assert_eq!(values[2], Value::from(1)); + assert_eq!(values[3], Value::from(1)); + assert_eq!(values[4], Value::Null); + assert_eq!(values[5], Value::Null); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_all_values_null() { + let schema = json!({ + "enum": [null, null, null] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 3); + assert!(values.iter().all(|v| *v == Value::Null)); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_large_number_of_values() { + let values: Vec<_> = (0..1000).map(Value::from).collect(); + let schema = json!({ + "enum": values + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => { + assert_eq!(values.len(), 1000); + assert_eq!(values[0], Value::from(0)); + assert_eq!(values[999], Value::from(999)); + } + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_values_with_object_non_string_keys() { + // JSON keys are always strings, but serde_json will parse them as such. + let schema = json!({ + "enum": [ + { "1": "one", "true": "bool" } + ] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => match &values[0] { + Value::Object(obj) => { + assert_eq!(obj[&Value::from("1")], Value::from("one")); + assert_eq!(obj[&Value::from("true")], Value::from("bool")); + } + _ => panic!("Expected object in enum values"), + }, + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_enum_values_with_deeply_nested_structures() { + let schema = json!({ + "enum": [ + { + "a": [ + { "b": [1, 2, { "c": null }] } + ] + } + ] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Enum { values, .. } => match &values[0] { + Value::Object(obj) => { + let a = &obj[&Value::from("a")]; + match a { + Value::Array(arr) => match &arr[0] { + Value::Object(inner) => { + let b = &inner[&Value::from("b")]; + match b { + Value::Array(barr) => { + assert_eq!(barr[0], Value::from(1)); + assert_eq!(barr[1], Value::from(2)); + match &barr[2] { + Value::Object(cobj) => { + assert_eq!(cobj[&Value::from("c")], Value::Null); + } + _ => panic!("Expected object for 'c'"), + } + } + _ => panic!("Expected array for 'b'"), + } + } + _ => panic!("Expected object in 'a' array"), + }, + _ => panic!("Expected array for 'a'"), + } + } + _ => panic!("Expected object in enum values"), + }, + _ => panic!("Expected Type::Enum"), + } +} + +#[test] +fn test_deserialize_const_all_fields() { + let schema = json!({ + "const": 42, + "description": "a constant", + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { description, value } => { + assert_eq!(description.as_deref(), Some("a constant")); + assert_eq!(value, &Value::from(42)); + } + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_only_required() { + let schema = json!({ + "const": "foo" + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { description, value } => { + assert_eq!(description, &None); + assert_eq!(value, &Value::from("foo")); + } + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_value_null() { + let schema = json!({ + "const": null + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::Null); + } + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_value_object() { + let schema = json!({ + "const": { "foo": "bar", "baz": 1 } + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { value, .. } => match value { + Value::Object(ref obj) => { + assert_eq!(obj[&Value::from("foo")], Value::from("bar")); + assert_eq!(obj[&Value::from("baz")], Value::from(1)); + } + _ => panic!("Expected object for const value"), + }, + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_value_array() { + let schema = json!({ + "const": [1, 2, 3] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { value, .. } => match value { + Value::Array(ref arr) => { + assert_eq!(arr.len(), 3); + assert_eq!(arr[0], Value::from(1)); + assert_eq!(arr[1], Value::from(2)); + assert_eq!(arr[2], Value::from(3)); + } + _ => panic!("Expected array for const value"), + }, + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_value_boolean() { + let schema = json!({ + "const": true + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from(true)); + } + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_value_deeply_nested() { + let schema = json!({ + "const": { + "a": [1, { "b": [null, false] }] + } + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Const { value, .. } => match value { + Value::Object(ref obj) => { + let a = &obj[&Value::from("a")]; + match a { + Value::Array(arr) => { + assert_eq!(arr[0], Value::from(1)); + match &arr[1] { + Value::Object(inner) => { + let b = &inner[&Value::from("b")]; + match b { + Value::Array(barr) => { + assert_eq!(barr[0], Value::Null); + assert_eq!(barr[1], Value::from(false)); + } + _ => panic!("Expected array for 'b'"), + } + } + _ => panic!("Expected object in 'a' array"), + } + } + _ => panic!("Expected array for 'a'"), + } + } + _ => panic!("Expected object for const value"), + }, + _ => panic!("Expected Type::Const"), + } +} + +#[test] +fn test_deserialize_const_invalid_missing_value() { + let schema = json!({ + "type": "const" + }); + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_basic() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 2); + match variants[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected first variant to be Type::String"), + } + match variants[1].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected second variant to be Type::Integer"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_single_variant() { + let schema = json!({ + "anyOf": [ + { "type": "string" } + ] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 1); + match variants[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected variant to be Type::String"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_empty() { + let schema = json!({ + "anyOf": [] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert!(variants.is_empty()); + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_mixed_types() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "integer" }, + { "type": "array", "items": { "type": "boolean" } }, + { "type": "object", "properties": { "foo": { "type": "null" } } } + ] + }); + let s: Schema = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 4); + match variants[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected variant 0 to be Type::String"), + } + match variants[1].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected variant 1 to be Type::Integer"), + } + match variants[2].as_type() { + Type::Array { items, .. } => match items.as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected array items to be Type::Boolean"), + }, + _ => panic!("Expected variant 2 to be Type::Array"), + } + match variants[3].as_type() { + Type::Object { properties, .. } => { + assert!(properties.contains_key("foo")); + } + _ => panic!("Expected variant 3 to be Type::Object"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_invalid_variants_type() { + let schema = json!({ + "anyOf": "not_an_array" + }); + let result: Result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_missing_anyof() { + let schema = json!({}); + let result: Result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_with_invalid_variant() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + 123 + ] + }); + let result: Result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_deeply_nested() { + let schema = json!({ + "anyOf": [ + { + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + }, + { "type": "boolean" } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 2); + match variants[0].as_type() { + Type::AnyOf(inner) => { + assert_eq!(inner.len(), 2); + match inner[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected inner variant 0 to be Type::String"), + } + match inner[1].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected inner variant 1 to be Type::Integer"), + } + } + _ => panic!("Expected first variant to be Type::AnyOf"), + } + match variants[1].as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected second variant to be Type::Boolean"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_all_null() { + let schema = json!({ + "anyOf": [null, null, null] + }); + let result = Schema::from_serde_json_value(schema); + // Should error: null is not a valid schema + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_with_duplicates() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "string" }, + { "type": "integer" }, + { "type": "integer" } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 4); + match variants[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected variant 0 to be Type::String"), + } + match variants[1].as_type() { + Type::String { .. } => {} + _ => panic!("Expected variant 1 to be Type::String"), + } + match variants[2].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected variant 2 to be Type::Integer"), + } + match variants[3].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected variant 3 to be Type::Integer"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_large_number_of_variants() { + let variants: Vec<_> = (0..100) + .map(|i| json!({ "type": "integer", "default": i })) + .collect(); + let schema = json!({ + "anyOf": variants + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 100); + for (i, v) in variants.iter().enumerate() { + match v.as_type() { + Type::Integer { default, .. } => { + assert_eq!(default, &Some(Value::from(i as i64))); + } + _ => panic!("Expected all variants to be Type::Integer"), + } + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_deeply_nested_multiple_levels() { + let schema = json!({ + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { "type": "string" } + ] + } + ] + } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(outer) => { + assert_eq!(outer.len(), 1); + match outer[0].as_type() { + Type::AnyOf(middle) => { + assert_eq!(middle.len(), 1); + match middle[0].as_type() { + Type::AnyOf(inner) => { + assert_eq!(inner.len(), 1); + match inner[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected innermost to be Type::String"), + } + } + _ => panic!("Expected middle to be Type::AnyOf"), + } + } + _ => panic!("Expected outer to be Type::AnyOf"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_empty_object_variant() { + let schema = json!({ + "anyOf": [ + {} + ] + }); + let result = Schema::from_serde_json_value(schema); + // Should error: empty object is not a valid schema + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_with_invalid_schema_in_list() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "foo": "bar" } + ] + }); + let result = Schema::from_serde_json_value(schema); + // Should error: { "foo": "bar" } is not a valid schema + assert!(result.is_err()); +} + +#[test] +fn test_deserialize_anyof_all_same_variant() { + let schema = json!({ + "anyOf": [ + { "type": "boolean" }, + { "type": "boolean" }, + { "type": "boolean" } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 3); + for v in variants.iter() { + match v.as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected all variants to be Type::Boolean"), + } + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_nested_types() { + let schema = json!({ + "anyOf": [ + { "type": "string", "minLength": 2 }, + { "type": "integer", "minimum": 0 }, + { + "type": "array", + "items": { "type": "boolean" }, + "minItems": 1 + }, + { + "type": "object", + "properties": { + "foo": { "type": "string" }, + "bar": { "type": "number" } + }, + "required": ["foo"] + } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 4); + match &variants[0].as_type() { + Type::String { min_length, .. } => assert_eq!(min_length, &Some(2)), + _ => panic!("Expected first variant to be Type::String"), + } + match &variants[1].as_type() { + Type::Integer { minimum, .. } => assert_eq!(minimum, &Some(0)), + _ => panic!("Expected second variant to be Type::Integer"), + } + match &variants[2].as_type() { + Type::Array { + items, min_items, .. + } => { + assert_eq!(min_items, &Some(1)); + match items.as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected array items to be Type::Boolean"), + } + } + _ => panic!("Expected third variant to be Type::Array"), + } + match &variants[3].as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("foo")); + assert!(properties.contains_key("bar")); + let req = required.as_ref().expect("required should be present"); + assert_eq!(req[0].as_ref(), "foo"); + } + _ => panic!("Expected fourth variant to be Type::Object"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_deeply_nested_anyof() { + let schema = json!({ + "anyOf": [ + { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + }, + { "type": "integer" } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 2); + match &variants[0].as_type() { + Type::AnyOf(inner_variants) => { + assert_eq!(inner_variants.len(), 2); + match inner_variants[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected inner variant 0 to be Type::String"), + } + match inner_variants[1].as_type() { + Type::Null { .. } => {} + _ => panic!("Expected inner variant 1 to be Type::Null"), + } + } + _ => panic!("Expected first variant to be Type::AnyOf"), + } + match &variants[1].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected second variant to be Type::Integer"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_enum_and_const() { + let schema = json!({ + "anyOf": [ + { + "enum": ["a", "b", "c"] + }, + { + "const": 42 + } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 2); + match &variants[0].as_type() { + Type::Enum { values, .. } => { + assert_eq!( + values.as_ref(), + &vec![Value::from("a"), Value::from("b"), Value::from("c")] + ); + } + _ => panic!("Expected first variant to be Type::Enum"), + } + match &variants[1].as_type() { + Type::Const { value, .. } => { + assert_eq!(value, &Value::from(42)); + } + _ => panic!("Expected second variant to be Type::Const"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_complex_nesting() { + let schema = json!({ + "anyOf": [ + { + "type": "object", + "properties": { + "x": { + "anyOf": [ + { "type": "string" }, + { "type": "integer" } + ] + } + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { "type": "boolean" }, + { "type": "null" } + ] + } + } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 2); + match &variants[0].as_type() { + Type::Object { properties, .. } => { + let x = properties.get("x").expect("x property"); + match x.as_type() { + Type::AnyOf(inner) => { + assert_eq!(inner.len(), 2); + match inner[0].as_type() { + Type::String { .. } => {} + _ => panic!("Expected x.anyOf[0] to be Type::String"), + } + match inner[1].as_type() { + Type::Integer { .. } => {} + _ => panic!("Expected x.anyOf[1] to be Type::Integer"), + } + } + _ => panic!("Expected x to be Type::AnyOf"), + } + } + _ => panic!("Expected first variant to be Type::Object"), + } + match &variants[1].as_type() { + Type::Array { items, .. } => match items.as_type() { + Type::AnyOf(inner) => { + assert_eq!(inner.len(), 2); + match inner[0].as_type() { + Type::Boolean { .. } => {} + _ => panic!("Expected items.anyOf[0] to be Type::Boolean"), + } + match inner[1].as_type() { + Type::Null { .. } => {} + _ => panic!("Expected items.anyOf[1] to be Type::Null"), + } + } + _ => panic!("Expected items to be Type::AnyOf"), + }, + _ => panic!("Expected second variant to be Type::Array"), + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_duplicate_and_null_variants() { + let schema = json!({ + "anyOf": [ + { "type": "null" }, + { "type": "null" }, + { "type": "string" }, + { "type": "string" } + ] + }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 4); + assert!(matches!(variants[0].as_type(), Type::Null { .. })); + assert!(matches!(variants[1].as_type(), Type::Null { .. })); + assert!(matches!(variants[2].as_type(), Type::String { .. })); + assert!(matches!(variants[3].as_type(), Type::String { .. })); + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_anyof_with_large_number_of_variants() { + let variants: Vec<_> = (0..100) + .map(|i| json!({ "type": "integer", "minimum": i })) + .collect(); + let schema = json!({ "anyOf": variants }); + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::AnyOf(variants) => { + assert_eq!(variants.len(), 100); + for (i, v) in variants.iter().enumerate() { + match v.as_type() { + Type::Integer { minimum, .. } => assert_eq!(minimum, &Some(i as i64)), + _ => panic!("Expected variant {} to be Type::Integer", i), + } + } + } + _ => panic!("Expected Type::AnyOf"), + } +} + +#[test] +fn test_deserialize_object_with_mixed_discriminators_error() { + // This test verifies that using different discriminator fields in the same + // discriminated subobject results in an error + let schema = json!({ + "type": "object", + "peekoo": 1, + "properties": { + "name": { "type": "string" } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "user" } + } + }, + "then": { + "properties": { + "email": { "type": "string" } + }, + "required": ["email"] + } + }, + { + "if": { + "properties": { + "kind": { "const": "admin" } // Different discriminator field + } + }, + "then": { + "properties": { + "permissions": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["permissions"] + } + } + ] + }); + + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); + + // Verify the error message mentions single discriminator requirement + let error_msg = format!("{}", result.unwrap_err()); + assert!(error_msg.contains("single discriminator")); +} + +#[test] +fn test_deserialize_object_with_multiple_discriminator_properties_error() { + // This test verifies that having multiple properties in a discriminator specification + // results in an error + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "user" }, + "category": { "const": "external" } // Multiple discriminator properties + } + }, + "then": { + "properties": { + "email": { "type": "string" } + } + } + } + ] + }); + + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); + + // Verify the error message mentions exactly one property requirement + let error_msg = format!("{}", result.unwrap_err()); + assert!(error_msg.contains("exactly one property")); +} + +#[test] +fn test_deserialize_object_with_no_discriminator_properties_error() { + // This test verifies that having no properties in a discriminator specification + // results in an error + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "allOf": [ + { + "if": { + "properties": {} // No discriminator properties + }, + "then": { + "properties": { + "email": { "type": "string" } + } + } + } + ] + }); + + let result = Schema::from_serde_json_value(schema); + assert!(result.is_err()); + + // Verify the error message mentions exactly one property requirement + let error_msg = format!("{}", result.unwrap_err()); + assert!(error_msg.contains("exactly one property")); +} + +#[test] +fn test_deserialize_object_with_consistent_discriminator_success() { + // This test verifies that using the same discriminator field consistently works + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "entityType": { "type": "string" } + }, + "required": ["name", "entityType"], + "allOf": [ + { + "if": { + "properties": { + "entityType": { "const": "user" } + } + }, + "then": { + "properties": { + "email": { "type": "string" }, + "lastLogin": { "type": "string" } + }, + "required": ["email"] + } + }, + { + "if": { + "properties": { + "entityType": { "const": "group" } + } + }, + "then": { + "properties": { + "members": { + "type": "array", + "items": { "type": "string" } + }, + "permissions": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["members"] + } + }, + { + "if": { + "properties": { + "entityType": { "const": "service" } + } + }, + "then": { + "properties": { + "endpoint": { "type": "string" }, + "healthCheck": { "type": "string" }, + "version": { "type": "string" } + }, + "required": ["endpoint", "version"] + } + } + ] + }); + + let s = Schema::from_serde_json_value(schema).unwrap(); + match s.as_type() { + Type::Object { + discriminated_subobject, + properties, + required, + .. + } => { + // Verify base properties + assert!(properties.contains_key("name")); + assert!(properties.contains_key("entityType")); + + let req = required.as_ref().expect("required should be present"); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"entityType".into())); + + // Verify discriminated subobject + let dso = discriminated_subobject + .as_ref() + .expect("should have discriminated_subobject"); + assert_eq!(dso.discriminator.as_ref(), "entityType"); + assert_eq!(dso.variants.len(), 3); + + // Verify user variant + let user_variant = dso.variants.get("user").expect("user variant"); + assert!(user_variant.properties.contains_key("email")); + assert!(user_variant.properties.contains_key("lastLogin")); + let user_req = user_variant.required.as_ref().expect("user required"); + assert!(user_req.contains(&"email".into())); + + // Verify group variant + let group_variant = dso.variants.get("group").expect("group variant"); + assert!(group_variant.properties.contains_key("members")); + assert!(group_variant.properties.contains_key("permissions")); + let group_req = group_variant.required.as_ref().expect("group required"); + assert!(group_req.contains(&"members".into())); + + // Verify service variant + let service_variant = dso.variants.get("service").expect("service variant"); + assert!(service_variant.properties.contains_key("endpoint")); + assert!(service_variant.properties.contains_key("healthCheck")); + assert!(service_variant.properties.contains_key("version")); + let service_req = service_variant.required.as_ref().expect("service required"); + assert!(service_req.contains(&"endpoint".into())); + assert!(service_req.contains(&"version".into())); + } + _ => panic!("Expected Type::Object"), + } +}