diff --git a/Cargo.lock b/Cargo.lock index 81a1f8d0a..2042879b4 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" @@ -652,6 +658,7 @@ dependencies = [ "chrono", "clap", "derive_builder", + "dsc-lib-jsonschema", "dsc-lib-osinfo", "dsc-lib-security_context", "indicatif", @@ -681,6 +688,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 +1917,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 +3966,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/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" } } } 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; 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); + } + } + +}