From 77f8544868004b97344d037bb42522305e4c1f2c Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi <35780660+anakrish@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:40:45 -0500 Subject: [PATCH] feat: Type System (#452) Details: - Implement complete Type enum with 12 variants: Any, Integer, Number, Boolean, Null, String, Array, Set, Object, Enum, Const, AnyOf - Add Schema wrapper struct with reference counting for efficient sharing - Support JSON Schema-compatible deserialization with serde - Implement discriminated subobjects for polymorphic type definitions - Add comprehensive test suite covering all type variants - Include Azure resource schema examples (Storage, VM, Key Vault, App Service) - Create meta-schema validation system with lazy static validator - Add extensive edge case and corner case test coverage - Implement custom deserializers for complex schema patterns This establishes the foundation for type checking and validation of Rego policies, particularly useful for cloud resource schemas and policy validation. Regorus's type system is a first of many features intended to enable type checking and various other constraints on Rego policies. The type system is inspired from: - JSON schema - Bicep Signed-off-by: Anand Krishnamoorthi --- .github/workflows/pr.yml | 3 + Cargo.lock | 128 +- Cargo.toml | 2 +- src/lib.rs | 2 + src/schema.rs | 1033 +++++++++++++ src/schema/meta.rs | 58 + src/schema/meta.schema.json | 376 +++++ src/schema/meta/tests.rs | 160 ++ src/schema/tests.rs | 5 + src/schema/tests/azure.rs | 1266 ++++++++++++++++ src/schema/tests/suite.rs | 2775 +++++++++++++++++++++++++++++++++++ 11 files changed, 5780 insertions(+), 28 deletions(-) create mode 100644 src/schema.rs create mode 100644 src/schema/meta.rs create mode 100644 src/schema/meta.schema.json create mode 100644 src/schema/meta/tests.rs create mode 100644 src/schema/tests.rs create mode 100644 src/schema/tests/azure.rs create mode 100644 src/schema/tests/suite.rs 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"), + } +}