From 12f6f91a6d3d6028cb012f95bf5a0c7fbc971a08 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 9 Oct 2025 15:28:06 -0500 Subject: [PATCH 1/4] (GH-538) Add `dsc-lib-jsonschema` crate This change begins the schema canonicalization process by adding a new library crate to contain the helpers for defining canonical and enhanced schemas ergonomically. In this initial phase, the new crate defines: - The `VSCODE_KEYWORDS` constant, enumerating the keywords that VS Code's JSON language server recognizes. - The `idiomaticize_string_enum` transformer for munging the generated schemas for string enums with annotation keywords. - The `idiomaticize_externally_tagged_enum` transformer for munging the generated schemas for externally tagged enums to a more idiomatic and readable representation. --- Cargo.lock | 35 ++ Cargo.toml | 10 + build.data.psd1 | 6 + build.ps1 | 1 + lib/dsc-lib-jsonschema/Cargo.toml | 22 + lib/dsc-lib-jsonschema/locales/en-us.toml | 114 ++++ lib/dsc-lib-jsonschema/src/lib.rs | 18 + lib/dsc-lib-jsonschema/src/macros.rs | 18 + lib/dsc-lib-jsonschema/src/tests/mod.rs | 16 + .../src/tests/transforms/mod.rs | 4 + .../src/tests/vscode/mod.rs | 4 + lib/dsc-lib-jsonschema/src/transforms/mod.rs | 487 ++++++++++++++++ lib/dsc-lib-jsonschema/src/vscode/mod.rs | 29 + .../tests/integration/main.rs | 18 + .../idiomaticizing/enums/externally_tagged.rs | 521 ++++++++++++++++++ .../transforms/idiomaticizing/enums/mod.rs | 7 + .../idiomaticizing/enums/string_variants.rs | 374 +++++++++++++ .../transforms/idiomaticizing/mod.rs | 8 + .../tests/integration/transforms/mod.rs | 8 + 19 files changed, 1700 insertions(+) create mode 100644 lib/dsc-lib-jsonschema/Cargo.toml create mode 100644 lib/dsc-lib-jsonschema/locales/en-us.toml create mode 100644 lib/dsc-lib-jsonschema/src/lib.rs create mode 100644 lib/dsc-lib-jsonschema/src/macros.rs create mode 100644 lib/dsc-lib-jsonschema/src/tests/mod.rs create mode 100644 lib/dsc-lib-jsonschema/src/tests/transforms/mod.rs create mode 100644 lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs create mode 100644 lib/dsc-lib-jsonschema/src/transforms/mod.rs create mode 100644 lib/dsc-lib-jsonschema/src/vscode/mod.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/main.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/externally_tagged.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/mod.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/string_variants.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/mod.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 81a1f8d0a..c886c6a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,6 +576,12 @@ dependencies = [ "syn", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -681,6 +687,19 @@ dependencies = [ "which", ] +[[package]] +name = "dsc-lib-jsonschema" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "regex", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "dsc-lib-osinfo" version = "1.0.0" @@ -1897,6 +1916,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -3936,6 +3965,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 81afdf6d3..2f981b27d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "dsc", "lib/dsc-lib", + "lib/dsc-lib-jsonschema", "resources/dscecho", "lib/dsc-lib-osinfo", "resources/osinfo", @@ -27,6 +28,7 @@ members = [ default-members = [ "dsc", "lib/dsc-lib", + "lib/dsc-lib-jsonschema", "resources/dscecho", "lib/dsc-lib-osinfo", "resources/osinfo", @@ -51,6 +53,7 @@ default-members = [ Windows = [ "dsc", "lib/dsc-lib", + "lib/dsc-lib-jsonschema", "resources/dscecho", "lib/dsc-lib-osinfo", "resources/osinfo", @@ -70,6 +73,7 @@ Windows = [ macOS = [ "dsc", "lib/dsc-lib", + "lib/dsc-lib-jsonschema", "resources/dscecho", "lib/dsc-lib-osinfo", "resources/osinfo", @@ -86,6 +90,7 @@ macOS = [ Linux = [ "dsc", "lib/dsc-lib", + "lib/dsc-lib-jsonschema", "resources/dscecho", "lib/dsc-lib-osinfo", "resources/osinfo", @@ -206,8 +211,13 @@ cc = { version = "1.2" } # registry, dsc-lib-registry static_vcruntime = { version = "2.0" } +# test-only dependencies +# dsc-lib-jsonschema +pretty_assertions = { version = "1.4.1" } + # Workspace crates as dependencies dsc-lib = { path = "lib/dsc-lib" } +dsc-lib-jsonschema = { path = "lib/dsc-lib-jsonschema" } dsc-lib-osinfo = { path = "lib/dsc-lib-osinfo" } dsc-lib-security_context = { path = "lib/dsc-lib-security_context" } tree-sitter-dscexpression = { path = "grammars/tree-sitter-dscexpression" } diff --git a/build.data.psd1 b/build.data.psd1 index 25cd55930..6b092b6d0 100644 --- a/build.data.psd1 +++ b/build.data.psd1 @@ -244,6 +244,12 @@ ) } } + @{ + Name = 'dsc-lib-jsonschema' + RelativePath = 'lib/dsc-lib-jsonschema' + Kind = 'Library' + IsRust = $true + } @{ Name = 'dsc-lib' RelativePath = 'lib/dsc-lib' diff --git a/build.ps1 b/build.ps1 index ebe922238..bb22c7420 100755 --- a/build.ps1 +++ b/build.ps1 @@ -356,6 +356,7 @@ if (!$SkipBuild) { ".", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", + "lib/dsc-lib-jsonschema", "lib/dsc-lib-security_context", "lib/dsc-lib-osinfo", "lib/dsc-lib", diff --git a/lib/dsc-lib-jsonschema/Cargo.toml b/lib/dsc-lib-jsonschema/Cargo.toml new file mode 100644 index 000000000..e76aa8afb --- /dev/null +++ b/lib/dsc-lib-jsonschema/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "dsc-lib-jsonschema" +version = "0.0.0" # version stays 0.0.0 until we're ready to publish - should pin to dsc-lib. +edition = "2024" + +[lib] +doctest = false # Disable doc tests for compilation speed + +[dependencies] +regex = { workspace = true } +rust-i18n = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# Helps review complex comparisons, like schemas +pretty_assertions = { workspace = true } + +[lints.clippy] +pedantic = { level = "deny" } diff --git a/lib/dsc-lib-jsonschema/locales/en-us.toml b/lib/dsc-lib-jsonschema/locales/en-us.toml new file mode 100644 index 000000000..00a721fbb --- /dev/null +++ b/lib/dsc-lib-jsonschema/locales/en-us.toml @@ -0,0 +1,114 @@ +_version = 1 + +[transforms.idiomaticize_externally_tagged_enum] +applies_to = "invalid application of idiomaticize_externally_tagged_enum; missing 'oneOf' keyword in transforming schema: %{transforming_schema}" +oneOf_array = "invalid application of idiomaticize_externally_tagged_enum; 'oneOf' isn't an array in transforming schema: %{transforming_schema}" +oneOf_item_as_object = """ +invalid application of idiomaticize_externally_tagged_enum; items in 'oneOf' should always be objects, but encountered an invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_define_type = """ +invalid application of idiomaticize_externally_tagged_enum; every entry in oneOf should define the 'type' keyword, but encountered an invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_type_string = """ +invalid application of idiomaticize_externally_tagged_enum; the value for the 'type' keyword should be a string, but was invalid in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_not_object_type = """ +invalid application of idiomaticize_externally_tagged_enum; expected type for 'oneOf' entry to be 'object' but was '%{item_data_type}' in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_properties_missing = """ +invalid application of idiomaticize_externally_tagged_enum; expected each item in oneOf to define an object with properties, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_properties_not_object = """ +invalid application of idiomaticize_externally_tagged_enum; the 'properties' keyword should always be an object, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_properties_entry_not_object = """ +invalid application of idiomaticize_externally_tagged_enum; the property '%{name}' in the 'oneOf' item's 'properties' keyword should always be an object, but was something else in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" + +[transforms.idiomaticize_string_enum] +applies_to = "invalid application of idiomaticize_string_enum; missing 'oneOf' keyword in transforming schema: %{transforming_schema}" +oneOf_array = "invalid application of idiomaticize_string_enum; 'oneOf' isn't an array in transforming schema: %{transforming_schema}" +oneOf_item_as_object = """ +invalid application of idiomaticize_string_enum; items in 'oneOf' should always be objects, but encountered an invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_define_type = """ +invalid application of idiomaticize_string_enum; every entry in oneOf should define the 'type' keyword, but encountered an invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_type_string = """ +invalid application of idiomaticize_string_enum; the value for the 'type' keyword should be a string, but was invalid in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_not_string_type = """ +invalid application of idiomaticize_string_enum; expected type for 'oneOf' entry to be 'string' but was '%{invalid_type}' in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_enum_not_array = """ +invalid application of idiomaticize_string_enum; the 'enum' keyword should always be an array, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_enum_item_not_string = """ +invalid application of idiomaticize_string_enum; every item in the 'enum' keyword should always be an array of strings, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_const_missing = """ +invalid application of idiomaticize_string_enum; documented items in a string enum are generated as subschemas with the 'const' keyword, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" +oneOf_item_const_not_string = """ +invalid application of idiomaticize_string_enum; the value for the 'const' keyword in a string enum should be a string, but encountered invalid item in transforming schema: + +invalid item: %{invalid_item} + +transforming schema: %{transforming_schema} +""" diff --git a/lib/dsc-lib-jsonschema/src/lib.rs b/lib/dsc-lib-jsonschema/src/lib.rs new file mode 100644 index 000000000..7087d61ea --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/lib.rs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Helper library for working with DSC and JSON Schemas. + +use rust_i18n::i18n; + +#[macro_use] +pub mod macros; + +pub mod vscode; +pub mod transforms; + +#[cfg(test)] +mod tests; + +// Enable localization for emitted strings +i18n!("locales", fallback = "en-us"); diff --git a/lib/dsc-lib-jsonschema/src/macros.rs b/lib/dsc-lib-jsonschema/src/macros.rs new file mode 100644 index 000000000..6060ad55e --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/macros.rs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines macros used by the module. + +/// Panics with a translated message. +macro_rules! panic_t { + ($($all:tt)*) => { + panic!("{}", crate::_rust_i18n_t!($($all)*)) + }; +} + +/// Asserts an expression evaluates to true or panics with a translated message. +macro_rules! assert_t { + ($expr:expr, $($tail:tt)*) => { + assert!($expr, "{}", crate::_rust_i18n_t!($($tail)*)) + }; +} diff --git a/lib/dsc-lib-jsonschema/src/tests/mod.rs b/lib/dsc-lib-jsonschema/src/tests/mod.rs new file mode 100644 index 000000000..2f49bec5a --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/mod.rs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines unit tests for [`dsc-lib-jsonschema`]. +//! +//! Instead of defining tests in each of the module files for the crate, we +//! define them in this module to improve compilation times. The tests in this +//! module are for internal code. Do not define tests for public items in this +//! module. Instead, define those tests in the `tests/integration` folder, +//! which forces usage of the crate as a public API. +//! +//! When you define tests in this module, ensure that you mirror the structure +//! of the modules from the rest of the source tree. + +#[cfg(test)] mod transforms; +#[cfg(test)] mod vscode; diff --git a/lib/dsc-lib-jsonschema/src/tests/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/tests/transforms/mod.rs new file mode 100644 index 000000000..596880853 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/transforms/mod.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests for [`dsc-lib-jsonschema::transforms`] diff --git a/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs b/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs new file mode 100644 index 000000000..084dc4f58 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests for [`dsc-lib-jsonschema::vscode`] diff --git a/lib/dsc-lib-jsonschema/src/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/transforms/mod.rs new file mode 100644 index 000000000..e7311cea6 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/mod.rs @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines various functions that implement the [`Transform`] trait for [`schemars`], enabling you +//! modify generated JSON Schemas. +//! +//! [`Transform`]: schemars::transform + +use core::{assert, cmp::PartialEq}; +use std::{ops::Index}; +use schemars::Schema; +use serde_json::{self, json, Map, Value}; + +use crate::vscode::VSCODE_KEYWORDS; + +/// Munges the generated schema for externally tagged enums into an idiomatic object schema. +/// +/// Schemars generates the schema for externally tagged enums as a schema with the `oneOf` +/// keyword where every tag is a different item in the array. Each item defines a type with a +/// single property, requires that property, and disallows specifying any other properties. +/// +/// This transformer returns the schema as a single object schema with each of the tags defined +/// as properties. It sets both the `minProperties` and `maxProperties` keywords to `1`. This +/// is more idiomatic, shorter to read and parse, easier to reason about, and matches the +/// underlying data semantics more accurately. +/// +/// This transformer should _only_ be used on externally tagged enums. You must specify it with the +/// [schemars `transform()` attribute][`transform`]. +/// +/// # Examples +/// +/// The following struct derives [`JsonSchema`] without specifying the [`transform`] attribute +/// with [`idiomaticize_externally_tagged_enum`]: +/// +/// ``` +/// use pretty_assertions::assert_eq; +/// use serde_json; +/// use schemars::{schema_for, JsonSchema, json_schema}; +/// #[derive(JsonSchema)] +/// pub enum ExternallyTaggedEnum { +/// Name(String), +/// Count(f32), +/// } +/// +/// let generated_schema = schema_for!(ExternallyTaggedEnum); +/// let expected_schema = json_schema!({ +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "title": "ExternallyTaggedEnum", +/// "oneOf": [ +/// { +/// "type": "object", +/// "properties": { +/// "Name": { +/// "type": "string" +/// } +/// }, +/// "additionalProperties": false, +/// "required": ["Name"] +/// }, +/// { +/// "type": "object", +/// "properties": { +/// "Count": { +/// "type": "number", +/// "format": "float" +/// } +/// }, +/// "additionalProperties": false, +/// "required": ["Count"] +/// } +/// ] +/// }); +/// assert_eq!(generated_schema, expected_schema); +/// ``` +/// +/// While the derived schema _does_ effectively validate the enum, it's difficult to understand +/// without deep familiarity with JSON Schema. Compare it to the same enum with the +/// [`idiomaticize_externally_tagged_enum`] transform applied: +/// +/// ``` +/// use pretty_assertions::assert_eq; +/// use serde_json; +/// use schemars::{schema_for, JsonSchema, json_schema}; +/// use dsc_lib_jsonschema::transforms::idiomaticize_externally_tagged_enum; +/// +/// #[derive(JsonSchema)] +/// #[schemars(transform = idiomaticize_externally_tagged_enum)] +/// pub enum ExternallyTaggedEnum { +/// Name(String), +/// Count(f32), +/// } +/// +/// let generated_schema = schema_for!(ExternallyTaggedEnum); +/// let expected_schema = json_schema!({ +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "title": "ExternallyTaggedEnum", +/// "type": "object", +/// "properties": { +/// "Name": { +/// "type": "string" +/// }, +/// "Count": { +/// "type": "number", +/// "format": "float" +/// } +/// }, +/// "minProperties": 1, +/// "maxProperties": 1, +/// "additionalProperties": false +/// }); +/// assert_eq!(generated_schema, expected_schema); +/// ``` +/// +/// The transformed schema is shorter, clearer, and idiomatic for JSON Schema draft 2019-09 and +/// later. It validates values as effectively as the default output for an externally tagged +/// enum, but is easier for your users and integrating developers to understand and work +/// with. +/// +/// # Panics +/// +/// This transform panics when called against a generated schema that doesn't define the `oneOf` +/// keyword. Schemars uses the `oneOf` keyword when generating subschemas for externally tagged +/// enums. This transform panics on an invalid application of the transform to prevent unexpected +/// behavior for the schema transformation. This ensures invalid applications are caught during +/// development and CI instead of shipping broken schemas. +/// +/// [`JsonSchema`]: schemars::JsonSchema +/// [`transform`]: derive@schemars::JsonSchema +pub fn idiomaticize_externally_tagged_enum(schema: &mut Schema) { + // First, retrieve the oneOf keyword entries. If this transformer was called against an invalid + // schema or subschema, it should fail fast. + let one_ofs = schema.get("oneOf") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.applies_to", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )) + .as_array() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_array", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )); + // Initialize the map of properties to fill in when introspecting on the items in the oneOf array. + let mut properties_map = Map::new(); + + for item in one_ofs { + let item_data: Map = item.as_object() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_as_object", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(item).unwrap() + )) + .clone(); + // If we're accidentally operating on an invalid schema, short-circuit. + let item_data_type = item_data.get("type") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_define_type", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )) + .as_str() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_type_string", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )); + assert_t!( + !item_data_type.ne("object"), + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_not_object_type", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap(), + invalid_type = item_data_type + ); + // Retrieve the title and description from the top-level of the item, if any. Depending on + // the implementation, these values might be set on the item, in the property, or both. + let item_title = item_data.get("title"); + let item_desc = item_data.get("description"); + // Retrieve the property definitions. There should never be more than one property per item, + // but this implementation doesn't guard against that edge case.. + let properties_data = item_data.get("properties") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_missing", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap(), + )) + .as_object() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_not_object", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap(), + )) + .clone(); + for property_name in properties_data.keys() { + // Retrieve the property definition to munge as needed. + let mut property_data = properties_data.get(property_name) + .unwrap() // can't fail because we're iterating on keys in the map + .as_object() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_entry_not_object", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap(), + name = property_name + )) + .clone(); + // Process the annotation keywords. If they are defined on the item but not the property, + // insert the item-defined keywords into the property data. + if let Some(t) = item_title && property_data.get("title").is_none() { + property_data.insert("title".into(), t.clone()); + } + if let Some(d) = item_desc && property_data.get("description").is_none() { + property_data.insert("description".into(), d.clone()); + } + for keyword in VSCODE_KEYWORDS { + if let Some(keyword_value) = item_data.get(keyword) && property_data.get(keyword).is_none() { + property_data.insert(keyword.to_string(), keyword_value.clone()); + } + } + // Insert the processed property into the top-level properties definition. + properties_map.insert(property_name.into(), serde_json::Value::Object(property_data)); + } + } + // Replace the oneOf array with an idiomatic object schema definition + schema.remove("oneOf"); + schema.insert("type".to_string(), json!("object")); + schema.insert("minProperties".to_string(), json!(1)); + schema.insert("maxProperties".to_string(), json!(1)); + schema.insert("additionalProperties".to_string(), json!(false)); + schema.insert("properties".to_string(), properties_map.into()); +} + +/// Munges the generated schema for enums that only define string variants into an idiomatic string +/// schema. +/// +/// When an enum defines string variants without documenting any of the variants, Schemars correctly +/// generates the schema as a `string` subschema with the `enum` keyword. However, if you define any +/// documentation keywords for any variants, Schemars generates the schema with the `oneOf` keyword +/// where every variant is a different item in the array. Each item defines a type with a constant +/// string value, and all annotation keywords for that variant. +/// +/// This transformer returns the schema as a single string schema with each of the variants defined +/// as an item in the `enum` keyword. It hoists the per-variant documentation to the extended +/// keywords recognized by VS Code: `enumDescriptions` and `enumMarkdownDescriptions`. This is more +/// idiomatic, shorter to read and parse, easier to reason about, and matches the underlying data +/// semantics more accurately. +/// +/// # Examples +/// +/// The following struct derives [`JsonSchema`] without specifying the [`transform`] attribute +/// with [`idiomaticize_string_enum`]: +/// +/// ```rust +/// use pretty_assertions::assert_eq; +/// use serde_json; +/// use schemars::{schema_for, JsonSchema, json_schema}; +/// +/// #[derive(JsonSchema)] +/// #[serde(rename_all="camelCase")] +/// enum StringEnum { +/// /// # foo-title +/// /// +/// ///foo-description +/// Foo, +/// /// # bar-title +/// /// +/// /// bar-description +/// Bar, +/// /// # baz-title +/// /// +/// /// baz-description +/// Baz +/// } +/// +/// let generated_schema = schema_for!(StringEnum); +/// let expected_schema = json_schema!({ +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "title": "StringEnum", +/// "oneOf": [ +/// { +/// "type": "string", +/// "const": "foo", +/// "title": "foo-title", +/// "description": "foo-description" +/// }, +/// { +/// "type": "string", +/// "const": "bar", +/// "title": "bar-title", +/// "description": "bar-description", +/// }, +/// { +/// "type": "string", +/// "const": "baz", +/// "title": "baz-title", +/// "description": "baz-description", +/// } +/// ], +/// }); +/// assert_eq!(generated_schema, expected_schema); +/// ``` +/// +/// While the derived schema _does_ effectively validate the enum, it's difficult to understand +/// without deep familiarity with JSON Schema. Compare it to the same enum with the +/// [`idiomaticize_string_enum`] transform applied: +/// +/// ```rust +/// use pretty_assertions::assert_eq; +/// use serde_json; +/// use schemars::{schema_for, JsonSchema, json_schema}; +/// use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; +/// +/// #[derive(JsonSchema)] +/// #[serde(rename_all="camelCase")] +/// #[schemars(transform = idiomaticize_string_enum)] +/// enum StringEnum { +/// /// # foo-title +/// /// +/// ///foo-description +/// Foo, +/// /// # bar-title +/// /// +/// /// bar-description +/// Bar, +/// /// # baz-title +/// /// +/// /// baz-description +/// Baz +/// } +/// +/// let generated_schema = schema_for!(StringEnum); +/// let expected_schema = json_schema!({ +/// "type": "string", +/// "enum": [ +/// "foo", +/// "bar", +/// "baz" +/// ], +/// "enumDescriptions": [ +/// "foo-description", +/// "bar-description", +/// "baz-description", +/// ], +/// "enumMarkdownDescriptions": [ +/// "foo-description", +/// "bar-description", +/// "baz-description", +/// ], +/// "title": "StringEnum", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// }); +/// assert_eq!(generated_schema, expected_schema); +/// ``` +/// +/// # Panics +/// +/// If this transform is applied to a schema that defines the `enum` keyword, it immediately +/// returns without modifying the schema. Otherwise, it checks whether the schema defines the +/// `oneOf` keyword. If the generated schema doesn't define the `oneOf` keyword, this transform +/// panics. +/// +/// Schemars uses the `oneOf` keyword when generating subschemas for string enums with annotation +/// keywords. This transform panics on an invalid application of the transform to prevent +/// unexpectedbehavior for the schema transformation. This ensures invalid applications are caught +/// during development and CI instead of shipping broken schemas. +/// +/// [`JsonSchema`]: schemars::JsonSchema +/// [`transform`]: derive@schemars::JsonSchema#transform +pub fn idiomaticize_string_enum(schema: &mut Schema) { + #![allow(clippy::too_many_lines)] + // If this transform is called against a schema defining `enums`, there's nothing to do. + if schema.get("enum").is_some() { + return; + } + // First, retrieve the oneOf keyword entries. If this transformer was called against an invalid + // schema or subschema, it should fail fast. + let one_ofs = schema.get("oneOf") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.applies_to", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )) + .as_array() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_array", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )); + // Initialize the vectors for enums, their descriptions, and their markdown descriptions. + let mut enums: Vec = Vec::with_capacity(one_ofs.len()); + let mut enum_descriptions: Vec = Vec::with_capacity(one_ofs.len()); + let mut enum_markdown_descriptions: Vec = Vec::with_capacity(one_ofs.len()); + + // Iterate over the enums to add to the holding vectors. + for (index, item) in one_ofs.iter().enumerate() { + let item_data = item.as_object() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_as_object", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(item).unwrap() + )) + .clone(); + // If we're accidentally operating on an invalid schema, short-circuit. + let item_data_type = item_data.get("type") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_define_type", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )) + .as_str() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_type_string", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )); + assert_t!( + !item_data_type.ne("string"), + "transforms.idiomaticize_string_enum.oneOf_item_not_string_type", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap(), + invalid_type = item_data_type + ); + // Retrieve the title, description, and markdownDescription from the item, if any. + let item_title = item_data.get("title").and_then(|v| v.as_str()); + let item_desc = item_data.get("description").and_then(|v| v.as_str()); + let item_md_desc = item_data.get("markdownDescription").and_then(|v| v.as_str()); + // Retrieve the value for the enum - schemars emits as a `const` for each item that has + // docs, and an enum with a single value for non-documented enums. + let item_enum: &str; + if let Some(item_enum_value) = item_data.get("enum") { + item_enum = item_enum_value.as_array() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_enum_not_array", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )) + .index(0) + .as_str() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_enum_item_not_string", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )); + } else { + item_enum = item_data.get("const") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_const_missing", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )) + .as_str() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_string_enum.oneOf_item_const_not_string", + transforming_schema = serde_json::to_string_pretty(schema).unwrap(), + invalid_item = serde_json::to_string_pretty(&item_data).unwrap() + )); + } + + enums.insert(index, item_enum.to_string()); + + // Define the enumDescription entry as description with title as fallback. If neither + // keyword is defined, add as an empty string. + let desc = match item_desc { + Some(d) => d, + None => item_title.unwrap_or_default(), + }; + enum_descriptions.insert(index, desc.to_string()); + // Define the enumMarkdownDescription entry as markdownDescription with description + // then title as fallback. If none of the keywords are defined, add as an empty string. + let md_desc = match item_md_desc { + Some(d) => d, + None => desc, + }; + enum_markdown_descriptions.insert(index, md_desc.to_string()); + } + // Replace the oneOf array with an idiomatic object schema definition + schema.remove("oneOf"); + schema.insert("type".to_string(), json!("string")); + schema.insert("enum".to_string(), serde_json::to_value(enums).unwrap()); + if enum_descriptions.iter().any(|e| !e.is_empty()) { + schema.insert( + "enumDescriptions".to_string(), + serde_json::to_value(enum_descriptions).unwrap() + ); + } + if enum_markdown_descriptions.iter().any(|e| !e.is_empty()) { + schema.insert( + "enumMarkdownDescriptions".to_string(), + serde_json::to_value(enum_markdown_descriptions).unwrap() + ); + } +} diff --git a/lib/dsc-lib-jsonschema/src/vscode/mod.rs b/lib/dsc-lib-jsonschema/src/vscode/mod.rs new file mode 100644 index 000000000..e5e101d7a --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/vscode/mod.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Provides helpers for working with JSON Schemas and VS Code. + +/// Defines the available keywords for VS Code's extended vocabulary. +/// +/// These keywords are annotation keywords that don't change the validation processing, so any +/// consumer of a schema using these keywords can safely ignore them if it doesn't understand +/// the keywords. +/// +/// The transformers and generators in this library strip the VS Code keywords from canonical +/// schemas, as they are primarily for improving the development experience in a code editor, not +/// machine processing. Removing them from the canonical schemas makes the canonical schemas +/// smaller and more compatible, as some JSON Schema implementations may error on unrecognized +/// keywords instead of ignoring them. +pub const VSCODE_KEYWORDS: [&str; 11] = [ + "defaultSnippets", + "errorMessage", + "patternErrorMessage", + "deprecationMessage", + "enumDescriptions", + "markdownEnumDescriptions", + "markdownDescription", + "doNotSuggest", + "suggestSortText", + "allowComments", + "allowTrailingCommas", +]; diff --git a/lib/dsc-lib-jsonschema/tests/integration/main.rs b/lib/dsc-lib-jsonschema/tests/integration/main.rs new file mode 100644 index 000000000..c114d89f7 --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/main.rs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines integration tests for [`dsc-lib-jsonschema`]. +//! +//! Instead of defining tests in each of the module files for the crate, we +//! define them here as integration tests to improve compilation times. +//! +//! The tests in this module are for public code. The tests should validate +//! expected behaviors at the public API level. Don't add tests to this module +//! for inner code behaviors. +//! +//! We organize the tests in the `tests/integration` folder instead of directly +//! in `tests` to minimize compilation times. If we defined the tests one level +//! higher in the `tests` folder, Rust would generate numerous binaries to +//! execute our tests. + +#[cfg(test)] mod transforms; diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/externally_tagged.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/externally_tagged.rs new file mode 100644 index 000000000..ab7e2e79d --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/externally_tagged.rs @@ -0,0 +1,521 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the [`idiomaticize_externally_tagged_enum`] +//! transform. Validates behavior when called on an externally tagged enum with various +//! levels and methods of documentation. +//! +//! [`idiomaticize_externally_tagged_enum`]: crate::transforms::idiomaticize_externally_tagged_enum + +use pretty_assertions::assert_eq as assert_pretty_eq; +use schemars::{schema_for, JsonSchema, json_schema}; + +use dsc_lib_jsonschema::transforms::idiomaticize_externally_tagged_enum; + +/// Defines an externally tagged enum where each variant maps to a different type. This +/// enum includes every supported documentation keyword for the enum and each variant. +#[allow(dead_code)] +#[derive(JsonSchema)] +#[serde(rename_all="camelCase")] +#[schemars( + title = "enum-title", + description = "enum-description", + extend("markdownDescription" = "enum-markdown") +)] +enum ExternallyTaggedEnum { + /// String variant + #[schemars( + title = "string-variant-title", + description = "string-variant-description", + extend("markdownDescription" = "string-variant-markdown") + )] + String(String), + /// Integer variant + #[schemars( + title = "integer-variant-title", + description = "integer-variant-description", + extend("markdownDescription" = "integer-variant-markdown") + )] + Integer(i64), + /// Boolean variant + #[schemars( + title = "boolean-variant-title", + description = "boolean-variant-description", + extend("markdownDescription" = "boolean-variant-markdown") + )] + Boolean(bool), +} + +/// Checks the expected structure of an externally tagged enum's schema _without_ the +/// idiomaticizing transform. This helps ensure we can catch any cases where +/// [`schemars`] updates the default schema generated for externally tagged enums. +#[test] fn externally_tagged_enum_without_tranform() { + let ref schema = schema_for!(ExternallyTaggedEnum); + let ref expected = json_schema!({ + "oneOf": [ + { + "type": "object", + "required": ["string"], + "additionalProperties": false, + "properties": { + "string": { + "type": "string", + } + }, + "title": "string-variant-title", + "description": "string-variant-description", + "markdownDescription": "string-variant-markdown" + }, + { + "required": ["integer"], + "additionalProperties": false, + "properties": { + "integer": { + "type": "integer", + "format": "int64", + } + }, + "type": "object", + "title": "integer-variant-title", + "description": "integer-variant-description", + "markdownDescription": "integer-variant-markdown" + }, + { + "required": ["boolean"], + "additionalProperties": false, + "properties": { + "boolean": { + "type": "boolean", + } + }, + "type": "object", + "title": "boolean-variant-title", + "description": "boolean-variant-description", + "markdownDescription": "boolean-variant-markdown" + }, + ], + "title": "enum-title", + "description": "enum-description", + "markdownDescription": "enum-markdown", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the expected structure after using [`idiomaticize_externally_tagged_enum`] +/// to convert the structure of the generated schema to an idiomatic representation. +#[test] fn externally_tagged_enum_idiomaticized() { + let ref mut schema = schema_for!(ExternallyTaggedEnum); + idiomaticize_externally_tagged_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "enum-title", + "description": "enum-description", + "markdownDescription": "enum-markdown", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false, + "properties": { + "string": { + "type": "string", + "title": "string-variant-title", + "description": "string-variant-description", + "markdownDescription": "string-variant-markdown" + }, + "integer": { + "type": "integer", + "format": "int64", + "title": "integer-variant-title", + "description": "integer-variant-description", + "markdownDescription": "integer-variant-markdown" + }, + "boolean": { + "type": "boolean", + "title": "boolean-variant-title", + "description": "boolean-variant-description", + "markdownDescription": "boolean-variant-markdown" + } + } + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_externally_tagged_enum`] when the defined +/// `enum` doesn't use any documentation annotation keywords. +#[test] fn externally_tagged_enum_without_any_docs_idiomaticized() { + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + String(String), + Integer(i64), + Boolean(bool), + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_externally_tagged_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestingEnum", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false, + "properties": { + "string": { "type": "string" }, + "integer": { "type": "integer", "format": "int64" }, + "boolean": { "type": "boolean" } + } + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_externally_tagged_enum`] when the defined +/// `enum` uses Rust documentation strings to document each variant. +#[test] fn externally_tagged_enum_with_rust_docs_idiomaticized() { + /// # testing-enum-title + /// + /// testing-enum-description + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + /// # string-variant-title + /// + /// string-variant-description + String(String), + /// # integer-variant-title + /// + /// integer-variant-description + Integer(i64), + /// # boolean-variant-title + /// + /// boolean-variant-description + Boolean(bool), + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_externally_tagged_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "testing-enum-title", + "description": "testing-enum-description", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false, + "properties": { + "string": { + "title": "string-variant-title", + "description": "string-variant-description", + "type": "string" + }, + "integer": { + "title": "integer-variant-title", + "description": "integer-variant-description", + "type": "integer", "format": "int64" + }, + "boolean": { + "title": "boolean-variant-title", + "description": "boolean-variant-description", + "type": "boolean" + } + } + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_externally_tagged_enum`] when the defined +/// `enum` uses Rust documentation strings _and_ [`schemars`] attributes to provide +/// documentation annotations. +#[test] fn externally_tagged_enum_with_varied_docs_idiomaticized() { + /// # testing-enum-title + /// + /// testing-enum-description + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + #[schemars(extend("markdownDescription" = "testing-enum-markdown"))] + enum TestingEnum { + /// string-variant-description + #[schemars( + title = "string-variant-title", + extend("markdownDescription"="string-variant-markdown") + )] + String(String), + /// # integer-variant-title + #[schemars( + description = "integer-variant-description", + extend("markdownDescription" = "integer-variant-markdown") + )] + Integer(i64), + #[schemars(title = "boolean-variant-title")] + Boolean(bool), + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_externally_tagged_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "testing-enum-title", + "description": "testing-enum-description", + "markdownDescription": "testing-enum-markdown", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false, + "properties": { + "string": { + "title": "string-variant-title", + "description": "string-variant-description", + "markdownDescription": "string-variant-markdown", + "type": "string" + }, + "integer": { + "title": "integer-variant-title", + "description": "integer-variant-description", + "markdownDescription": "integer-variant-markdown", + "type": "integer", "format": "int64" + }, + "boolean": { + "title": "boolean-variant-title", + "type": "boolean" + } + } + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_externally_tagged_enum`] when the defined +/// `enum` uses Rust documentation strings to document only a subset of variants. +#[test] fn externally_tagged_enum_with_some_missing_docs_idiomaticized() { + /// # testing-enum-title + /// + /// testing-enum-description + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + /// string-variant-description + String(String), + #[schemars(title = "integer-variant-title")] + Integer(i64), + Boolean(bool), + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_externally_tagged_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "testing-enum-title", + "description": "testing-enum-description", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false, + "properties": { + "string": { + "description": "string-variant-description", + "type": "string" + }, + "integer": { + "title": "integer-variant-title", + "type": "integer", "format": "int64" + }, + "boolean": { + "type": "boolean" + } + } + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +#[test] #[should_panic] fn panics_when_schema_missing_oneof_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_non_array_one_of_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": { + "type": "object" + } + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_as_non_object() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + "non-object" + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_without_type_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_string_type_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "type": ["object", "null"], + "required": ["bar"], + "properties": { + "bar": { + "type": "string" + } + } + }, + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_object_type() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "type": "string", + "const": "bar" + }, + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_without_properties_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "type": "object", + "required": ["bar"], + }, + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_property_as_non_object() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "type": "object", + "required": ["bar"], + "properties": { + "bar": "invalid" + } + }, + ] + }); + + idiomaticize_externally_tagged_enum(transforming_schema); +} diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/mod.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/mod.rs new file mode 100644 index 000000000..71958fe2d --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/mod.rs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for idiomaticizing the generated schemas for `enum` items. + +#[cfg(test)] mod string_variants; +#[cfg(test)] mod externally_tagged; diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/string_variants.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/string_variants.rs new file mode 100644 index 000000000..f60100032 --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/enums/string_variants.rs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the [`idiomaticize_string_enum`] +//! transform. Validates behavior when called on an externally tagged enum with various +//! levels and methods of documentation. +//! +//! [`idiomaticize_string_enum`]: crate::transforms::idiomaticize_string_enum + +use pretty_assertions::assert_eq as assert_pretty_eq; +use schemars::{schema_for, JsonSchema, json_schema}; + +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; + +/// Defines an enum where each variant maps to a string value. This enum includes every +/// supported documentation keyword for the enum and each variant. +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars( + title="enum-title", + description="enum-description", + extend("markdownDescription" = "enum-markdown") +)] +#[serde(rename_all="camelCase")] +enum StringEnum { + #[schemars( + title="foo-title", + description="foo-description", + extend("markdownDescription"="foo-markdown") + )] + Foo, + #[schemars( + title="bar-title", + description="bar-description", + extend("markdownDescription"="bar-markdown") + )] + Bar, + #[schemars( + title="baz-title", + description="baz-description", + extend("markdownDescription"="baz-markdown") + )] + Baz +} + +/// Checks the expected structure of a string enum's schema _without_ the idiomaticizing +/// transform. This helps ensure we can catch any cases where [`schemars`] updates the default +/// schema generated for string enums. +#[test] fn string_enum_without_tranform() { + let ref schema = schema_for!(StringEnum); + let ref expected = json_schema!({ + "oneOf": [ + { + "type": "string", + "const": "foo", + "title": "foo-title", + "description": "foo-description", + "markdownDescription": "foo-markdown" + }, + { + "type": "string", + "const": "bar", + "title": "bar-title", + "description": "bar-description", + "markdownDescription": "bar-markdown" + }, + { + "type": "string", + "const": "baz", + "title": "baz-title", + "description": "baz-description", + "markdownDescription": "baz-markdown" + } + ], + "title": "enum-title", + "description": "enum-description", + "markdownDescription": "enum-markdown", + "$schema": "https://json-schema.org/draft/2020-12/schema" + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} +/// Checks the expected structure after using the [`idiomaticize_string_enum`] function to +/// convert the structure of the generated schema to an idiomatic representation. +#[test] fn string_enum_idiomaticized() { + let ref mut schema = schema_for!(StringEnum); + idiomaticize_string_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "enum-title", + "description": "enum-description", + "type": "string", + "markdownDescription": "enum-markdown", + "enum": ["foo", "bar", "baz"], + "enumDescriptions": ["foo-description", "bar-description", "baz-description"], + "enumMarkdownDescriptions": ["foo-markdown", "bar-markdown", "baz-markdown"] + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_string_enum`] when the defined `enum` doesn't use +/// any documentation annotation keywords. +#[test] fn string_enum_without_any_docs_idiomaticized() { + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + Foo, + Bar, + Baz + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_string_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestingEnum", + "type": "string", + "enum": ["foo", "bar", "baz"] + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_string_enum`] when the defined `enum` uses Rust +/// documentation strings to document each variant. +#[test] fn string_enum_with_rust_docs_idiomaticized() { + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + /// Foo-description + Foo, + /// Bar-description + Bar, + /// Baz-description + Baz + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_string_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestingEnum", + "type": "string", + "enum": ["foo", "bar", "baz"], + "enumDescriptions": ["Foo-description", "Bar-description", "Baz-description"], + "enumMarkdownDescriptions": ["Foo-description", "Bar-description", "Baz-description"], + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_string_enum`] when the defined `enum` uses Rust +/// documentation strings _and_ [`schemars`] attributes to provide documentation annotations. +#[test] fn string_enum_with_varied_docs_idiomaticized() { + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + /// Foo-description + #[schemars(title="Foo-title", extend("markdownDescription"="Foo-markdown"))] + Foo, + #[schemars( + title="Bar-title", extend("markdownDescription"="Bar-markdown"))] + Bar, + #[schemars(title="Baz-title")] + Baz + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_string_enum(schema); + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestingEnum", + "type": "string", + "enum": ["foo", "bar", "baz"], + "enumDescriptions": ["Foo-description", "Bar-title", "Baz-title"], + "enumMarkdownDescriptions": ["Foo-markdown", "Bar-markdown", "Baz-title"], + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +/// Checks the behavior for [`idiomaticize_string_enum`] when the defined `enum` uses Rust +/// documentation strings to document only a subset of variants. +#[test] fn string_enum_with_some_missing_docs_idiomaticized() { + #[allow(dead_code)] + #[derive(JsonSchema)] + #[serde(rename_all="camelCase")] + enum TestingEnum { + /// Foo-description + Foo, + /// Bar-description + Bar, + Baz + } + + let ref mut schema = schema_for!(TestingEnum); + idiomaticize_string_enum(schema); + // Note that for some reason, non-documented items go before documented + // ones when generating the schema. + let ref expected = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TestingEnum", + "type": "string", + "enum": ["baz", "foo", "bar"], + "enumDescriptions": ["", "Foo-description", "Bar-description"], + "enumMarkdownDescriptions": ["", "Foo-description", "Bar-description"], + }); + assert_pretty_eq!( + serde_json::to_string_pretty(schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +#[test] #[should_panic] fn panics_when_schema_missing_oneof_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_non_array_one_of_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": { + "type": "object" + } + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_as_non_object() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + "non-object" + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_without_type_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "const": "foo" + } + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_string_type_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": ["string", "null"], + "const": "foo" + } + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_string_type() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "object", + "required": ["foo"], + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "type": "string", + "const": "bar" + }, + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_array_enum_keyword() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "string", + "enum": "foo" + }, + { + "type": "string", + "const": "bar" + }, + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_string_enum_item() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "string", + "enum": [false] + }, + { + "type": "string", + "const": "bar" + }, + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_without_enum_or_const() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "string", + }, + ] + }); + + idiomaticize_string_enum(transforming_schema); +} + +#[test] #[should_panic] fn panics_when_schema_has_one_of_item_with_non_string_const() { + let ref mut transforming_schema = json_schema!({ + "type": "object", + "oneOf": [ + { + "type": "string", + "const": false + }, + ] + }); + + idiomaticize_string_enum(transforming_schema); +} diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/mod.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/mod.rs new file mode 100644 index 000000000..9b7c511ff --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticizing/mod.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for idiomaticizing the generated schemas. The schemas that [`schemars`] +//! generates are sometimes non-idiomatic, especially when you use annotation keywords for variants +//! and fields. + +#[cfg(test)] mod enums; diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs new file mode 100644 index 000000000..f50483bed --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for [`dsc-lib-jsonschema::transforms`]. This module defines functions that +//! a user can add with the `#[schemars(transform = )]` attribute to modify the +//! generated schema. + +#[cfg(test)] mod idiomaticizing; From 39fb18be3cc9fab8e340343e7cdaeb1f9e6dfd86 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 9 Oct 2025 15:30:17 -0500 Subject: [PATCH 2/4] (GH-538) Idiomaticize schemas for enums in `dsc-lib` This change uses the new `idiomaticize_*` transformers from the `dsc-lib-jsonschema` crate in the `dsc-lib` crate to ensure that the generated schemas are idiomatic. --- Cargo.lock | 1 + lib/dsc-lib/Cargo.toml | 1 + lib/dsc-lib/src/configure/config_doc.rs | 10 ++ lib/dsc-lib/src/configure/config_progress.rs | 3 +- lib/dsc-lib/src/configure/config_result.rs | 2 + .../src/discovery/command_discovery.rs | 2 + lib/dsc-lib/src/dscresources/dscresource.rs | 2 + .../src/dscresources/resource_manifest.rs | 5 + lib/dsc-lib/src/extensions/dscextension.rs | 2 + lib/dsc-lib/tests/integration/main.rs | 16 ++ lib/dsc-lib/tests/integration/schemas/mod.rs | 6 + .../tests/integration/schemas/schema_for.rs | 140 ++++++++++++++++++ 12 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 lib/dsc-lib/tests/integration/main.rs create mode 100644 lib/dsc-lib/tests/integration/schemas/mod.rs create mode 100644 lib/dsc-lib/tests/integration/schemas/schema_for.rs diff --git a/Cargo.lock b/Cargo.lock index c886c6a84..2042879b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -658,6 +658,7 @@ dependencies = [ "chrono", "clap", "derive_builder", + "dsc-lib-jsonschema", "dsc-lib-osinfo", "dsc-lib-security_context", "indicatif", diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index 17025fade..bc0bea436 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -43,6 +43,7 @@ which = { workspace = true } # workspace crate dependencies dsc-lib-osinfo = { workspace = true } dsc-lib-security_context = { workspace = true } +dsc-lib-jsonschema = { workspace = true } tree-sitter-dscexpression = { workspace = true } [dev-dependencies] diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index 6bbf6c0b2..ef4ac5773 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -2,6 +2,10 @@ // Licensed under the MIT License. use chrono::{DateTime, Local}; +use dsc_lib_jsonschema::transforms::{ + idiomaticize_externally_tagged_enum, + idiomaticize_string_enum +}; use rust_i18n::t; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Serialize}; @@ -12,6 +16,7 @@ use crate::{dscerror::DscError, schemas::DscRepoSchema}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum SecurityContextKind { Current, Elevated, @@ -20,6 +25,7 @@ pub enum SecurityContextKind { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum Operation { Get, Set, @@ -29,6 +35,7 @@ pub enum Operation { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum ExecutionKind { Actual, WhatIf, @@ -43,6 +50,7 @@ pub struct Process { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_externally_tagged_enum)] pub enum RestartRequired { System(String), Service(String), @@ -190,6 +198,7 @@ pub struct Parameter { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[schemars(transform = idiomaticize_string_enum)] pub enum DataType { #[serde(rename = "string")] String, @@ -223,6 +232,7 @@ impl Display for DataType { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[schemars(transform = idiomaticize_string_enum)] pub enum CopyMode { #[serde(rename = "serial")] Serial, diff --git a/lib/dsc-lib/src/configure/config_progress.rs b/lib/dsc-lib/src/configure/config_progress.rs index 42ef494dc..9501038fb 100644 --- a/lib/dsc-lib/src/configure/config_progress.rs +++ b/lib/dsc-lib/src/configure/config_progress.rs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. - +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -13,6 +13,7 @@ pub struct ConfigurationResourceStartedEvent { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum ConfigurationResourceCompletionStatus { Success, Failure, diff --git a/lib/dsc-lib/src/configure/config_result.rs b/lib/dsc-lib/src/configure/config_result.rs index b5396ab2c..e5207101e 100644 --- a/lib/dsc-lib/src/configure/config_result.rs +++ b/lib/dsc-lib/src/configure/config_result.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -9,6 +10,7 @@ use crate::configure::config_doc::{Configuration, Metadata}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum MessageLevel { Error, Warning, diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index 9cba08498..221a5a6a0 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -13,6 +13,7 @@ use crate::extensions::dscextension::{self, DscExtension, Capability as Extensio use crate::extensions::extension_manifest::ExtensionManifest; use crate::progress::{ProgressBar, ProgressFormat}; use crate::util::convert_wildcard_to_regex; +use dsc_lib_jsonschema::transforms::idiomaticize_externally_tagged_enum; use regex::RegexBuilder; use rust_i18n::t; use semver::{Version, VersionReq}; @@ -40,6 +41,7 @@ static EXTENSIONS: LazyLock>> = LazyLock:: static ADAPTED_RESOURCES: LazyLock>>> = LazyLock::new(|| RwLock::new(BTreeMap::new())); #[derive(Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(transform = idiomaticize_externally_tagged_enum)] pub enum ImportedManifest { Resource(DscResource), Extension(DscExtension), diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 07d2b470e..e38443b8d 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -3,6 +3,7 @@ use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}}, dscresources::resource_manifest::{AdapterInputKind, Kind}}; use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse}; +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; use dscerror::DscError; use jsonschema::Validator; use rust_i18n::t; @@ -59,6 +60,7 @@ pub struct DscResource { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum Capability { /// The resource supports retrieving configuration. Get, diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index 34d3f070a..6eecbcbf6 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; use rust_i18n::t; use schemars::{Schema, JsonSchema, json_schema}; use semver::Version; @@ -12,6 +13,7 @@ use crate::{dscerror::DscError, schemas::DscRepoSchema}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum Kind { Adapter, Exporter, @@ -94,6 +96,7 @@ pub enum ArgKind { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[schemars(transform = idiomaticize_string_enum)] pub enum InputKind { /// The input is accepted as environmental variables. #[serde(rename = "env")] @@ -122,6 +125,7 @@ pub struct SchemaCommand { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[schemars(transform = idiomaticize_string_enum)] pub enum ReturnKind { /// The return JSON is the state of the resource. #[serde(rename = "state")] @@ -224,6 +228,7 @@ pub struct Adapter { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[schemars(transform = idiomaticize_string_enum)] pub enum AdapterInputKind { /// The adapter accepts full unprocessed configuration. #[serde(rename = "full")] diff --git a/lib/dsc-lib/src/extensions/dscextension.rs b/lib/dsc-lib/src/extensions/dscextension.rs index d7b01c5d3..a25389644 100644 --- a/lib/dsc-lib/src/extensions/dscextension.rs +++ b/lib/dsc-lib/src/extensions/dscextension.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use dsc_lib_jsonschema::transforms::idiomaticize_string_enum; use serde::{Deserialize, Serialize}; use serde_json::Value; use schemars::JsonSchema; @@ -33,6 +34,7 @@ pub struct DscExtension { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] +#[schemars(transform = idiomaticize_string_enum)] pub enum Capability { /// The extension aids in discovering resources. Discover, diff --git a/lib/dsc-lib/tests/integration/main.rs b/lib/dsc-lib/tests/integration/main.rs new file mode 100644 index 000000000..a203d38a9 --- /dev/null +++ b/lib/dsc-lib/tests/integration/main.rs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines integration tests for [`dsc-lib`]. +//! +//! Instead of defining tests in each of the module files for the crate, we define them here as +//! integration tests to improve compilation times. +//! +//! The tests in this module are for public code. The tests should validate expected behaviors at +//! the public API level. Don't add tests to this module for inner code behaviors. +//! +//! We organize the tests in the `tests/integration` folder instead of directly in `tests` to +//! minimize compilation times. If we defined the tests one level higher in the `tests` folder, +//! Rust would generate numerous binaries to execute our tests. + +#[cfg(test)] mod schemas; diff --git a/lib/dsc-lib/tests/integration/schemas/mod.rs b/lib/dsc-lib/tests/integration/schemas/mod.rs new file mode 100644 index 000000000..e1abf0e1c --- /dev/null +++ b/lib/dsc-lib/tests/integration/schemas/mod.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines integration tests for generating JSON Schemas for the public types in [`dsc-lib`]. + +#[cfg(test)] mod schema_for; diff --git a/lib/dsc-lib/tests/integration/schemas/schema_for.rs b/lib/dsc-lib/tests/integration/schemas/schema_for.rs new file mode 100644 index 000000000..55339296c --- /dev/null +++ b/lib/dsc-lib/tests/integration/schemas/schema_for.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! These integration tests ensure that we can call [`schemars::schema_for`] against the items +//! defined in [`dsc-lib`] without raising panics when using transform functions to munge the +//! generated schemas. + +// use test_case::test_case; + +/// Generates a test function that ensures calling [`schemars::schema_for`] on the given type does +/// not panic. Helps ensure that we use the transforms correctly. +macro_rules! test_schema_for { + ($head:ident $(:: $tail:tt)+) => { + test_schema_for!( $head :: ; $($tail),* ); + }; + + ($($module:ident ::)+ ; $type:ident) => { + #[test] fn $type() { + use schemars::schema_for; + schema_for!($($module ::)+ $type); + } + }; + + ($($module:ident ::)+ ; $head:ident , $($tail:ident),+) => { + test_schema_for!( $($module ::)* $head :: ; $($tail),* ); + }; +} + +#[allow(non_snake_case)] +#[cfg(test)] mod dsc_lib { + #[cfg(test)] mod configure { + #[cfg(test)] mod config_doc { + test_schema_for!(dsc_lib::configure::config_doc::SecurityContextKind); + test_schema_for!(dsc_lib::configure::config_doc::Operation); + test_schema_for!(dsc_lib::configure::config_doc::ExecutionKind); + test_schema_for!(dsc_lib::configure::config_doc::Process); + test_schema_for!(dsc_lib::configure::config_doc::RestartRequired); + test_schema_for!(dsc_lib::configure::config_doc::MicrosoftDscMetadata); + test_schema_for!(dsc_lib::configure::config_doc::Metadata); + test_schema_for!(dsc_lib::configure::config_doc::UserFunction); + test_schema_for!(dsc_lib::configure::config_doc::UserFunctionDefinition); + test_schema_for!(dsc_lib::configure::config_doc::UserFunctionParameter); + test_schema_for!(dsc_lib::configure::config_doc::UserFunctionOutput); + test_schema_for!(dsc_lib::configure::config_doc::Configuration); + test_schema_for!(dsc_lib::configure::config_doc::Parameter); + test_schema_for!(dsc_lib::configure::config_doc::DataType); + test_schema_for!(dsc_lib::configure::config_doc::CopyMode); + test_schema_for!(dsc_lib::configure::config_doc::Copy); + test_schema_for!(dsc_lib::configure::config_doc::Plan); + test_schema_for!(dsc_lib::configure::config_doc::Identity); + test_schema_for!(dsc_lib::configure::config_doc::Sku); + test_schema_for!(dsc_lib::configure::config_doc::Resource); + } + #[cfg(test)] mod config_results { + test_schema_for!(dsc_lib::configure::config_result::MessageLevel); + test_schema_for!(dsc_lib::configure::config_result::ResourceMessage); + test_schema_for!(dsc_lib::configure::config_result::ResourceGetResult); + test_schema_for!(dsc_lib::configure::config_result::ConfigurationGetResult); + test_schema_for!(dsc_lib::configure::config_result::ResourceSetResult); + test_schema_for!(dsc_lib::configure::config_result::GroupResourceSetResult); + test_schema_for!(dsc_lib::configure::config_result::ConfigurationSetResult); + test_schema_for!(dsc_lib::configure::config_result::ResourceTestResult); + test_schema_for!(dsc_lib::configure::config_result::GroupResourceTestResult); + test_schema_for!(dsc_lib::configure::config_result::ConfigurationTestResult); + test_schema_for!(dsc_lib::configure::config_result::ConfigurationExportResult); + } + #[cfg(test)] mod parameters { + test_schema_for!(dsc_lib::configure::parameters::Input); + test_schema_for!(dsc_lib::configure::parameters::SecureString); + test_schema_for!(dsc_lib::configure::parameters::SecureObject); + test_schema_for!(dsc_lib::configure::parameters::SecureKind); + } + } + #[cfg(test)] mod discovery { + #[cfg(test)] mod command_discovery { + test_schema_for!(dsc_lib::discovery::command_discovery::ImportedManifest); + } + } + + #[cfg(test)] mod dscresources { + #[cfg(test)] mod dscresource { + test_schema_for!(dsc_lib::dscresources::dscresource::DscResource); + test_schema_for!(dsc_lib::dscresources::dscresource::Capability); + test_schema_for!(dsc_lib::dscresources::dscresource::ImplementedAs); + } + #[cfg(test)] mod invoke_result { + test_schema_for!(dsc_lib::dscresources::invoke_result::GetResult); + test_schema_for!(dsc_lib::dscresources::invoke_result::ResourceGetResponse); + test_schema_for!(dsc_lib::dscresources::invoke_result::SetResult); + test_schema_for!(dsc_lib::dscresources::invoke_result::ResourceSetResponse); + test_schema_for!(dsc_lib::dscresources::invoke_result::TestResult); + test_schema_for!(dsc_lib::dscresources::invoke_result::ResourceTestResponse); + test_schema_for!(dsc_lib::dscresources::invoke_result::ValidateResult); + test_schema_for!(dsc_lib::dscresources::invoke_result::ExportResult); + test_schema_for!(dsc_lib::dscresources::invoke_result::ResolveResult); + } + #[cfg(test)] mod resource_manifest { + test_schema_for!(dsc_lib::dscresources::resource_manifest::Kind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ArgKind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::InputKind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::SchemaKind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::SchemaCommand); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ReturnKind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::GetMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::SetMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::TestMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::DeleteMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ValidateMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ExportMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ResolveMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::Adapter); + test_schema_for!(dsc_lib::dscresources::resource_manifest::AdapterInputKind); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ListMethod); + test_schema_for!(dsc_lib::dscresources::resource_manifest::ResourceManifest); + } + } + + #[cfg(test)] mod extensions { + #[cfg(test)] mod discover { + test_schema_for!(dsc_lib::extensions::discover::DiscoverMethod); + test_schema_for!(dsc_lib::extensions::discover::DiscoverResult); + } + #[cfg(test)] mod dscextension { + test_schema_for!(dsc_lib::extensions::dscextension::DscExtension); + test_schema_for!(dsc_lib::extensions::dscextension::Capability); + } + #[cfg(test)] mod extension_manifest { + test_schema_for!(dsc_lib::extensions::extension_manifest::ExtensionManifest); + } + #[cfg(test)] mod import { + test_schema_for!(dsc_lib::extensions::import::ImportMethod); + test_schema_for!(dsc_lib::extensions::import::ImportArgKind); + } + #[cfg(test)] mod secret { + test_schema_for!(dsc_lib::extensions::secret::SecretArgKind); + test_schema_for!(dsc_lib::extensions::secret::SecretMethod); + } + } + +} From 4a5117e5aa68dfd8c219704692f9c916804461d7 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Sat, 11 Oct 2025 13:46:27 -0500 Subject: [PATCH 3/4] (MAINT) Update i18n tests for `panic_t!` and `assert_t!` This change updates the i18n Pester tests to account for: - Usage of the `t!` macro when the translation key is defined on the next line. - Usage of the convenience `panic_t!` macro, which simplifies sending translated strings to the `panic!` macro. - Usage of the convenience `assert_t!` macro, which simplifies sending translated strings to the panic when an `assert!` macro fails. These use cases were required for the `dsc-lib-jsonschema` crate, which uses both `panic_t!` and `assert_t!` to raise translated panic messages when a transform is invalidly applied. While this change doesn't modify the behavior for any other crates, it does lay the groundwork for translating panic messages and assertions throughout the crates. --- dsc/tests/dsc_i18n.tests.ps1 | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/dsc/tests/dsc_i18n.tests.ps1 b/dsc/tests/dsc_i18n.tests.ps1 index 1962d3aef..38394f98d 100644 --- a/dsc/tests/dsc_i18n.tests.ps1 +++ b/dsc/tests/dsc_i18n.tests.ps1 @@ -26,23 +26,25 @@ Describe 'Internationalization tests' { } } + $patterns = @{ + t = '(?s)\bt\!\(\s*"(?.*?)".*?\)' + panic_t = '(?s)\bpanic_t\!\(\s*"(?.*?)".*?\)' + assert_t = '(?s)\bassert_t\!\(\s*.*?,\s*"(?.*?)".*?\)' + } + $missing = @() Get-ChildItem -Recurse -Path $project -Include *.rs -File | ForEach-Object { - # Write-Verbose -Verbose "File: $_" - $line = 0 - Get-Content -Path $_ | ForEach-Object { - $line++ - ($_ | Select-String -Pattern '[^\w]t\!\("(?.*?)".*?\)' -AllMatches).Matches | ForEach-Object { + $content = Get-Content -Path $_ -Raw + foreach ($pattern in $patterns.keys) { + ($content | Select-String -Pattern $patterns[$pattern] -AllMatches).Matches | ForEach-Object { # write-verbose -verbose "Line: $_" if ($null -ne $_) { $key = $_.Groups['key'].Value if ($i18n.ContainsKey($key)) { $i18n[$key] = 1 - # write-verbose -verbose "Found on line $line : $key" } else { $missing += $key - # write-verbose -verbose "Missing: $key" } } } From 62221fc878b5ed63cd928f2b98c64b82120ce05e Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 21 Oct 2025 10:02:03 -0500 Subject: [PATCH 4/4] (GH-538) Define schema extension methods This change defines the `SchemaUtilityExtensions` trait in the `dsc-lib-jsonschema` crate to provide useful shorthand methods for the `schemars::Schema` type when munging and transforming schemas. While the trait includes some methods I expect to be useful, it doesn't try to fully map methods for retrieving every keyword. Instead, it primarily simplifies working with the following keywords: - `$id` to retrieve and set the identifier for a schema. - `$defs` to retrieve and munge referenced subschemas. - `properties` to retrieve and munge property definitions. The trait _does_ define generic methods for retrieving keywords with an expected type, like `get_keyword_as_object()` and `get_keyword_as_str`. These are convenience methods to replace always calling code like the following when you want to retrieve a keyword with a known type: ```rust let ref schema = json_schema!({ "title": "Schema title" }); if let Some(title) = schema.get("title").and_then(|v| v.as_str()) { println!("Schema title is {title}"); } ``` So you can instead do: ```rust let ref schema = json_schema!({ "title": "Schema title" }); if let Some(title) = schema.get_keyword_as_str("title") { println!("Schema title is {title}"); } ``` This change: - Defines and implements the trait, providing documentation for every new method. - Adds tests for the behaviors of every extension method. --- Cargo.lock | 1 + Cargo.toml | 2 +- lib/dsc-lib-jsonschema/Cargo.toml | 3 +- lib/dsc-lib-jsonschema/src/lib.rs | 3 +- .../src/schema_utility_extensions.rs | 1388 +++++++++++++++++ lib/dsc-lib-jsonschema/src/tests/mod.rs | 3 +- .../tests/schema_utility_extensions/mod.rs | 993 ++++++++++++ 7 files changed, 2389 insertions(+), 4 deletions(-) create mode 100644 lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs create mode 100644 lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 2042879b4..2170bc7c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,7 @@ dependencies = [ "serde", "serde_json", "tracing", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2f981b27d..d805e05db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,7 +198,7 @@ tree-sitter-rust = { version = "0.24" } utfx = { version = "0.1" } # dsc-lib uuid = { version = "1.18", features = ["v4"] } -# dsc-lib +# dsc-lib, dsc-lib-jsonschema url = { version = "2.5" } # dsc-lib urlencoding = { version = "2.1" } diff --git a/lib/dsc-lib-jsonschema/Cargo.toml b/lib/dsc-lib-jsonschema/Cargo.toml index e76aa8afb..b27abe823 100644 --- a/lib/dsc-lib-jsonschema/Cargo.toml +++ b/lib/dsc-lib-jsonschema/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" # version stays 0.0.0 until we're ready to publish - should pi edition = "2024" [lib] -doctest = false # Disable doc tests for compilation speed +doctest = false # Disable doc tests by default for compilation speed [dependencies] regex = { workspace = true } @@ -13,6 +13,7 @@ schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } +url = { workspace = true } [dev-dependencies] # Helps review complex comparisons, like schemas diff --git a/lib/dsc-lib-jsonschema/src/lib.rs b/lib/dsc-lib-jsonschema/src/lib.rs index 7087d61ea..1a3c7a871 100644 --- a/lib/dsc-lib-jsonschema/src/lib.rs +++ b/lib/dsc-lib-jsonschema/src/lib.rs @@ -8,8 +8,9 @@ use rust_i18n::i18n; #[macro_use] pub mod macros; -pub mod vscode; +pub mod schema_utility_extensions; pub mod transforms; +pub mod vscode; #[cfg(test)] mod tests; diff --git a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs new file mode 100644 index 000000000..550358f03 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs @@ -0,0 +1,1388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Provides helper functions for retrieving data from and munging [`schemars::Schema`] instances. +//! +//! The `get_keyword_as_*` functions simplify retrieving the value of a keyword for a given type. +//! If the schema defines the keyword with the expected type, those functions return a reference to +//! that value as the correct type. If the keyword doesn't exist or has the wrong value type, the +//! functions return [`None`]. +//! +//! The rest of the utility methods work with specific keywords, like `$id` and `$defs`. + +use core::{clone::Clone, iter::Iterator, option::Option::None}; +use std::string::String; + +use schemars::Schema; +use serde_json::{Map, Number, Value}; +use url::{Position, Url}; + +type Array = Vec; +type Object = Map; + +/// Provides utility extension methods for [`schemars::Schema`]. +pub trait SchemaUtilityExtensions { + //********************** get_keyword_as_* functions **********************// + /// Checks a JSON Schema for a given keyword and returns a reference to the value of that + /// keyword, if it exists, as a [`Vec`]. + /// + /// If the keyword doesn't exist or isn't an array, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an array, the function returns the array. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "enum": ["foo", "bar", "baz"] + /// }); + /// assert_eq!( + /// schema.get_keyword_as_array("enum"), + /// json!(["foo", "bar", "baz"]).as_array() + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "enum": "foo, bar, baz" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_array("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_array("enum"), + /// None + /// ) + /// ``` + fn get_keyword_as_array(&self, key: &str) -> Option<&Array>; + /// Checks a JSON Schema for a given keyword and mutably borrows the value of that keyword, + /// if it exists, as a [`Vec`]. + /// + /// If the keyword doesn't exist or isn't an array, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an array, the function returns the array. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut array_json = json!(["foo", "bar", "baz"]); + /// let ref mut schema = json_schema!({ + /// "enum": array_json + /// }); + /// assert_eq!( + /// schema.get_keyword_as_array_mut("enum"), + /// array_json.as_array_mut() + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut schema = json_schema!({ + /// "enum": "foo, bar, baz" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_array_mut("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_array_mut("enum"), + /// None + /// ) + /// ``` + fn get_keyword_as_array_mut(&mut self, key: &str) -> Option<&mut Array>; + /// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`bool`]. + /// + /// If the keyword doesn't exist or isn't a boolean, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a boolean, the function returns the boolean. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "readOnly": true + /// }); + /// assert_eq!( + /// schema.get_keyword_as_bool("readOnly"), + /// Some(true) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "readOnly": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_bool("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_bool("readOnly"), + /// None + /// ) + /// ``` + fn get_keyword_as_bool(&self, key: &str) -> Option; + /// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`f64`]. + /// + /// If the keyword doesn't exist or isn't a float, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a float, the function returns the float. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "x-float-value": 1.23 + /// }); + /// assert_eq!( + /// schema.get_keyword_as_f64("x-float-value"), + /// Some(1.23) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "x-float-value": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_f64("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_f64("x-float-value"), + /// None + /// ) + /// ``` + fn get_keyword_as_f64(&self, key: &str) -> Option; + /// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`i64`]. + /// + /// If the keyword doesn't exist or isn't an integer, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an integer, the function returns the integer. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": 123 + /// }); + /// assert_eq!( + /// schema.get_keyword_as_i64("minLength"), + /// Some(123) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_i64("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_i64("minLength"), + /// None + /// ) + /// ``` + fn get_keyword_as_i64(&self, key: &str) -> Option; + /// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it + /// exists, as `()`. + /// + /// If the keyword doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an integer, the function returns the integer. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "x-null-value": null + /// }); + /// assert_eq!( + /// schema.get_keyword_as_null("x-null-value"), + /// Some(()) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "x-null-value": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_null("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_null("x-null-value"), + /// None + /// ) + /// ``` + fn get_keyword_as_null(&self, key: &str) -> Option<()>; + /// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`Map`]. + /// + /// If the keyword doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an object, the function returns the object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "properties": { + /// "foo": { "title": "Foo property"} + /// } + /// }); + /// assert_eq!( + /// schema.get_keyword_as_object("properties"), + /// json!({"foo": { "title": "Foo property"}}).as_object() + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "properties": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_object("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_object("enum"), + /// None + /// ) + /// ``` + fn get_keyword_as_object(&self, key: &str) -> Option<&Object>; + /// Checks a JSON Schema for a given keyword and mutably borrows the value of that keyword, + /// if it exists, as a [`Map`]. + /// + /// If the keyword doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is an object, the function returns the object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut object_json = json!({ + /// "foo": { + /// "title": "Foo property" + /// } + /// }); + /// let ref mut schema = json_schema!({ + /// "properties": object_json + /// }); + /// assert_eq!( + /// schema.get_keyword_as_object_mut("properties"), + /// object_json.as_object_mut() + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut schema = json_schema!({ + /// "properties": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_object_mut("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_object_mut("enum"), + /// None + /// ) + /// ``` + fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Object>; + /// Checks a JSON schema for a given keyword and borrows the value of that keyword, if it + /// exists, as a [`Number`]. + /// + /// If the keyword doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a number, the function returns the number. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": 1 + /// }); + /// assert_eq!( + /// schema.get_keyword_as_number("minLength"), + /// json!(1).as_number() + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_number("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_number("minLength"), + /// None + /// ) + /// ``` + fn get_keyword_as_number(&self, key: &str) -> Option<&Number>; + /// Checks a JSON schema for a given keyword and borrows the value of that keyword, if it + /// exists, as a [`str`]. + /// + /// If the keyword doesn't exist or isn't a string, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a string, the function returns the string. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "title": "Schema title" + /// }); + /// assert_eq!( + /// schema.get_keyword_as_str("title"), + /// Some("Schema title") + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "title": true + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_str("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_str("title"), + /// None + /// ) + /// ``` + fn get_keyword_as_str(&self, key: &str) -> Option<&str>; + /// Checks a JSON schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`String`]. + /// + /// If the keyword doesn't exist or isn't a string, this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a string, the function returns the string. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "title": "Schema title" + /// }); + /// assert_eq!( + /// schema.get_keyword_as_string("title"), + /// Some("Schema title".to_string()) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "title": true + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_string("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_string("title"), + /// None + /// ) + /// ``` + fn get_keyword_as_string(&self, key: &str) -> Option; + /// Checks a JSON schema for a given keyword and returns the value of that keyword, if it + /// exists, as a [`u64`]. + /// + /// If the keyword doesn't exist or isn't a [`u64`], this function returns [`None`]. + /// + /// # Examples + /// + /// When the given keyword exists and is a [`u64`], the function returns the [`u64`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": 5 + /// }); + /// assert_eq!( + /// schema.get_keyword_as_u64("minLength"), + /// Some(5 as u64) + /// ); + /// ``` + /// + /// When the given keyword doesn't exist or has the wrong data type, the function returns + /// [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "minLength": "invalid" + /// }); + /// + /// assert_eq!( + /// schema.get_keyword_as_u64("not_exist"), + /// None + /// ); + /// + /// assert_eq!( + /// schema.get_keyword_as_u64("minLength"), + /// None + /// ) + /// ``` + fn get_keyword_as_u64(&self, key: &str) -> Option; + + //************************ $id keyword functions *************************// + /// Retrieves the value of the `$id` keyword as a [`String`]. + /// + /// If the schema doesn't have the `$id` keyword, this function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines the `$id` as a string, the function returns the value. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json" + /// }); + /// + /// assert_eq!( + /// schema.get_id(), + /// Some("https://contoso.com/schemas/example.json") + /// ) + /// ``` + fn get_id(&self) -> Option<&str>; + /// Retrieves the value of the `$id` keyword as a [`Url`]. + /// + /// If the schema doesn't have the `$id` keyword, or the value isn't an absolute URL, this + /// function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines `$id` as a string representing an absolute URL, the function returns + /// that URL object. + /// + /// ```rust + /// use schemars::json_schema; + /// use url::Url; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json" + /// }); + /// let id_url = Url::parse("https://contoso.com/schemas/example.json").unwrap(); + /// + /// assert_eq!( + /// schema.get_id_as_url(), + /// Some(id_url) + /// ) + /// ``` + fn get_id_as_url(&self) -> Option; + /// Indicates whether the [`Schema`] defines the `$id` keyword. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json" + /// }); + /// + /// assert_eq!( + /// schema.has_id_keyword(), + /// true + /// ) + /// ``` + fn has_id_keyword(&self) -> bool; + /// Defines the `$id` keyword for the [`Schema`], returning the old value if `$id` was already + /// defined. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut schema = json_schema!({ + /// "title": "Example schema" + /// }); + /// + /// // Because the schema didn't already define `$id`, there's no prior value to return. + /// assert_eq!( + /// schema.set_id("https://contoso.com/schemas/initial.json"), + /// None + /// ); + /// // When the ID is set a second time, the prior value is returned. + /// assert_eq!( + /// schema.set_id("https://contoso.com/schemas/final.json"), + /// Some("https://contoso.com/schemas/initial.json".to_string()) + /// ); + /// ``` + fn set_id(&mut self, id_uri: &str) -> Option; + + //*********************** $defs keyword functions ************************// + /// Retrieves the `$defs` keyword and returns the object if it exists. + /// + /// If the keyword isn't defined or isn't an object, the function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines `$defs` as an object, the function returns a reference to that + /// object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref defs_json = json!({ + /// "foo": { + /// "title": "Foo subschema" + /// } + /// }); + /// let ref schema = json_schema!({ + /// "$defs": defs_json + /// }); + /// assert_eq!( + /// schema.get_defs(), + /// defs_json.as_object() + /// ); + /// ``` + fn get_defs(&self) -> Option<&Object>; + /// Retrieves the `$defs` keyword and mutably borrows the object if it exists. + /// + /// If the keyword isn't defined or isn't an object, the function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines `$defs` as an object, the function mutably borrows that + /// object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut defs_json = json!({ + /// "foo": { + /// "title": "Foo subschema" + /// } + /// }); + /// let ref mut schema = json_schema!({ + /// "$defs": defs_json + /// }); + /// assert_eq!( + /// schema.get_defs_mut(), + /// defs_json.as_object_mut() + /// ); + /// ``` + fn get_defs_mut(&mut self) -> Option<&mut Object>; + /// Looks up a reference in the `$defs` keyword by `$id` and returns the subschema entry as an + /// object if it exists. + /// + /// The value for the `id` _must_ be the absolute URL of the target subschema's `$id` keyword. + /// If the target subschema doesn't define the `$id` keyword, this function can't resolve the + /// lookup. + /// + /// For a more flexible lookup, use the [`get_defs_subschema_from_reference()`] function + /// instead. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref schema = json_schema!({ + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// + /// assert_eq!( + /// schema.get_defs_subschema_from_id("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object() + /// ); + /// assert_eq!( + /// schema.get_defs_subschema_from_id("/schemas/example/foo.json"), + /// None + /// ); + /// ``` + /// + /// [`get_defs_subschema_from_reference()`]: SchemaUtilityExtensions::get_defs_subschema_from_reference + fn get_defs_subschema_from_id(&self, id: &str) -> Option<&Object>; + /// Looks up a reference in the `$defs` keyword by `$id` and mutably borrows the subschema + /// entry as an object if it exists. + /// + /// The value for the `id` _must_ be the absolute URL of the target subschema's `$id` keyword. + /// If the target subschema doesn't define the `$id` keyword, this function can't resolve the + /// lookup. + /// + /// For a more flexible lookup, use the [`get_defs_subschema_from_reference_mut()`] function + /// instead. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref mut schema = json_schema!({ + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// + /// assert_eq!( + /// schema.get_defs_subschema_from_id_mut("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object_mut() + /// ); + /// assert_eq!( + /// schema.get_defs_subschema_from_id_mut("/schemas/example/foo.json"), + /// None + /// ); + /// ``` + /// + /// [`get_defs_subschema_from_reference_mut()`]: SchemaUtilityExtensions::get_defs_subschema_from_reference_mut + fn get_defs_subschema_from_id_mut(&mut self, id: &str) -> Option<&mut Object>; + /// Looks up a reference in the `$defs` keyword and returns the subschema entry as an obect if + /// it exists. + /// + /// The reference can be any of the following: + /// + /// - A URI identifier fragment, like `#/$defs/foo` + /// - An absolute URL for the referenced schema, like `https://contoso.com/schemas/example.json` + /// - A site-relative URL for the referenced schema, like `/schemas/example.json`. The function + /// can only resolve site-relative URLs when the schema itself defines `$id` with an absolute + /// URL, because it uses the current schema's `$id` as the base URL. + /// + /// If the reference can't be resolved or resolves to a non-object value, this function returns + /// [`None`]. + /// + /// # Examples + /// + /// You can retrieve a definition with a fragment point, the absolute URL of the target schema's + /// `$id` keyword, or the relative URL of the target schema's `$id` keyword. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// // Lookup with pointer: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("#/$defs/foo"), + /// definition.as_object() + /// ); + /// // Lookup with absolute URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object() + /// ); + /// // Lookup with site-relative URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("/schemas/example/foo.json"), + /// definition.as_object() + /// ); + /// ``` + /// + /// If the [`Schema`] _doesn't_ define the `$id` keyword as an absolute URL, lookups for + /// site-relative references fail to resolve and return [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref schema = json_schema!({ + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// // Lookup with pointer: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("#/$defs/foo"), + /// definition.as_object() + /// ); + /// // Lookup with absolute URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object() + /// ); + /// // Lookup with site-relative URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference("/schemas/example/foo.json"), + /// None + /// ); + /// ``` + fn get_defs_subschema_from_reference(&self, reference: &str) -> Option<&Object>; + /// Looks up a reference in the `$defs` keyword and mutably borrows the subschema entry as an + /// object if it exists. + /// + /// The reference can be any of the following: + /// + /// - An absolute URL for the referenced schema, like `https://contoso.com/schemas/example.json` + /// - A site-relative URL for the referenced schema, like `/schemas/example.json`. The function + /// can only resolve site-relative URLs when the schema itself defines `$id` with an absolute + /// URL, because it uses the current schema's `$id` as the base URL. + /// + /// If the reference can't be resolved or resolves to a non-object value, this function returns + /// [`None`]. + /// + /// Due to a bug in [`schemars::Schema::pointer_mut()`], this function can't correctly resolve + /// references from URI fragment identifiers like `#/$defs/foo`, unlike + /// [`get_defs_subschema_from_reference()`]. Until the [fixing PR] is merged and included in a + /// [`schemars`] release, this function can only resolve absolute and relative URLs matching + /// the target definitions subschema's `$id` keyword. For more information on the bug, see + /// see [schemars#478]. + /// + /// # Examples + /// + /// You can retrieve a definition with the absolute URL of the target schema's `$id` keyword or + /// the relative URL of the target schema's `$id` keyword. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref mut schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// // Lookup with absolute URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference_mut("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object_mut() + /// ); + /// // Lookup with site-relative URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference_mut("/schemas/example/foo.json"), + /// definition.as_object_mut() + /// ); + /// ``` + /// + /// If the [`Schema`] _doesn't_ define the `$id` keyword as an absolute URL, lookups for + /// site-relative references fail to resolve and return [`None`]. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref mut schema = json_schema!({ + /// "$defs": { + /// "foo": definition + /// } + /// }); + /// // Lookup with absolute URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference_mut("https://contoso.com/schemas/example/foo.json"), + /// definition.as_object_mut() + /// ); + /// // Lookup with site-relative URL: + /// assert_eq!( + /// schema.get_defs_subschema_from_reference_mut("/schemas/example/foo.json"), + /// None + /// ); + /// ``` + /// + /// [`get_defs_subschema_from_reference()`]: SchemaUtilityExtensions::get_defs_subschema_from_reference + /// [schemars#478]: https://github.com/GREsau/schemars/issues/478 + /// [fixing PR]: https://github.com/GREsau/schemars/pull/479 + fn get_defs_subschema_from_reference_mut(&mut self, reference: &str) -> Option<&mut Object>; + /// Inserts a subschema entry into the `$defs` keyword for the [`Schema`]. If an entry for the + /// given key already exists, this function returns the old value as a map. + /// + /// If the [`Schema`] doesn't define the `$defs` keyword, this function inserts it as an object + /// containing the given key-value pair for the definition. + /// + /// # Examples + /// + /// When the given definition key exists, the function returns that value as an object after + /// replacing it in the `$defs` object. + /// + /// ``` + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let original_definition = json!({ + /// "title": "Foo property" + /// }).as_object().unwrap().clone(); + /// let mut new_definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// }).as_object().unwrap().clone(); + /// let ref mut schema = json_schema!({ + /// "$defs": { + /// "foo": original_definition + /// } + /// }); + /// assert_eq!( + /// schema.insert_defs_subschema("foo", &new_definition), + /// Some(original_definition) + /// ); + /// assert_eq!( + /// schema.get_defs_subschema_from_reference_mut("https://contoso.com/schemas/example/foo.json"), + /// Some(&mut new_definition) + /// ) + /// ``` + fn insert_defs_subschema(&mut self, definition_key: &str, definition_value: &Object) -> Option; + /// Looks up a subschema in the `$defs` keyword by reference and, if it exists, renames the + /// _key_ for the definition. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let definition = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref mut schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$defs": { + /// "foo": definition.clone() + /// } + /// }); + /// // Lookup the definition by site-relative URL and replace with full ID + /// schema.rename_defs_subschema_for_reference( + /// "/schemas/example/foo.json", + /// "https://contoso.com/schemas/example/foo.json" + /// ); + /// // Prior key no longer resolveable + /// assert_eq!( + /// schema.get_defs_mut().unwrap().get("foo"), + /// None + /// ); + /// // New key contains expected value + /// assert_eq!( + /// schema.get_defs_mut().unwrap() + /// .get("https://contoso.com/schemas/example/foo.json") + /// .unwrap() + /// .as_object(), + /// definition.as_object() + /// ) + /// ``` + fn rename_defs_subschema_for_reference(&mut self, reference: &str, new_name: &str); + + //********************* properties keyword functions *********************// + /// Retrieves the `properties` keyword and returns the object if it exists. + /// + /// If the keyword isn't defined or isn't an object, the function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines `properties` as an object, the function returns a reference to that + /// object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref properties_json = json!({ + /// "foo": { + /// "title": "Foo property" + /// } + /// }); + /// let ref schema = json_schema!({ + /// "properties": properties_json + /// }); + /// assert_eq!( + /// schema.get_properties(), + /// properties_json.as_object() + /// ); + /// ``` + fn get_properties(&self) -> Option<&Object>; + /// Retrieves the `properties` keyword and mutably borrows the object if it exists. + /// + /// If the keyword isn't defined or isn't an object, the function returns [`None`]. + /// + /// # Examples + /// + /// When the schema defines `properties` as an object, the function mutably borrows that + /// object. + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut properties_json = json!({ + /// "foo": { + /// "title": "Foo subschema" + /// } + /// }); + /// let ref mut schema = json_schema!({ + /// "properties": properties_json + /// }); + /// assert_eq!( + /// schema.get_properties_mut(), + /// properties_json.as_object_mut() + /// ); + /// ``` + fn get_properties_mut(&mut self) -> Option<&mut Object>; + /// Looks up a property in the `properties` keyword by name and returns the subschema entry as + /// an object if it exists. + /// + /// If the named property doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref property = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref schema = json_schema!({ + /// "properties": { + /// "foo": property + /// } + /// }); + /// + /// assert_eq!( + /// schema.get_property_subschema("foo"), + /// property.as_object() + /// ); + /// ``` + fn get_property_subschema(&self, property_name: &str) -> Option<&Object>; + /// Looks up a property in the `properties` keyword by name and mutably borrows the subschema + /// entry as an object if it exists. + /// + /// If the named property doesn't exist or isn't an object, this function returns [`None`]. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use serde_json::json; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// + /// let ref mut property = json!({ + /// "$id": "https://contoso.com/schemas/example/foo.json", + /// "title": "Foo property" + /// }); + /// let ref mut schema = json_schema!({ + /// "properties": { + /// "foo": property + /// } + /// }); + /// + /// assert_eq!( + /// schema.get_property_subschema_mut("foo"), + /// property.as_object_mut() + /// ); + /// ``` + fn get_property_subschema_mut(&mut self, property_name: &str) -> Option<&mut Object>; +} + +impl SchemaUtilityExtensions for Schema { + fn get_keyword_as_array(&self, key: &str) -> Option<&Array> { + self.get(key) + .and_then(Value::as_array) + } + fn get_keyword_as_array_mut(&mut self, key: &str) -> Option<&mut Array> { + self.get_mut(key) + .and_then(Value::as_array_mut) + } + fn get_keyword_as_bool(&self, key: &str) -> Option { + self.get(key) + .and_then(Value::as_bool) + } + fn get_keyword_as_f64(&self, key: &str) -> Option { + self.get(key) + .and_then(Value::as_f64) + } + fn get_keyword_as_i64(&self, key: &str) -> Option { + self.get(key) + .and_then(Value::as_i64) + } + fn get_keyword_as_null(&self, key: &str) -> Option<()> { + self.get(key) + .and_then(Value::as_null) + } + fn get_keyword_as_number(&self, key: &str) -> Option<&Number> { + self.get(key) + .and_then(Value::as_number) + } + fn get_keyword_as_object(&self, key: &str) -> Option<&Object> { + self.get(key) + .and_then(Value::as_object) + } + fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Object> { + self.get_mut(key) + .and_then(Value::as_object_mut) + } + fn get_keyword_as_str(&self, key: &str) -> Option<&str> { + self.get(key) + .and_then(Value::as_str) + } + fn get_keyword_as_string(&self, key: &str) -> Option { + self.get(key) + .and_then(Value::as_str) + .map(std::string::ToString::to_string) + } + fn get_keyword_as_u64(&self, key: &str) -> Option { + self.get(key) + .and_then(Value::as_u64) + } + fn get_defs(&self) -> Option<&Object> { + self.get_keyword_as_object("$defs") + } + fn get_defs_mut(&mut self) -> Option<&mut Object> { + self.get_keyword_as_object_mut("$defs") + } + fn get_defs_subschema_from_id(&self, id: &str) -> Option<&Object> { + let defs = self.get_defs()?; + + for def in defs.values() { + if let Some(definition) = def.as_object() { + let def_id = definition.get("$id").and_then(Value::as_str); + + if def_id == Some(id) { + return Some(definition); + } + } + } + + None + } + fn get_defs_subschema_from_id_mut(&mut self, id: &str) -> Option<&mut Object> { + let defs = self.get_defs_mut()?; + + for def in defs.values_mut() { + if let Some(definition) = def.as_object_mut() { + let def_id = definition.get("$id").and_then(Value::as_str); + + if def_id == Some(id) { + return Some(definition); + } + } + } + + None + } + fn get_defs_subschema_from_reference(&self, reference: &str) -> Option<&Object> { + // If the reference is a normative pointer to $defs, short-circuit. + if reference.to_string().starts_with("#/$defs/") { + return self.pointer(reference).and_then(Value::as_object); + } + + let id = reference.to_string(); + // if the reference is a full URL, look up subschema by $id + if id.starts_with("https://") { + return self.get_defs_subschema_from_id(reference); + } + // if the reference is a relative URL, try to compose ID from current schema $id + if let Some(schema_id) = self.get_id_as_url() { + let url_prefix = schema_id[..Position::BeforePath].to_string(); + let id = format!("{url_prefix}{id}"); + return self.get_defs_subschema_from_id(&id) + } + + None + } + fn get_defs_subschema_from_reference_mut(&mut self, reference: &str) -> Option<&mut Object> { + // If the reference is a normative pointer to $defs, short-circuit. + if reference.to_string().starts_with("#/$defs/") { + return self.pointer_mut(reference).and_then(Value::as_object_mut); + } + + let id = reference.to_string(); + // if the reference is a full URL, look up subschema by $id + if id.starts_with("https://") { + return self.get_defs_subschema_from_id_mut(reference); + } + // if the reference is a relative URL, try to compose ID from current schema $id + if let Some(schema_id) = self.get_id_as_url() { + let url_prefix = schema_id[..Position::BeforePath].to_string(); + let id = format!("{url_prefix}{id}"); + return self.get_defs_subschema_from_id_mut(&id) + } + + None + } + fn insert_defs_subschema( + &mut self, + definition_key: &str, + definition_value: &Object + ) -> Option { + if let Some(defs) = self.get_defs_mut() { + let old_value = defs.clone() + .get(definition_key) + .and_then(Value::as_object) + .cloned(); + + defs.insert(definition_key.to_string(), Value::Object(definition_value.clone())) + .and(old_value) + } else { + let defs: &mut Object = &mut Map::new(); + defs.insert(definition_key.to_string(), Value::Object(definition_value.clone())); + self.insert("$defs".to_string(), Value::Object(defs.clone())); + + None + } + } + fn rename_defs_subschema_for_reference(&mut self, reference: &str, new_name: &str) { + let lookup_self = self.clone(); + // Lookup the reference. If unresolved, return immediately. + let Some(value) = lookup_self.get_defs_subschema_from_reference(reference) else { + return; + }; + // If defs can't be retrieved mutably, return immediately. + let Some(defs) = self.get_defs_mut() else { + return; + }; + // Replace the existing key in the map by looking for the key-value pair with the same + // value and rename it. + let new_key = &new_name.to_string(); + *defs = defs.iter_mut().map(|(k, v)| { + if *v == Value::Object(value.clone()) { + (new_key.clone(), v.clone()) + } else { + (k.clone(), v.clone()) + } + }).collect(); + } + fn get_id(&self) -> Option<&str> { + self.get_keyword_as_str("$id") + } + fn get_id_as_url(&self) -> Option { + // By default `Url::parse` fails for non-absolute URLs. + match self.get_id() { + None => None, + Some(id_str) => Url::parse(id_str).ok() + } + } + fn has_id_keyword(&self) -> bool { + self.get_id().is_some() + } + fn set_id(&mut self, id_uri: &str) -> Option { + // Unfortunately, we need to clone the Schema to immutably retrieve the ID to return it. + // Attempting to return it from the `insert().and_then()` fails to compile for temporary + // value. + let old_id = self.clone() + .get_mut("$id") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + + self.insert("$id".to_string(), Value::String(id_uri.to_string())) + .and(old_id) + } + fn get_properties(&self) -> Option<&Object> { + self.get_keyword_as_object("properties") + } + fn get_properties_mut(&mut self) -> Option<&mut Object> { + self.get_keyword_as_object_mut("properties") + } + fn get_property_subschema(&self, property_name: &str) -> Option<&Object> { + self.get_properties() + .and_then(|properties| properties.get(property_name)) + .and_then(Value::as_object) + } + fn get_property_subschema_mut(&mut self, property_name: &str) -> Option<&mut Object> { + self.get_properties_mut() + .and_then(|properties| properties.get_mut(property_name)) + .and_then(Value::as_object_mut) + } +} diff --git a/lib/dsc-lib-jsonschema/src/tests/mod.rs b/lib/dsc-lib-jsonschema/src/tests/mod.rs index 2f49bec5a..2dba6afbc 100644 --- a/lib/dsc-lib-jsonschema/src/tests/mod.rs +++ b/lib/dsc-lib-jsonschema/src/tests/mod.rs @@ -8,9 +8,10 @@ //! module are for internal code. Do not define tests for public items in this //! module. Instead, define those tests in the `tests/integration` folder, //! which forces usage of the crate as a public API. -//! +//! //! When you define tests in this module, ensure that you mirror the structure //! of the modules from the rest of the source tree. +#[cfg(test)] mod schema_utility_extensions; #[cfg(test)] mod transforms; #[cfg(test)] mod vscode; diff --git a/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions/mod.rs b/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions/mod.rs new file mode 100644 index 000000000..6b7710a57 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions/mod.rs @@ -0,0 +1,993 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests for [`dsc-lib-jsonschema::schema_utility_extensions`]. + +use core::{clone::Clone, convert::Into}; +use std::sync::LazyLock; + +// use pretty_assertions::assert_eq; +use schemars::{json_schema, Schema}; +use serde_json::{json, Map, Value}; + +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +// Setup static data for the `get_keyword_as*` test macros +static ARRAY_VALUE: LazyLock> = LazyLock::new(|| Vec::from([ + Value::String("a".to_string()), + Value::String("b".to_string()), + Value::String("c".to_string()), +])); +static BOOLEAN_VALUE: bool = true; +static INTEGER_VALUE: i64 = 5; +static FLOAT_VALUE: f64 = 1.2; +static OBJECT_VALUE: LazyLock> = LazyLock::new(|| json!({ + "foo": "bar" +}).as_object().unwrap().clone()); +static NULL_VALUE: () = (); +static STRING_VALUE: &str = "value"; +static TEST_SCHEMA: LazyLock = LazyLock::new(|| json_schema!({ + "$id": "https://schema.contoso.com/test/get_keyword_as.json", + "array": *ARRAY_VALUE, + "boolean": BOOLEAN_VALUE, + "integer": INTEGER_VALUE, + "float": FLOAT_VALUE, + "object": *OBJECT_VALUE, + "null": null, + "string": *STRING_VALUE, +})); + +/// Defines test cases for a given `get_keyword_as` function (non-mutable). +/// +/// Each test case verifies behavior when: +/// +/// - The given keyword doesn't exist (return [`None`]) +/// - The given keyword has the wrong data type (return [`None`]) +/// - The given keyword has the correct data type (return [`Some`] with the data). +/// +/// # Arguments +/// +/// The first argument must be the identifier for the function to test. The second argument is +/// name of a keyword to retrieve with invalid data. The third argument is the name of a keyword +/// to retrieve with valid data. The last argument is the expected value for the valid lookup. +macro_rules! test_cases_for_get_keyword_as { + ($( + $test_function:ident: $invalid_lookup:expr, $valid_lookup:expr, $expected_valid:expr, + )*) => { + $( + #[cfg(test)] + mod $test_function { + #![allow(unused_imports)] + use super::*; + // use super::super::*; + use pretty_assertions::assert_eq; + use schemars::{json_schema, Schema}; + use serde_json::{json, Map, Value, Number}; + + #[test] fn when_keyword_missing() { + let schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function("not_exist"), None); + } + #[test] fn when_keyword_has_invalid_type() { + let schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function($invalid_lookup), None); + } + #[test] fn when_keyword_has_valid_type() { + let schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function($valid_lookup), $expected_valid); + } + } + )* + }; +} + +/// Defines test cases for a given `get_keyword_as` function (mutable). +/// +/// Each test case verifies behavior when: +/// +/// - The given keyword doesn't exist (return [`None`]) +/// - The given keyword has the wrong data type (return [`None`]) +/// - The given keyword has the correct data type (return [`Some`] with the data). +/// +/// # Arguments +/// +/// The first argument must be the identifier for the function to test. The second argument is +/// name of a keyword to retrieve with invalid data. The third argument is the name of a keyword +/// to retrieve with valid data. The last argument is the expected value for the valid lookup. +macro_rules! test_cases_for_get_keyword_as_mut { + ($( + $test_function:ident: $invalid_lookup:expr, $valid_lookup:expr, $expected_valid:expr, + )*) => { + $( + #[cfg(test)] + mod $test_function { + #![allow(unused_imports)] + use super::*; + // use super::super::*; + use pretty_assertions::assert_eq; + use schemars::{json_schema, Schema}; + use serde_json::{json, Map, Value, Number}; + + #[test] fn when_keyword_missing() { + let ref mut schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function("not_exist"), None); + } + #[test] fn when_keyword_has_invalid_type() { + let ref mut schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function($invalid_lookup), None); + } + #[test] fn when_keyword_has_valid_type() { + let ref mut schema = TEST_SCHEMA.clone(); + assert_eq!(schema.$test_function($valid_lookup), $expected_valid); + } + } + )* + }; +} + +test_cases_for_get_keyword_as!( + get_keyword_as_array: "boolean", "array", Some(&*ARRAY_VALUE), + get_keyword_as_bool: "array", "boolean", Some(BOOLEAN_VALUE), + get_keyword_as_f64: "array", "float", Some(FLOAT_VALUE), + get_keyword_as_i64: "array", "integer", Some(INTEGER_VALUE), + get_keyword_as_null: "array", "null", Some(NULL_VALUE), + get_keyword_as_object: "array", "object", Some(&*OBJECT_VALUE), + get_keyword_as_number: "array", "integer", Some(&(INTEGER_VALUE.into())), + get_keyword_as_str: "array", "string", Some(STRING_VALUE), + get_keyword_as_string: "array", "string", Some(STRING_VALUE.to_string()), +); + + +test_cases_for_get_keyword_as_mut!( + get_keyword_as_array_mut: "boolean", "array", Some(&mut (*ARRAY_VALUE).clone()), + get_keyword_as_object_mut: "array", "object", Some(&mut (*OBJECT_VALUE).clone()), +); + +#[cfg(test)] mod get_id { + use pretty_assertions::assert_eq; + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_id_keyword_missing() { + let ref schema = json_schema!({ + "title": "Missing ID" + }); + assert_eq!(schema.get_id(), None); + } + #[test] fn when_id_keyword_is_not_string() { + let ref schema = json_schema!({ + "$id": 5, + }); + assert_eq!(schema.get_id(), None); + } + #[test] fn when_id_keyword_is_string() { + let id = "https://schemas.contoso.com/test/valid_id.json"; + let ref schema = json_schema!({ + "$id": id + }); + assert_eq!(schema.get_id(), Some(id)); + } +} +#[cfg(test)] mod get_id_as_url { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use url::Url; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_id_keyword_missing() { + let ref schema = json_schema!({ + "title": "Missing ID" + }); + assert_eq!(schema.get_id_as_url(), None); + } + #[test] fn when_id_keyword_is_not_string() { + let ref schema = json_schema!({ + "$id": 5, + }); + assert_eq!(schema.get_id_as_url(), None); + } + #[test] fn when_id_keyword_is_string_but_not_valid_url() { + let ref schema = json_schema!({ + "$id": "invalid", + }); + assert_eq!(schema.get_id_as_url(), None); + } + #[test] fn when_id_keyword_is_relative_url() { + let ref schema = json_schema!({ + "$id": "/test/valid_id.json", + }); + assert_eq!(schema.get_id_as_url(), None); + } + #[test] fn when_id_keyword_is_absolute_url() { + let id_str = "https://schemas.contoso.com/test/valid_id.json"; + let id_url = Url::parse(id_str).unwrap(); + let ref schema = json_schema!({ + "$id": id_str + }); + assert_eq!(schema.get_id_as_url(), Some(id_url)); + } +} + +#[cfg(test)] mod has_id_keyword { + use pretty_assertions::assert_eq; + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_keyword_exists() { + let ref schema = json_schema!({ + "$id": "https://schemas.contoso.com/test/valid_id.json" + }); + assert_eq!(schema.has_id_keyword(), true); + } + #[test] fn when_keyword_not_exists() { + let ref schema = json_schema!({ + "title": "Missing '$id' keyword" + }); + assert_eq!(schema.has_id_keyword(), false); + } +} + +#[cfg(test)] mod set_id { + use pretty_assertions::assert_eq; + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_id_already_defined() { + let id = "https://schemas.contoso.com/test/valid_id.json"; + let ref mut schema = json_schema!({ + "$id": id + }); + assert_eq!( + schema.set_id("https://schemas.contoso.com/test/new_id.json"), + Some(id.to_string()) + ); + } + #[test] fn when_id_not_already_defined() { + let id_uri = "https://schemas.contoso.com/test/valid_id.json"; + let ref mut schema = json_schema!({ + "title": "Without initial '$id' keyword" + }); + assert_eq!(schema.set_id(id_uri), None); + } +} + +#[cfg(test)] mod get_defs { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let ref schema = json_schema!({ + "title": "Schema without '$defs' keyword" + }); + assert_eq!(schema.get_defs(), None); + } + #[test] fn when_defs_keyword_is_not_object() { + let ref schema = json_schema!({ + "title": "Schema with non-object '$defs' keyword", + "$defs": "invalid" + }); + assert_eq!(schema.get_defs(), None); + } + #[test] fn when_defs_keyword_is_object() { + let defs_json= json!({ + "first": { + "title": "first definition subschema" + }, + "second": { + "title": "second definition subschema" + }, + }); + let defs_object = defs_json.as_object().unwrap(); + let ref schema = json_schema!({ + "title": "schema with '$defs' as object", + "$defs": defs_object, + }); + assert_eq!(schema.get_defs(), Some(defs_object)); + } +} + +#[cfg(test)] mod get_defs_mut { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let ref mut schema = json_schema!({ + "title": "Schema without '$defs' keyword" + }); + assert_eq!(schema.get_defs_mut(), None); + } + #[test] fn when_defs_keyword_is_not_object() { + let ref mut schema = json_schema!({ + "title": "Schema with non-object '$defs' keyword", + "$defs": "invalid" + }); + assert_eq!(schema.get_defs_mut(), None); + } + #[test] fn when_defs_keyword_is_object() { + let defs_json= json!({ + "first": { + "title": "first definition subschema" + }, + "second": { + "title": "second definition subschema" + }, + }); + let ref mut defs_object = defs_json.as_object().unwrap().clone(); + let ref mut schema = json_schema!({ + "title": "schema with '$defs' as object", + "$defs": defs_object.clone(), + }); + assert_eq!(schema.get_defs_mut(), Some(defs_object)); + } +} +#[cfg(test)] mod get_defs_subschema_from_id { + use core::option::Option::None; + + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let ref schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json" + }); + assert_eq!( + schema.get_defs_subschema_from_id("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_not_object() { + let ref schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": "invalid" + }); + assert_eq!( + schema.get_defs_subschema_from_id("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_missing_id() { + let ref schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "title": "Foo" + } + } + }); + assert_eq!( + schema.get_defs_subschema_from_id("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_has_matching_id_keyword() { + let ref schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let ref expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_id("https://contoso.com/schemas/foo.json"), + expected.as_object() + ); + } +} +#[cfg(test)] mod get_defs_subschema_from_id_mut { + use core::option::Option::None; + + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json" + }); + assert_eq!( + schema.get_defs_subschema_from_id_mut("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_not_object() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": "invalid" + }); + assert_eq!( + schema.get_defs_subschema_from_id_mut("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_missing_id() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "title": "Foo" + } + } + }); + assert_eq!( + schema.get_defs_subschema_from_id_mut("https://contoso.com/schemas/foo.json"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_has_matching_id_keyword() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let ref mut expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_id_mut("https://contoso.com/schemas/foo.json"), + expected.as_object_mut() + ); + } +} +#[cfg(test)] mod get_defs_subschema_from_reference { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let schema = json_schema!({ + "title": "missing defs" + }); + assert_eq!( + schema.get_defs_subschema_from_reference("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_not_object() { + let schema = json_schema!({ + "title": "missing defs", + "$defs": "invalid" + }); + assert_eq!( + schema.get_defs_subschema_from_reference("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_missing() { + let schema = json_schema!({ + "title": "missing defs", + "$defs": { + "second": { + "title": "second value" + } + } + }); + assert_eq!( + schema.get_defs_subschema_from_reference("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_is_not_object() { + let schema = json_schema!({ + "title": "missing defs", + "$defs": { + "first": "invalid" + } + }); + assert_eq!( + schema.get_defs_subschema_from_reference("#/$defs/first"), + None + ); + } + #[test] fn with_defs_pointer_reference() { + let schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_reference("#/$defs/foo").unwrap(), + expected.as_object().unwrap() + ); + } + #[test] fn with_absolute_id_uri_reference() { + let schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_reference("/schemas/foo.json").unwrap(), + expected.as_object().unwrap() + ); + } + #[test] fn with_relative_id_uri_reference() { + let schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_reference("https://contoso.com/schemas/foo.json").unwrap(), + expected.as_object().unwrap() + ); + } +} + +#[cfg(test)] mod get_defs_subschema_from_reference_mut { + use pretty_assertions::assert_ne; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_keyword_missing() { + let ref mut schema = json_schema!({ + "title": "missing defs" + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_not_object() { + let ref mut schema = json_schema!({ + "title": "missing defs", + "$defs": "invalid" + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_missing() { + let ref mut schema = json_schema!({ + "title": "missing defs", + "$defs": { + "second": { + "title": "second value" + } + } + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("#/$defs/first"), + None + ); + } + #[test] fn when_defs_keyword_is_object_and_entry_is_not_object() { + let ref mut schema = json_schema!({ + "title": "missing defs", + "$defs": { + "first": "invalid" + } + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("#/$defs/first"), + None + ); + } + /// Inverted the test - current logic fails due to a bug in [`schemars::Schema::pointer_mut()`], + /// see [schemars#478]. + /// + /// This bug will be fixed when schemars merges [schemars#479] and makes a new release. + /// + /// [schemars#478]: https://github.com/GREsau/schemars/issues/478 + /// [schemars#479]: https://github.com/GREsau/schemars/pull/479 + #[test] fn with_defs_pointer_reference() { + let ref mut schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let ref mut expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + + assert_ne!( + schema.get_defs_subschema_from_reference_mut("#/$defs/foo"), + expected.as_object_mut() + ); + } + #[test] fn with_absolute_id_uri_reference() { + let ref mut schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let ref mut expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("/schemas/foo.json").unwrap(), + expected.as_object_mut().unwrap() + ); + } + #[test] fn with_relative_id_uri_reference() { + let ref mut schema = json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/object.json", + "type": "object", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + } + } + }); + let ref mut expected = json!({ + "$id": "https://contoso.com/schemas/foo.json", + "title": "Foo" + }); + assert_eq!( + schema.get_defs_subschema_from_reference_mut("https://contoso.com/schemas/foo.json").unwrap(), + expected.as_object_mut().unwrap() + ); + } +} + +#[cfg(test)] mod insert_defs_subschema { + #[test] fn when_defs_keyword_missing() {} + #[test] fn when_defs_keyword_is_not_object() {} + #[test] fn when_defs_keyword_is_object_and_entry_not_exist() {} + #[test] fn when_defs_keyword_is_object_and_entry_exists() {} +} + +#[cfg(test)] mod rename_defs_subschema_for_reference { + use pretty_assertions::assert_eq; + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_not_defined() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json" + }); + let expected = json_schema!({ + "$id": "https://contoso.com/schemas/test.json" + }); + schema.rename_defs_subschema_for_reference("#/$defs/not_exist", "not_exist"); + assert_eq!(schema.clone(), expected); + } + #[test] fn when_defs_subschema_not_defined() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + + }); + let expected = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + }); + schema.rename_defs_subschema_for_reference("#/$defs/not_exist", "not_exist"); + assert_eq!(schema.clone(), expected); + } + + #[test] fn rename_by_defs_pointer_reference() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + + }); + let expected = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "https://contoso.com/schemas/foo.json": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + }); + schema.rename_defs_subschema_for_reference( + "#/$defs/foo", + "https://contoso.com/schemas/foo.json" + ); + assert_eq!(schema.clone(), expected); + } + + #[test] fn rename_by_absolute_id_uri() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + + }); + let expected = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "https://contoso.com/schemas/foo.json": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + }); + schema.rename_defs_subschema_for_reference( + "https://contoso.com/schemas/foo.json", + "https://contoso.com/schemas/foo.json" + ); + assert_eq!(schema.clone(), expected); + } + #[test] fn rename_by_relative_id_uri() { + let ref mut schema = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + + }); + let expected = json_schema!({ + "$id": "https://contoso.com/schemas/test.json", + "$defs": { + "https://contoso.com/schemas/foo.json": { + "$id": "https://contoso.com/schemas/foo.json" + } + } + }); + schema.rename_defs_subschema_for_reference( + "/schemas/foo.json", + "https://contoso.com/schemas/foo.json" + ); + assert_eq!(schema.clone(), expected); + } +} + +#[cfg(test)] mod get_properties { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_properties_keyword_missing() { + let ref schema = json_schema!({ + "title": "Missing properties" + }); + assert_eq!(schema.get_properties(), None); + } + #[test] fn when_properties_keyword_is_not_object() { + let ref schema = json_schema!({ + "properties": "invalid" + }); + assert_eq!(schema.get_properties(), None); + } + #[test] fn when_properties_keyword_is_object() { + let ref properties = json!({ + "foo": { + "title": "Foo property" + } + }); + let ref schema = json_schema!({ + "properties": properties + }); + assert_eq!( + schema.get_properties().unwrap(), + properties.as_object().unwrap() + ); + } +} + +#[cfg(test)] mod get_properties_mut { + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_properties_keyword_missing() { + let ref mut schema = json_schema!({ + "title": "Missing properties" + }); + assert_eq!(schema.get_properties_mut(), None); + } + #[test] fn when_properties_keyword_is_not_object() { + let ref mut schema = json_schema!({ + "properties": "invalid" + }); + assert_eq!(schema.get_properties_mut(), None); + } + #[test] fn when_properties_keyword_is_object() { + let ref mut properties = json!({ + "foo": { + "title": "Foo property" + } + }); + let ref mut schema = json_schema!({ + "properties": properties + }); + assert_eq!( + schema.get_properties_mut().unwrap(), + properties.as_object_mut().unwrap() + ); + } +} + +#[cfg(test)] mod get_property_subschema { + use core::option::Option::None; + + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_properties_keyword_missing() { + let ref schema = json_schema!({ + "title": "Missing properties" + }); + assert_eq!(schema.get_property_subschema("foo"), None) + } + #[test] fn when_properties_keyword_is_not_object() { + let ref schema = json_schema!({ + "properties": "Invalid" + }); + assert_eq!(schema.get_property_subschema("foo"), None) + } + #[test] fn when_given_property_missing() { + let ref schema = json_schema!({ + "properties": { + "bar": { "title": "Bar property" } + } + }); + assert_eq!(schema.get_property_subschema("foo"), None) + } + #[test] fn when_given_property_is_not_object() { + let ref schema = json_schema!({ + "properties": { + "foo": "invalid" + } + }); + assert_eq!(schema.get_property_subschema("foo"), None) + } + #[test] fn when_given_property_is_object() { + let ref property = json!({ + "title": "Foo property" + }); + let ref schema = json_schema!({ + "properties": { + "foo": property + } + }); + assert_eq!( + schema.get_property_subschema("foo").unwrap(), + property.as_object().unwrap() + ) + } +} + +#[cfg(test)] mod get_property_subschema_mut { + use core::option::Option::None; + + use pretty_assertions::assert_eq; + use schemars::json_schema; + use serde_json::json; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_properties_keyword_missing() { + let ref mut schema = json_schema!({ + "title": "Missing properties" + }); + assert_eq!(schema.get_property_subschema_mut("foo"), None) + } + #[test] fn when_properties_keyword_is_not_object() { + let ref mut schema = json_schema!({ + "properties": "Invalid" + }); + assert_eq!(schema.get_property_subschema_mut("foo"), None) + } + #[test] fn when_given_property_missing() { + let ref mut schema = json_schema!({ + "properties": { + "bar": { "title": "Bar property" } + } + }); + assert_eq!(schema.get_property_subschema_mut("foo"), None) + } + #[test] fn when_given_property_is_not_object() { + let ref mut schema = json_schema!({ + "properties": { + "foo": "invalid" + } + }); + assert_eq!(schema.get_property_subschema_mut("foo"), None) + } + #[test] fn when_given_property_is_object() { + let ref mut property = json!({ + "title": "Foo property" + }); + let ref mut schema = json_schema!({ + "properties": { + "foo": property + } + }); + assert_eq!( + schema.get_property_subschema_mut("foo").unwrap(), + property.as_object_mut().unwrap() + ) + } +}