diff --git a/.cargo/config.toml b/.cargo/config.toml index aaef8570d..511ebe358 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -39,3 +39,7 @@ rustflags = [ # The following is only needed for release builds [source.crates-io] replace-with = "POWERSHELL" + +# Enable running `cargo xtask ` +[alias] +xtask = "run --package xtask --" diff --git a/Cargo.lock b/Cargo.lock index a3716dff0..dd97b0354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4445,6 +4445,21 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "clap", + "clap_complete", + "dsc-lib", + "jsonschema", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "y2j" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index e76afff81..98ad9fe38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,8 @@ members = [ "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", - "y2j" + "y2j", + "xtask" ] # This value is modified by the `Set-DefaultWorkspaceMember` helper. # Be sure to use `Reset-DefaultWorkspaceMember` before committing to @@ -49,7 +50,8 @@ default-members = [ "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", - "y2j" + "y2j", + "xtask" ] [workspace.metadata.groups] @@ -77,7 +79,8 @@ Windows = [ "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", - "y2j" + "y2j", + "xtask" ] macOS = [ "dsc", @@ -96,7 +99,8 @@ macOS = [ "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", - "y2j" + "y2j", + "xtask" ] Linux = [ "dsc", @@ -115,7 +119,8 @@ Linux = [ "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", - "y2j" + "y2j", + "xtask" ] [profile.release] diff --git a/data.build.json b/data.build.json index 74d8242c2..1ed2b4cae 100644 --- a/data.build.json +++ b/data.build.json @@ -429,6 +429,13 @@ "Binaries": [ "y2j" ] + }, + { + "Name": "xtask", + "Kind": "CLI", + "RelativePath": "xtask", + "IsRust": true, + "TestOnly": true } ] } diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs index a66cee131..1d4b9e4a7 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs @@ -1,19 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use std::path::PathBuf; + use rust_i18n::t; -use schemars::{JsonSchema, Schema}; +use schemars::{JsonSchema, Schema, schema_for}; use thiserror::Error; -use crate::dsc_repo::{ +use crate::{dsc_repo::{ RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix, get_default_schema_uri, get_recognized_schema_uri, get_recognized_schema_uris, - get_recognized_uris_subschema -}; + get_recognized_uris_subschema, sync_bundled_resource_id_versions +}, schema_utility_extensions::SchemaUtilityExtensions}; /// Defines a reusable trait to simplify managing multiple versions of JSON Schemas for DSC /// structs and enums. @@ -68,6 +70,76 @@ pub trait DscRepoSchema : JsonSchema { ) } + /// Returns the default `$id` for the schema when exporting: + /// + /// The default export URI is for the canonical form of the schema in the `vNext` version + /// folder, with the GitHub URI prefix. + /// + /// The export URI should be set as the default `$id` for the type. The [`generate_exportable_schema()`] + /// function overrides this default when exporting schemas for various versions and forms. + /// + /// [`generate_exportable_schema()`]: DscRepoSchema::generate_exportable_schema + fn default_export_schema_id_uri() -> String { + Self::get_schema_id_uri( + RecognizedSchemaVersion::VNext, + SchemaForm::Canonical, + SchemaUriPrefix::Github + ) + } + + /// Returns the default URI for the `$schema` keyword. + /// + /// Use this to define the `$schema` keyword when deriving or manually implementing the + /// [`schemars::JsonSchema`] trait. + fn default_export_meta_schema_uri() -> String { + "https://json-schema.org/draft/2020-12/schema".to_string() + } + + /// Generates the JSON schema for a given version and form. This function is + /// useful for exporting the JSON Schema to disk. + fn generate_exportable_schema( + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm + ) -> Schema { + Self::generate_schema(schema_version, schema_form, SchemaUriPrefix::Github) + } + + /// Generates the JSON Schema for a given version, form, and URI prefix. + fn generate_schema( + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm, + schema_uri_prefix: SchemaUriPrefix + ) -> Schema { + // Start from the "full" schema, which includes definitions and VS Code keywords. + let mut schema = schema_for!(Self); + + // Set the ID for the schema + let id = Self::get_schema_id_uri( + schema_version, + schema_form, + schema_uri_prefix + ); + schema.set_id(id.as_str()); + schema.canonicalize_refs_and_defs_for_bundled_resources(); + sync_bundled_resource_id_versions(&mut schema); + + // Munge the schema for the given form + match schema_form { + SchemaForm::Canonical => { + crate::vscode::transforms::remove_vs_code_keywords(&mut schema); + crate::transforms::remove_bundled_schema_resources(&mut schema); + }, + SchemaForm::Bundled => { + crate::vscode::transforms::remove_vs_code_keywords(&mut schema); + }, + SchemaForm::VSCode => { + crate::vscode::transforms::vscodify_refs_and_defs(&mut schema); + }, + } + + schema + } + /// Returns the schema URI for a given version, form, and prefix. #[must_use] fn get_schema_id_uri( @@ -84,6 +156,44 @@ pub trait DscRepoSchema : JsonSchema { ) } + /// Sets the `$id` for a schema to the URI for a given version, form, and prefix. + fn set_schema_id_uri( + schema: &mut Schema, + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm, + uri_prefix: SchemaUriPrefix + ) { + schema.set_id(&Self::get_schema_id_uri(schema_version, schema_form, uri_prefix)); + } + + /// Returns the path for a schema relative to the `schemas` folder. + fn get_schema_relative_path( + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm + ) -> PathBuf { + let mut path = PathBuf::new(); + + path.push(schema_version.to_string()); + + let form_folder = schema_form.to_folder_prefix(); + let form_folder = form_folder.trim_end_matches("/"); + if !form_folder.is_empty() { + path.push(form_folder); + } + + for segment in Self::SCHEMA_FOLDER_PATH.split("/") { + path.push(segment); + } + + let file_name = format!( + "{}{}", Self::SCHEMA_FILE_BASE_NAME, + schema_form.to_extension() + ); + path.push(file_name); + + path + } + /// Returns the URI for the VS Code form of the schema with the default prefix for a given /// version. /// @@ -103,6 +213,14 @@ pub trait DscRepoSchema : JsonSchema { )) } + /// Sets the `$id` for a schema to the the URI for the enhanced form of the schema with the + /// default prefix for a given version. + fn set_enhanced_schema_id_uri(schema: &mut Schema, schema_version: RecognizedSchemaVersion) { + if let Some(id_uri) = Self::get_enhanced_schema_id_uri(schema_version) { + schema.set_id(&id_uri); + }; + } + /// Returns the URI for the canonical (non-bundled) form of the schema with the default /// prefix for a given version. #[must_use] @@ -116,6 +234,12 @@ pub trait DscRepoSchema : JsonSchema { ) } + /// Sets the `$id` for a schema to the the URI for the canonical form of the schema with the + /// default prefix for a given version. + fn set_canonical_schema_id_uri(schema: &mut Schema, schema_version: RecognizedSchemaVersion) { + schema.set_id(&Self::get_canonical_schema_id_uri(schema_version)); + } + /// Returns the URI for the bundled form of the schema with the default prefix for a given /// version. #[must_use] @@ -133,6 +257,14 @@ pub trait DscRepoSchema : JsonSchema { )) } + /// Sets the `$id` for a schema to the the URI for the bundled form of the schema with the + /// default prefix for a given version. + fn set_bundled_schema_id_uri(schema: &mut Schema, schema_version: RecognizedSchemaVersion) { + if let Some(id_uri) = Self::get_bundled_schema_id_uri(schema_version) { + schema.set_id(&id_uri); + }; + } + /// Returns the list of recognized schema URIs for the struct or enum. /// /// This convenience function generates a vector containing every recognized JSON Schema `$id` @@ -189,6 +321,20 @@ pub trait DscRepoSchema : JsonSchema { fn validate_schema_uri(&self) -> Result<(), UnrecognizedSchemaUri> { Ok(()) } + + /// Retuns a vector of the [`SchemaForm`]s that are valid for the type. + /// + /// The valid schema forms depend on the value of [`DscRepoSchema::SCHEMA_SHOULD_BUNDLE`]: + /// + /// - If the value is `true`, all schema forms are valid for the type. + /// - If the value is `false`, only [`SchemaForm::Canonical`] is valid for the type. + fn get_valid_schema_forms() -> Vec { + if Self::SCHEMA_SHOULD_BUNDLE { + vec![SchemaForm::VSCode, SchemaForm::Bundled, SchemaForm::Canonical] + } else { + vec![SchemaForm::Canonical] + } + } } /// Defines the error when a user-defined JSON Schema references an unrecognized schema URI. diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs index e4c9b50fb..b1437a05d 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs @@ -18,6 +18,8 @@ pub use schema_uri_prefix::SchemaUriPrefix; pub use dsc_lib_jsonschema_macros::DscRepoSchema; +use crate::schema_utility_extensions::SchemaUtilityExtensions; + /// Returns the constructed URI for a hosted DSC schema. /// /// This convenience function simplifies constructing the URIs for the various published schemas @@ -188,3 +190,70 @@ pub(crate) fn get_default_schema_form(should_bundle: bool) -> SchemaForm { SchemaForm::Canonical } } + +/// Retrieves the version segment from the `$id` keyword of a DSC repo schema. +pub(crate) fn get_schema_id_version(schema: &Schema) -> Option { + let Some(root_id) = schema.get_id() else { + return None; + }; + + // Remove the URI prefix and leading slash to get the URI relative to the `schemas` folder + let schema_folder_relative_id = root_id + .trim_start_matches(&SchemaUriPrefix::AkaDotMs.to_string()) + .trim_start_matches(&SchemaUriPrefix::Github.to_string()) + .trim_start_matches("/"); + // The version segment is the first segment of the relative URI + schema_folder_relative_id + .split("/") + .collect::>() + .first() + .map(std::string::ToString::to_string) +} + +/// Updates the version of bundled schema resources to match the root schema version. +/// +/// This transformer: +/// +/// 1. Parses the `$id` of the root schema to find the current version. +/// 1. Iterates over every bundled schema resource. +/// 1. If the bundled schema resource is for a DSC repo schema, the transformer updates the `$id` +/// of the bundled resource to use the same version as the root schema. +/// 1. After updating the ID for a bundled resource, the transformer updates all references to the +/// bundled schema resource. +pub(crate) fn sync_bundled_resource_id_versions(schema: &mut Schema) { + // First get the root ID so we can update the bundled dsc repo schema resources. + let lookup_schema = &schema.clone(); + let Some(schema_version_folder) = get_schema_id_version(lookup_schema) else { + return; + }; + let replacement_pattern = regex::Regex::new(r"schemas/v(Next|\d+(\.\d+){0,2})/").unwrap(); + let replacement_value = &format!("schemas/{schema_version_folder}/"); + + // Make sure we're working from canonicalized references and definitions: + schema.canonicalize_refs_and_defs_for_bundled_resources(); + + // Iterate over bundled schema resources, skipping bundled resources from outside of the + // repository. Replace the existing version segment with the canonical one for the `$id`. + for resource_id in lookup_schema.get_bundled_schema_resource_ids(true) { + let is_dsc_repo_schema = + resource_id.starts_with(&SchemaUriPrefix::Github.to_string()) || + resource_id.starts_with(&SchemaUriPrefix::AkaDotMs.to_string()); + if !is_dsc_repo_schema { + continue; + } + + let new_id = replacement_pattern.replace( + resource_id, + replacement_value + ); + // Munge the `$id` keyword in the definition subschema with the correct version folder. + let definition = schema.get_defs_subschema_from_id_mut(resource_id).unwrap(); + definition.set_id(&new_id); + schema.rename_defs_subschema_for_reference(&new_id, &new_id); + // Replace all references to the old ID with the new ID. + schema.replace_references(resource_id, &new_id); + } + + // Re-canonicalize the definition keys and references now that the IDs are updated. + schema.canonicalize_refs_and_defs_for_bundled_resources(); +} diff --git a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs index df095921e..431e4700d 100644 --- a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs +++ b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs @@ -1138,6 +1138,46 @@ pub trait SchemaUtilityExtensions { /// [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 Schema>; + /// Retrieves the key for a bundled schema resource in the `$defs` keyword if it exists. + /// + /// This method looks up a bundled schema resource by the value of its `$id` keyword. If the + /// schema resource is defined in the `$defs` keyword, this function returns a reference to + /// the key and otherwise [`None`]. + /// + /// # Examples + /// + /// The following snippet shows how you can retrieve the key for a bundled schema resource from + /// the `$defs` object in a schema. + /// + /// ```rust + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// use schemars::json_schema; + /// + /// let foo_id = &"https://contoso.com/schemas/properties/foo.json".to_string(); + /// let bar_id = &"https://contoso.com/schemas/properties/bar.json".to_string(); + /// let schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "properties": { + /// "foo": { "$ref": "/schemas/properties/foo.json" }, + /// }, + /// "$defs": { + /// "foo": { + /// "$id": foo_id + /// }, + /// }, + /// }); + /// + /// assert_eq!( + /// schema.get_bundled_schema_resource_defs_key(foo_id), + /// Some(&"foo".to_string()) + /// ); + /// + /// assert_eq!( + /// schema.get_bundled_schema_resource_defs_key(bar_id), + /// None + /// ); + /// ``` + fn get_bundled_schema_resource_defs_key(&self, id: &String) -> Option<&String>; /// 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. /// @@ -1175,6 +1215,109 @@ pub trait SchemaUtilityExtensions { /// ) /// ``` fn insert_defs_subschema(&mut self, definition_key: &str, definition_value: &Map) -> Option>; + /// Removes every entry in the `$defs` keyword that contains a bundled schema resource. + /// + /// Bundled schema resources are any definition in the `$defs` keyword that specifies the `$id` + /// keyword. + /// + /// This method doesn't update any references to the bundled schema resources. If the reference + /// to the bundled resource uses the URI fragment pointer to the `$defs` keyword, those + /// references will be broken. If the references point to the bundled schema resource by + /// absolute or relative URI, those references are still valid. + /// + /// After removing bundled schema resources from the `$defs` keyword, the method removes the + /// `$defs` keyword if it's empty. + /// + /// # Examples + /// + /// The following snippet shows how this method removes bundled schema resources. + /// + /// ```rust + /// use schemars::json_schema; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// # use pretty_assertions::assert_eq; + /// + /// let schema = &mut json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "properties": { + /// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, + /// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, + /// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, + /// }, + /// "$defs": { + /// "https://contoso.com/schemas/definitions/foo.json": { + /// "$id": "https://contoso.com/schemas/definitions/foo.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "https://contoso.com/schemas/definitions/bar.json": { + /// "$id": "https://contoso.com/schemas/definitions/bar.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "https://tstoys.com/schemas/baz.json": { + /// "$id": "https://tstoys.com/schemas/baz.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// }, + /// }); + /// + /// schema.remove_bundled_schema_resources(); + /// let actual = serde_json::to_string_pretty(schema).unwrap(); + /// + /// let expected = serde_json::to_string_pretty(&json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "properties": { + /// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, + /// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, + /// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, + /// } + /// })).unwrap(); + /// + /// assert_eq!(actual, expected); + /// ``` + fn remove_bundled_schema_resources(&mut self); + /// Replaces an existing key in the `$defs` keyword with a new key. + /// + /// This enables canonicalizing the keys for bundled schema resources. It only renames the + /// definition. It doesn't update the references to that definition. + /// + /// # Examples + /// + /// The following example shows how you can rename a definition. + /// + /// ```rust + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// use schemars::json_schema; + /// + /// let schema = &mut json_schema!({ + /// "type": "object", + /// "properties": { + /// "foo": { "$ref": "#/$defs/foo" }, + /// }, + /// "$defs": { + /// "foo": { "type": "string" } + /// } + /// }); + /// schema.rename_defs_subschema("foo", "bar"); + /// + /// let actual = serde_json::to_string_pretty(schema).unwrap(); + /// let expected = serde_json::to_string_pretty(&json_schema!({ + /// "type": "object", + /// "properties": { + /// "foo": { "$ref": "#/$defs/foo" }, + /// }, + /// "$defs": { + /// "bar": { "type": "string" } + /// } + /// })).unwrap(); + /// + /// pretty_assertions::assert_eq!(actual, expected); + /// ``` + fn rename_defs_subschema(&mut self, old_key: &str, new_key: &str); /// Looks up a subschema in the `$defs` keyword by reference and, if it exists, renames the /// _key_ for the definition. /// @@ -1215,7 +1358,6 @@ pub trait SchemaUtilityExtensions { /// ) /// ``` 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. /// @@ -1369,6 +1511,69 @@ pub trait SchemaUtilityExtensions { /// ) /// ``` fn get_references(&self) -> HashSet<&str>; + /// Retrieves a [`HashSet`] of the `$ref` keyword values that point to a bundled schema + /// resource. + /// + /// The lookup for the bundled schema resource is by `$id`. This method discovers the bundled + /// schema resource in the `$defs` keyword, looking for a definition with the given ID URI. + /// Then the method recursively searches the schema for references to the bundled resource by: + /// + /// - Fragment pointer URI to the definition, like `#/$defs/foo` + /// - Absolute URI, like `https://contoso.com/schemas/example/foo.json` + /// - Site-relative URI if the bundled schema resource and root schema share the same host for + /// their `$id` keywords like, `/schemas/example/foo.json`. + /// + /// # Examples + /// + /// The following snippet shows how the method returns the unique set of references to a bundled + /// schema resource. + /// + /// ```rust + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// use schemars::json_schema; + /// + /// let schema = json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "type": "object", + /// "properties": { + /// "foo": { "$ref": "#/$defs/foo" }, + /// "bar": { "$ref": "https://contoso.com/schemas/properties/bar.json" }, + /// "baz": { "$ref": "/schemas/properties/baz.json" }, + /// }, + /// "$defs": { + /// "foo": { + /// "$id": "https://contoso.com/schemas/properties/foo.json", + /// "type": "string", + /// "pattern": r"^\S+$", + /// }, + /// "bar": { + /// "$id": "https://contoso.com/schemas/properties/bar.json", + /// "type": "string", + /// "pattern": r"^\S+$", + /// }, + /// "baz": { + /// "$id": "https://contoso.com/schemas/properties/bar.json", + /// "type": "object", + /// "properties": { + /// "nestedFoo": { "$ref": "https://contoso.com/schemas/properties/foo.json" }, + /// "nestedBar": { "$ref": "#/$defs/bar" }, + /// }, + /// }, + /// + /// }, + /// }); + /// + /// let actual_foo = schema.get_references_to_bundled_schema_resource( + /// "https://contoso.com/schemas/properties/foo.json" + /// ); + /// let expected_foo: std::collections::HashSet<&str> = vec![ + /// "#/$defs/foo", + /// "https://contoso.com/schemas/properties/foo.json" + /// ].iter().cloned().collect(); + /// + /// pretty_assertions::assert_eq!(actual_foo, expected_foo); + /// ``` + fn get_references_to_bundled_schema_resource(&self, resource_id: &str) -> HashSet<&str>; /// Searches the schema for instances of the `$ref` keyword defined as a /// given value and replaces each instance with a new value. /// @@ -1459,6 +1664,127 @@ pub trait SchemaUtilityExtensions { /// assert_eq!(schema.reference_is_for_bundled_resource("#/$defs/invalid"), false); /// ``` fn reference_is_for_bundled_resource(&self, reference: &str) -> bool; + /// Returns the schema as a [`Value`] with the keys pre-sorted lexicographically. + /// + /// If the schema can't be converted to an object, this method returns the schema as a + /// [`Value`] without any modifications. + /// + /// # Examples + /// + /// ```rust + /// use schemars::json_schema; + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// use serde_json::{json, to_string_pretty}; + /// // define a schema with randomly sorted keywords: + /// let schema = &mut json_schema!({ + /// "title": "Tag", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "$id": "https://contoso.com/schemas/tag.json", + /// "type": "string", + /// "pattern": r"^\w+$", + /// "description": "Defines a metadata tag", + /// }); + /// let stable_value = &schema.to_value_with_stable_order(); + /// let expected_value = &json!({ + /// "$id": "https://contoso.com/schemas/tag.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "description": "Defines a metadata tag", + /// "pattern": r"^\w+$", + /// "title": "Tag", + /// "type": "string", + /// }); + /// + /// assert_eq!( + /// to_string_pretty(stable_value).unwrap(), + /// to_string_pretty(expected_value).unwrap() + /// ); + /// ``` + fn to_value_with_stable_order(&self) -> Value; + /// Canonicalizes the references to and definitions for bundled schema resources. + /// + /// Bundled schema resources are any definition in the `$defs` keyword that specifies the `$id` + /// keyword. + /// + /// This method: + /// + /// 1. Standardizes the key for bundled schema resources to the ID URI for that resource. When + /// a JSON Schema client resolves bundled schema resources. + /// 1. Replaces _all_ references to the bundled schema resource with the ID for that resource. + /// This converts all fragment pointer references, like `#/$defs/foo`, to the absolute URI + /// for the schema resource. Similarly, any relative URIs to the bundled resource, like + /// `/schemas/foo.json`, are also updated to the absolute URI. + /// + /// This standardizes the structure and references for bundled schema resources to enable more + /// consistent operations on them. + /// + /// # Examples + /// + /// The following snippet shows how this method transforms the schema. + /// + /// ```rust + /// use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// use schemars::json_schema; + /// # use pretty_assertions::assert_eq; + /// + /// let schema = &mut json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "properties": { + /// "foo": { "$ref": "#/$defs/foo" }, + /// "bar": { "$ref": "/schemas/definitions/bar.json" }, + /// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, + /// }, + /// "$defs": { + /// "foo": { + /// "$id": "https://contoso.com/schemas/definitions/foo.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "bar": { + /// "$id": "https://contoso.com/schemas/definitions/bar.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "baz": { + /// "$id": "https://tstoys.com/schemas/baz.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// }, + /// }); + /// schema.canonicalize_refs_and_defs_for_bundled_resources(); + /// let actual = serde_json::to_string_pretty(schema).unwrap(); + /// + /// let expected = serde_json::to_string_pretty(&json_schema!({ + /// "$id": "https://contoso.com/schemas/example.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "properties": { + /// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, + /// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, + /// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, + /// }, + /// "$defs": { + /// "https://contoso.com/schemas/definitions/foo.json": { + /// "$id": "https://contoso.com/schemas/definitions/foo.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "https://contoso.com/schemas/definitions/bar.json": { + /// "$id": "https://contoso.com/schemas/definitions/bar.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// "https://tstoys.com/schemas/baz.json": { + /// "$id": "https://tstoys.com/schemas/baz.json", + /// "$schema": "https://json-schema.org/draft/2020-12/schema", + /// "type": "string", + /// }, + /// }, + /// })).unwrap(); + /// + /// assert_eq!(actual, expected); + /// ``` + fn canonicalize_refs_and_defs_for_bundled_resources(&mut self); } impl SchemaUtilityExtensions for Schema { @@ -1599,6 +1925,25 @@ impl SchemaUtilityExtensions for Schema { None } + fn get_bundled_schema_resource_defs_key(&self, id: &String) -> Option<&String> { + let Some(defs) = self.get_defs() else { + return None; + }; + + for (def_key, def_value) in defs { + let Ok(def_subschema) = Schema::try_from(def_value.clone()) else { + continue; + }; + + if let Some(def_id) = def_subschema.get_id() { + if def_id == id.as_str() { + return Some(def_key); + } + } + } + + None + } fn insert_defs_subschema( &mut self, definition_key: &str, @@ -1620,6 +1965,39 @@ impl SchemaUtilityExtensions for Schema { None } } + fn remove_bundled_schema_resources(&mut self) { + let lookup_schema = self.clone(); + let Some(defs) = self.get_defs_mut() else { + return; + }; + + for bundled_id in lookup_schema.get_bundled_schema_resource_ids(true) { + let Some(bundled_key) = lookup_schema.get_bundled_schema_resource_defs_key( + &bundled_id.to_string() + ) else { + continue; + }; + + defs.remove(bundled_key); + } + + if self.get_defs_mut().is_some_and(|defs| defs.is_empty()) { + self.remove("$defs"); + } + } + fn rename_defs_subschema(&mut self, old_key: &str, new_key: &str) { + let Some(defs) = self.get_defs_mut() else { + return; + }; + + *defs = defs.iter_mut().map(|(k, v)| { + if k.as_str() == old_key { + (new_key.to_string(), v.clone()) + } else { + (k.clone(), v.clone()) + } + }).collect(); + } 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. @@ -1769,6 +2147,29 @@ impl SchemaUtilityExtensions for Schema { references } + fn get_references_to_bundled_schema_resource(&self, resource_id: &str) -> HashSet<&str> { + let Some(def_key) = self.get_bundled_schema_resource_defs_key(&resource_id.to_string()) else { + return HashSet::new(); + }; + + let matching_references = &mut vec![ + format!("#/$defs/{def_key}"), + resource_id.to_string(), + ]; + + if let Some(schema_id_url) = self.get_id_as_url() { + let resource_id_url = url::Url::parse(resource_id) + .expect("$id keyword values should always parse as URLs"); + if schema_id_url.host() == resource_id_url.host() { + matching_references.push(resource_id_url[Position::BeforePath..].to_string()); + } + } + + self.get_references() + .into_iter() + .filter(|reference| matching_references.contains(&&reference.to_string())) + .collect() + } fn replace_references(&mut self, find_value: &str, new_value: &str) { if self.get_keyword_as_str("$ref").is_some_and(|r| r == find_value) { self.insert("$ref".to_string(), Value::String(new_value.to_string())); @@ -1828,4 +2229,29 @@ impl SchemaUtilityExtensions for Schema { fn reference_is_for_bundled_resource(&self, reference: &str) -> bool { self.get_defs_subschema_from_reference(reference).is_some() } + fn to_value_with_stable_order(&self) -> Value { + let Some(map) = self.as_object() else { + return self.clone().to_value(); + }; + + let mut stable_map = map.clone(); + stable_map.sort_keys(); + stable_map.values_mut().for_each(Value::sort_all_objects); + + serde_json::json!(stable_map) + } + fn canonicalize_refs_and_defs_for_bundled_resources(&mut self) { + let lookup_schema = self.clone(); + let bundled_ids = lookup_schema.get_bundled_schema_resource_ids(true); + for bundled_id in bundled_ids { + let Some(defs_key) = lookup_schema.get_bundled_schema_resource_defs_key(&bundled_id.to_string()) else { + continue; + }; + let reference_lookup = format!("#/$defs/{defs_key}"); + for reference in lookup_schema.get_references_to_bundled_schema_resource(bundled_id) { + self.replace_references(reference, bundled_id); + } + self.rename_defs_subschema_for_reference(&reference_lookup, bundled_id); + } + } } diff --git a/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions.rs index 8c845da27..a127132b4 100644 --- a/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions.rs +++ b/lib/dsc-lib-jsonschema/src/tests/schema_utility_extensions.rs @@ -690,6 +690,75 @@ test_cases_for_get_keyword_as_mut!( } } +#[cfg(test)] mod get_bundled_schema_resource_defs_key { + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_not_defined() { + let schema = &json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + } + }); + pretty_assertions::assert_eq!( + schema.get_bundled_schema_resource_defs_key( + &"https://contoso.com/schemas/properties/bar.json".to_string() + ), + None + ); + } + + #[test] fn when_defs_not_contains_bundled_resource() { + let schema = &json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + }, + }); + pretty_assertions::assert_eq!( + schema.get_bundled_schema_resource_defs_key( + &"https://contoso.com/schemas/properties/bar.json".to_string() + ), + None + ); + } + + #[test] fn when_defs_contains_bundled_resource() { + let schema = &json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + }, + }); + pretty_assertions::assert_eq!( + schema.get_bundled_schema_resource_defs_key( + &"https://contoso.com/schemas/properties/foo.json".to_string() + ), + Some(&"foo".to_string()) + ); + } +} + #[cfg(test)] mod insert_defs_subschema { #[test] fn when_defs_keyword_missing() {} #[test] fn when_defs_keyword_is_not_object() {} @@ -697,6 +766,121 @@ test_cases_for_get_keyword_as_mut!( #[test] fn when_defs_keyword_is_object_and_entry_exists() {} } +#[cfg(test)] mod remove_bundled_schema_resources { + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_not_defined() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + } + }); + let expected = serde_json::to_string_pretty(schema).unwrap(); + schema.remove_bundled_schema_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn when_defs_not_contains_bundled_resources() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { "type": "string" }, + }, + }); + let expected = serde_json::to_string_pretty(schema).unwrap(); + schema.remove_bundled_schema_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn when_defs_contains_some_bundled_resources() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { "type": "string" }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + }, + }); + schema.remove_bundled_schema_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + let expected = serde_json::to_string_pretty(&json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { "type": "string" }, + }, + })).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn when_all_defs_are_bundled_resources() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + }, + }); + schema.remove_bundled_schema_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + let expected = serde_json::to_string_pretty(&json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + })).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } +} + #[cfg(test)] mod rename_defs_subschema_for_reference { use pretty_assertions::assert_eq; use schemars::json_schema; @@ -983,3 +1167,162 @@ test_cases_for_get_keyword_as_mut!( ) } } + +#[cfg(test)] mod get_references_to_bundled_schema_resource { + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_defs_not_defined() { + let schema = &json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "fragment_uri": { "$ref": "#/$defs/foo" }, + "absolute_uri": { "$ref": "https://contoso.com/schemas/properties/foo.json" }, + "relative_uri": { "$ref": "/schemas/properties/foo.json" }, + }, + }); + + assert!( + schema.get_references_to_bundled_schema_resource( + "https://contoso.com/schemas/properties/foo.json" + ).is_empty() + ); + } + + #[test] fn when_bundled_resource_defined() { + let schema = &json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "fragment_uri": { "$ref": "#/$defs/foo" }, + "absolute_uri": { "$ref": "https://contoso.com/schemas/properties/foo.json" }, + "relative_uri": { "$ref": "/schemas/properties/foo.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + }, + }); + + let expected: std::collections::HashSet<&str> = vec![ + "#/$defs/foo", + "https://contoso.com/schemas/properties/foo.json", + "/schemas/properties/foo.json", + ].iter().cloned().collect(); + + pretty_assertions::assert_eq!( + schema.get_references_to_bundled_schema_resource( + "https://contoso.com/schemas/properties/foo.json" + ), + expected + ) + } +} + +#[test] fn to_value_with_stable_order() { + let schema = &schemars::json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contoso.com/schemas/example.json", + "type": "object", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "type": "boolean" }, + }, + "$defs": { + "foo": { "type": "string" }, + }, + }); + let actual = &schema.to_value_with_stable_order(); + let expected = &serde_json::json!({ + "$defs": { + "foo": { "type": "string" }, + }, + "$id": "https://contoso.com/schemas/example.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "bar": { "type": "boolean" }, + "foo": { "$ref": "#/$defs/foo" }, + }, + "type": "object", + }); + + pretty_assertions::assert_eq!( + serde_json::to_string_pretty(actual).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ) +} + +#[cfg(test)] mod canonicalize_refs_and_defs_for_bundled_resources { + use schemars::json_schema; + + use crate::schema_utility_extensions::SchemaUtilityExtensions; + + #[test] fn when_schema_has_no_bundled_resources() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + }); + let expected = serde_json::to_string_pretty(schema).unwrap(); + schema.canonicalize_refs_and_defs_for_bundled_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn when_schema_has_bundled_resources() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + }, + }); + schema.canonicalize_refs_and_defs_for_bundled_resources(); + let actual = serde_json::to_string_pretty(schema).unwrap(); + let expected = serde_json::to_string_pretty(&json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "https://contoso.com/schemas/properties/foo.json" }, + "bar": { "$ref": "https://contoso.com/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "https://contoso.com/schemas/properties/foo.json": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + "https://contoso.com/schemas/properties/bar.json": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "https://contoso.com/schemas/properties/baz.json": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + }, + })).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); + } +} \ No newline at end of file diff --git a/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs b/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs index 2c98a0afa..283df1f7f 100644 --- a/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs +++ b/lib/dsc-lib-jsonschema/src/tests/vscode/mod.rs @@ -6,5 +6,6 @@ #[cfg(test)] mod keywords; #[cfg(test)] mod dialect; #[cfg(test)] mod schema_extensions; +#[cfg(test)] mod transforms; #[cfg(test)] mod validation_options_extensions; #[cfg(test)] mod vocabulary; diff --git a/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/mod.rs new file mode 100644 index 000000000..40b70d05e --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/mod.rs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] mod remove_vs_code_keywords; +#[cfg(test)] mod urlencode_defs_keys; diff --git a/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/remove_vs_code_keywords.rs b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/remove_vs_code_keywords.rs new file mode 100644 index 000000000..c2b25bdb7 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/remove_vs_code_keywords.rs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::json_schema; + +use crate::vscode::transforms::remove_vs_code_keywords; + +#[test] fn when_schema_has_no_vs_code_keywords() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + } + }); + let expected = serde_json::to_string_pretty(schema).unwrap(); + + remove_vs_code_keywords(schema); + let actual = serde_json::to_string_pretty(schema).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); +} + +#[test] fn when_schema_has_vs_code_keywords() { + let schema = &mut json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "markdownDescription": "VS Code only text.", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + "markdownDescription": "VS Code text for `foo` property.", + }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + "markdownDescription": "VS Code text for `bar` property.", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + "markdownDescription": "VS Code text for `baz` property.", + }, + } + }); + remove_vs_code_keywords(schema); + let actual = serde_json::to_string_pretty(schema).unwrap(); + + let expected = serde_json::to_string_pretty(&json_schema!({ + "$id": "https://contoso.com/schemas/example.json", + "properties": { + "foo": { "$ref": "#/$defs/foo" }, + "bar": { "$ref": "/schemas/properties/bar.json" }, + "baz": { "$ref": "https://contoso.com/schemas/properties/baz.json" }, + }, + "$defs": { + "foo": { + "$id": "https://contoso.com/schemas/properties/foo.json", + "type": "string", + }, + "bar": { + "$id": "https://contoso.com/schemas/properties/bar.json", + "type": "string", + }, + "baz": { + "$id": "https://contoso.com/schemas/properties/baz.json", + "type": "string", + }, + } + })).unwrap(); + + pretty_assertions::assert_eq!(actual, expected); +} diff --git a/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/urlencode_defs_keys.rs b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/urlencode_defs_keys.rs new file mode 100644 index 000000000..fc36ab244 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/vscode/transforms/urlencode_defs_keys.rs @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. diff --git a/lib/dsc-lib-jsonschema/src/transforms/canonicalize_refs_and_defs.rs b/lib/dsc-lib-jsonschema/src/transforms/canonicalize_refs_and_defs.rs new file mode 100644 index 000000000..1537a423b --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/canonicalize_refs_and_defs.rs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::Schema; + +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +/// Canonicalizes the references to and definitions for bundled schema resources. +/// +/// Bundled schema resources are any definition in the `$defs` keyword that specifies the `$id` +/// keyword. +/// +/// This transformer: +/// +/// 1. Standardizes the key for bundled schema resources to the ID URI for that resource. When +/// a JSON Schema client resolves bundled schema resources. +/// 1. Replaces _all_ references to the bundled schema resource with the ID for that resource. +/// This converts all fragment pointer references, like `#/$defs/foo`, to the absolute URI +/// for the schema resource. Similarly, any relative URIs to the bundled resource, like +/// `/schemas/foo.json`, are also updated to the absolute URI. +/// +/// This standardizes the structure and references for bundled schema resources to enable more +/// consistent operations on them. +/// +/// # Examples +/// +/// The following snippet shows how this method transforms the schema. +/// +/// ```rust +/// use dsc_lib_jsonschema::transforms::canonicalize_refs_and_defs; +/// use schemars::json_schema; +/// # use pretty_assertions::assert_eq; +/// +/// let schema = &mut json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "#/$defs/foo" }, +/// "bar": { "$ref": "/schemas/definitions/bar.json" }, +/// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, +/// }, +/// "$defs": { +/// "foo": { +/// "$id": "https://contoso.com/schemas/definitions/foo.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "bar": { +/// "$id": "https://contoso.com/schemas/definitions/bar.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "baz": { +/// "$id": "https://tstoys.com/schemas/baz.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// }, +/// }); +/// canonicalize_refs_and_defs(schema); +/// let actual = serde_json::to_string_pretty(schema).unwrap(); +/// +/// let expected = serde_json::to_string_pretty(&json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, +/// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, +/// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, +/// }, +/// "$defs": { +/// "https://contoso.com/schemas/definitions/foo.json": { +/// "$id": "https://contoso.com/schemas/definitions/foo.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://contoso.com/schemas/definitions/bar.json": { +/// "$id": "https://contoso.com/schemas/definitions/bar.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://tstoys.com/schemas/baz.json": { +/// "$id": "https://tstoys.com/schemas/baz.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// }, +/// })).unwrap(); +/// +/// assert_eq!(actual, expected); +/// ``` +pub fn canonicalize_refs_and_defs(schema: &mut Schema) { + schema.canonicalize_refs_and_defs_for_bundled_resources(); +} diff --git a/lib/dsc-lib-jsonschema/src/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/transforms/mod.rs index c93ac5943..98be43c0e 100644 --- a/lib/dsc-lib-jsonschema/src/transforms/mod.rs +++ b/lib/dsc-lib-jsonschema/src/transforms/mod.rs @@ -6,7 +6,11 @@ //! //! [`Transform`]: schemars::transform +mod canonicalize_refs_and_defs; +pub use canonicalize_refs_and_defs::canonicalize_refs_and_defs; mod idiomaticize_externally_tagged_enum; pub use idiomaticize_externally_tagged_enum::idiomaticize_externally_tagged_enum; mod idiomaticize_string_enum; pub use idiomaticize_string_enum::idiomaticize_string_enum; +mod remove_bundled_schema_resources; +pub use remove_bundled_schema_resources::remove_bundled_schema_resources; diff --git a/lib/dsc-lib-jsonschema/src/transforms/remove_bundled_schema_resources.rs b/lib/dsc-lib-jsonschema/src/transforms/remove_bundled_schema_resources.rs new file mode 100644 index 000000000..b5a2cef91 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/remove_bundled_schema_resources.rs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::Schema; + +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +/// Removes every entry in the `$defs` keyword that contains a bundled schema resource. +/// +/// Bundled schema resources are any definition in the `$defs` keyword that specifies the `$id` +/// keyword. +/// +/// This transform doesn't update any references to the bundled schema resources. If the +/// reference to the bundled resource uses the URI fragment pointer to the `$defs` keyword, those +/// references will be broken. If the references point to the bundled schema resource by absolute +/// or relative URI, those references are still valid. +/// +/// After removing bundled schema resources from the `$defs` keyword, the transform removes the +/// `$defs` keyword if it is empty. +/// +/// # Examples +/// +/// The following snippet shows how this transform removes bundled schema resources. +/// +/// ```rust +/// use schemars::json_schema; +/// use dsc_lib_jsonschema::transforms::remove_bundled_schema_resources; +/// # use pretty_assertions::assert_eq; +/// +/// let schema = &mut json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, +/// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, +/// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, +/// }, +/// "$defs": { +/// "https://contoso.com/schemas/definitions/foo.json": { +/// "$id": "https://contoso.com/schemas/definitions/foo.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://contoso.com/schemas/definitions/bar.json": { +/// "$id": "https://contoso.com/schemas/definitions/bar.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://tstoys.com/schemas/baz.json": { +/// "$id": "https://tstoys.com/schemas/baz.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// }, +/// }); +/// +/// remove_bundled_schema_resources(schema); +/// let actual = serde_json::to_string_pretty(schema).unwrap(); +/// +/// let expected = serde_json::to_string_pretty(&json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, +/// "bar": { "$ref": "https://contoso.com/schemas/definitions/bar.json" }, +/// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, +/// } +/// })).unwrap(); +/// +/// assert_eq!(actual, expected); +/// ``` +pub fn remove_bundled_schema_resources(schema: &mut Schema) { + schema.remove_bundled_schema_resources(); +} diff --git a/lib/dsc-lib-jsonschema/src/vscode/mod.rs b/lib/dsc-lib-jsonschema/src/vscode/mod.rs index bcfaf1338..9b388b38d 100644 --- a/lib/dsc-lib-jsonschema/src/vscode/mod.rs +++ b/lib/dsc-lib-jsonschema/src/vscode/mod.rs @@ -5,7 +5,9 @@ pub mod dialect; pub mod keywords; +pub mod transforms; pub mod vocabulary; + mod schema_extensions; pub use schema_extensions::VSCodeSchemaExtensions; mod validation_options_extensions; diff --git a/lib/dsc-lib-jsonschema/src/vscode/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/vscode/transforms/mod.rs new file mode 100644 index 000000000..a142f6646 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/vscode/transforms/mod.rs @@ -0,0 +1,4 @@ +mod remove_vs_code_keywords; +pub use remove_vs_code_keywords::remove_vs_code_keywords; +mod urlencode_defs_keys; +pub use urlencode_defs_keys::vscodify_refs_and_defs; diff --git a/lib/dsc-lib-jsonschema/src/vscode/transforms/remove_vs_code_keywords.rs b/lib/dsc-lib-jsonschema/src/vscode/transforms/remove_vs_code_keywords.rs new file mode 100644 index 000000000..fcaab373d --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/vscode/transforms/remove_vs_code_keywords.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::{Schema, transform::transform_subschemas}; + +use crate::vscode::keywords::VSCodeKeyword; + +/// Recursively removes all VS Code keywords from the schema. +/// +/// This transformer recurses through every level of the schema to find every defined +/// [`VSCodeKeyword`] and removes them. While the VS Code keywords are annotation keywords that +/// don't affect the validation for a schema, some validation libraries may error on the inclusion +/// of unknown keywords. Removing them from the canonical and bundled forms for a schema removes +/// that error path. +/// +/// Further, removing the VS Code keywords makes for a much smaller schema, since many of the VS +/// Code keywords provide extended documentation and VS Code specific functionality, like snippets. +/// This can reduce the time required to retrieve and parse the schemas, in addition to minimizing +/// network costs. +/// +/// # Examples +/// +/// The following example shows how you can use the transformer to remove all VS Code keywords from +/// a given schema. +/// +/// ```rust +/// use pretty_assertions::assert_eq; +/// use schemars::json_schema; +/// use dsc_lib_jsonschema::vscode::transforms::remove_vs_code_keywords; +/// +/// let schema = &mut json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "title": "Example schema", +/// "description": "This example schema describes an object.", +/// "markdownDescription": "This _markdown_ text is for VS Code only.", +/// "properties": { +/// "name": { "$ref": "#/$defs/name" }, +/// "state": { +/// "title": "Feature state", +/// "description": "Defines whether the feature should be enabled, disabled, or toggled.", +/// "markdownDescription": concat!( +/// "Defines whether feature should be enabled, disabled, or toggled. ", +/// "The `toggle` option should only be used in testing and development. ", +/// "Setting a feature to `toggle` will cause it to change on every `set` operation." +/// ), +/// "type": "string", +/// "enum": ["on", "off", "toggle"], +/// "markdownEnumDescriptions": [ +/// "Sets the named feature to `on`, enabling it.", +/// "Sets the named feature to `off`, disabling it.", +/// "Toggles the named feature, enabling it if disabled and disabling it if enabled.", +/// ], +/// }, +/// }, +/// "$defs": { +/// "name": { +/// "title": "Feature name", +/// "description": "Defines the feature to manage.", +/// "markdownDescription": concat!( +/// "Defines the feature to manage by its name. ", +/// "For a full list of available features and their names, see ", +/// "[Feature list](https://contoso.com/example/features)." +/// ), +/// "type": "string", +/// }, +/// }, +/// }); +/// +/// remove_vs_code_keywords(schema); +/// +/// let actual = serde_json::to_string_pretty(schema).unwrap(); +/// let expected = serde_json::to_string_pretty(&json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "title": "Example schema", +/// "description": "This example schema describes an object.", +/// "properties": { +/// "name": { "$ref": "#/$defs/name" }, +/// "state": { +/// "title": "Feature state", +/// "description": "Defines whether the feature should be enabled, disabled, or toggled.", +/// "type": "string", +/// "enum": ["on", "off", "toggle"], +/// }, +/// }, +/// "$defs": { +/// "name": { +/// "title": "Feature name", +/// "description": "Defines the feature to manage.", +/// "type": "string", +/// }, +/// }, +/// })).unwrap(); +/// +/// assert_eq!(actual, expected); +/// ``` +pub fn remove_vs_code_keywords(schema: &mut Schema) { + for keyword in VSCodeKeyword::ALL { + schema.remove(keyword); + } + + transform_subschemas(&mut remove_vs_code_keywords, schema); +} diff --git a/lib/dsc-lib-jsonschema/src/vscode/transforms/urlencode_defs_keys.rs b/lib/dsc-lib-jsonschema/src/vscode/transforms/urlencode_defs_keys.rs new file mode 100644 index 000000000..b89144ac8 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/vscode/transforms/urlencode_defs_keys.rs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::Schema; + +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +/// Replaces the keys in the `$defs` keyword with their urlencoded equivalent and updates all +/// references to the subschema to use the `#/$defs/...` pointer to the renamed definition key. +/// +/// The VS Code extension for JSON Schema does not correctly discover canonically bundled schema +/// resources. This transformer: +/// +/// 1. Finds all bundled schema resources. +/// 1. Compares the key for that bundled schema resource in `$defs` to the same key after URL +/// encoding. +/// 1. If the current key and url encoded key are the same, the transformer doesn't modify the +/// schema for that definition. +/// 1. If the current key and URL encoded key are different, the transformer renames the definition +/// to the new key and replaces _all_ references to the definition with the `#/$defs/` +/// pointer. This modifies references to the site-relative URI, absolute URI, and prior pointer +/// value. +/// +/// # Examples +/// +/// This example shows how the transformer modifies a canonically bundled schema to enable VS Code +/// to resolve references to bundled schema resources. +/// +/// ```rust +/// use schemars::json_schema; +/// use pretty_assertions::assert_eq; +/// use dsc_lib_jsonschema::vscode::transforms::vscodify_refs_and_defs; +/// +/// let schema = &mut json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "https://contoso.com/schemas/definitions/foo.json" }, +/// "bar": { "$ref": "/schemas/definitions/bar.json" }, +/// "baz": { "$ref": "https://tstoys.com/schemas/baz.json" }, +/// }, +/// "$defs": { +/// "https://contoso.com/schemas/definitions/foo.json": { +/// "$id": "https://contoso.com/schemas/definitions/foo.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://contoso.com/schemas/definitions/bar.json": { +/// "$id": "https://contoso.com/schemas/definitions/bar.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https://tstoys.com/schemas/baz.json": { +/// "$id": "https://tstoys.com/schemas/baz.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// }, +/// }); +/// +/// vscodify_refs_and_defs(schema); +/// let actual = serde_json::to_string_pretty(schema).unwrap(); +/// +/// let expected =serde_json::to_string_pretty(&json_schema!({ +/// "$id": "https://contoso.com/schemas/example.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "properties": { +/// "foo": { "$ref": "#/$defs/https%3A%2F%2Fcontoso.com%2Fschemas%2Fdefinitions%2Ffoo.json" }, +/// "bar": { "$ref": "#/$defs/https%3A%2F%2Fcontoso.com%2Fschemas%2Fdefinitions%2Fbar.json" }, +/// "baz": { "$ref": "#/$defs/https%3A%2F%2Ftstoys.com%2Fschemas%2Fbaz.json" }, +/// }, +/// "$defs": { +/// "https%3A%2F%2Fcontoso.com%2Fschemas%2Fdefinitions%2Ffoo.json": { +/// "$id": "https://contoso.com/schemas/definitions/foo.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https%3A%2F%2Fcontoso.com%2Fschemas%2Fdefinitions%2Fbar.json": { +/// "$id": "https://contoso.com/schemas/definitions/bar.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// "https%3A%2F%2Ftstoys.com%2Fschemas%2Fbaz.json": { +/// "$id": "https://tstoys.com/schemas/baz.json", +/// "$schema": "https://json-schema.org/draft/2020-12/schema", +/// "type": "string", +/// }, +/// }, +/// })).unwrap(); +/// +/// assert_eq!(actual, expected); +/// ``` +pub fn vscodify_refs_and_defs(schema: &mut Schema) { + let lookup_schema = schema.clone(); + for bundled_resource_id in lookup_schema.get_bundled_schema_resource_ids(true) { + let Some(def_key) = lookup_schema + .get_bundled_schema_resource_defs_key(&bundled_resource_id.to_string()) else { + continue; + }; + + let encoded_key = &urlencoding::encode(def_key.as_str()).to_string(); + + if def_key != encoded_key { + schema.rename_defs_subschema(def_key.as_ref(), encoded_key.as_ref()); + let new_reference = &format!("#/$defs/{encoded_key}"); + for reference in lookup_schema.get_references_to_bundled_schema_resource(bundled_resource_id) { + schema.replace_references(reference, new_reference.as_ref()); + } + } + } +} \ No newline at end of file diff --git a/xtask/.project.data.json b/xtask/.project.data.json new file mode 100644 index 000000000..f397a42ac --- /dev/null +++ b/xtask/.project.data.json @@ -0,0 +1,6 @@ +{ + "Name": "xtask", + "Kind": "CLI", + "IsRust": true, + "TestOnly": true +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 000000000..a686fe00e --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2024" + +[dependencies] +clap = { workspace = true } +clap_complete = { workspace = true } +jsonschema = { workspace = true } +rust-i18n = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +# Workspace dependencies +dsc-lib = { workspace = true } \ No newline at end of file diff --git a/xtask/locales/en-us.toml b/xtask/locales/en-us.toml new file mode 100644 index 000000000..38f87e629 --- /dev/null +++ b/xtask/locales/en-us.toml @@ -0,0 +1,8 @@ +[args] +about = "xtask provides build helpers for the DSC rust project." +schemaAbout = "Commands for managing DSC repository schemas." +schemaExportAbout = "Export DSC schemas to disk." + +[schemas.export] +serializationFailure = "Failed to serialize JSON Schema as string" +ioError = "Failed to export JSON Schema, IO error" diff --git a/xtask/src/args.rs b/xtask/src/args.rs new file mode 100644 index 000000000..03e8001ba --- /dev/null +++ b/xtask/src/args.rs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use clap::{Parser, Subcommand}; +use rust_i18n::t; + +#[derive(Debug, Parser)] +#[clap(name = "xtask", about = t!("args.about").to_string(), long_about = None)] +pub struct Args { + /// The subcommand to run + #[clap(subcommand)] + pub subcommand: SubCommand, +} + +#[derive(Debug, PartialEq, Eq, Subcommand)] +pub enum SubCommand { + #[clap(name = "schema", about = t!("args.schemaAbout").to_string())] + Schema { + #[clap(subcommand)] + sub_command: SchemaSubCommand + }, +} + +#[derive(Debug, PartialEq, Eq, Subcommand)] +pub enum SchemaSubCommand { + #[clap(name = "export", about = t!("args.schemaExportAbout").to_string())] + Export +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 000000000..cde1023ee --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use clap::Parser; +use dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion; +use rust_i18n::i18n; +use thiserror::Error; + +use crate::{ + args::{Args, SchemaSubCommand, SubCommand}, + schemas::export::{SchemaExportError, export_schemas} +}; + +mod args; +pub(crate) mod schemas { + pub(crate) mod export; +} + +#[derive(Debug, Error)] +pub(crate) enum XTaskError { + #[error(transparent)] + SchemaExport(#[from] SchemaExportError) +} + +i18n!("locales", fallback = "en-us"); + +fn main() -> Result<(), XTaskError> { + let args = Args::parse(); + + match args.subcommand { + SubCommand::Schema { sub_command } => match sub_command { + SchemaSubCommand::Export => { + match export_schemas(RecognizedSchemaVersion::VNext) { + Ok(_) => Ok(()), + Err(e) => Err(XTaskError::SchemaExport(e)), + } + }, + }, + } +} diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs new file mode 100644 index 000000000..f1454e57f --- /dev/null +++ b/xtask/src/schemas/export.rs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fs, ops::Add, path::PathBuf, sync::LazyLock}; + +use dsc_lib::schemas::{ + dsc_repo::{DscRepoSchema, RecognizedSchemaVersion}, + schema_utility_extensions::SchemaUtilityExtensions +}; +use rust_i18n::t; +use schemars::Schema; +use thiserror::Error; + +/// Defines the errors to raise when exporting a DSC schema to the file system. +#[derive(Debug, Error)] +pub(crate) enum SchemaExportError { + /// Raised when a schema fails to serialize to a pretty-formatted string. + #[error("{t}: {0}", t = t!("schemas.export.serializationFailure"))] + SerializationFailure(#[from]serde_json::Error), + /// Raised when an IO error prevents exporting a schema to the file system. + #[error("{t}: {0}", t = t!("schemas.export.ioError"))] + IOError(#[from] std::io::Error), +} + +/// Helper static to retrieve the root folder once and use repeatedly when exporting schemas to the +/// filesystem. +static PROJECT_DIR: LazyLock = LazyLock::new(|| { + let p = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + p.parent().unwrap().into() +}); + +/// Writes the given JSON Schema to the filesystem relative to the project folder. +pub(crate) fn write_schema(relative_path: PathBuf, schema: Schema) -> Result<(), SchemaExportError> { + + let json_schema = serde_json::to_string_pretty(&schema.to_value_with_stable_order())?.add("\n"); + let path = PROJECT_DIR.clone().join("schemas").join(relative_path); + let folder = path.parent().unwrap(); + println!("Exporting schema at '{}'", path.display()); + + if !folder.exists() { + fs::create_dir_all(folder)?; + } + fs::write(path, json_schema)?; + + return Ok(()) +} + +macro_rules! export_type_schemas { + ($schema_version:expr => $($type_to_export:ty),+) => { + { + $( + for schema_form in <$type_to_export>::get_valid_schema_forms() { + write_schema( + <$type_to_export>::get_schema_relative_path($schema_version, schema_form).into(), + <$type_to_export>::generate_exportable_schema($schema_version, schema_form) + )?; + } + )+ + } + }; +} + +pub(crate) fn export_schemas( + schema_version: RecognizedSchemaVersion +) -> Result<(), SchemaExportError> { + export_type_schemas!( + schema_version => + dsc_lib::configure::config_doc::Configuration, + dsc_lib::configure::config_doc::DataType, + dsc_lib::configure::config_doc::ExecutionKind, + dsc_lib::configure::config_doc::Metadata, + dsc_lib::configure::config_doc::Operation, + dsc_lib::configure::config_doc::Output, + dsc_lib::configure::config_doc::Parameter, + dsc_lib::configure::config_doc::Resource, + dsc_lib::configure::config_doc::ResourceDiscoveryMode, + dsc_lib::configure::config_doc::RestartRequired, + dsc_lib::configure::config_doc::SecurityContextKind, + dsc_lib::configure::config_doc::UserFunction, + dsc_lib::configure::config_doc::UserFunctionDefinition, + dsc_lib::configure::config_doc::UserFunctionOutput, + dsc_lib::configure::config_doc::UserFunctionParameter, + dsc_lib::configure::config_result::ConfigurationExportResult, + dsc_lib::configure::config_result::ConfigurationGetResult, + dsc_lib::configure::config_result::ConfigurationSetResult, + dsc_lib::configure::config_result::ConfigurationTestResult, + dsc_lib::configure::config_result::ResourceGetResult, + dsc_lib::configure::config_result::ResourceMessage, + dsc_lib::configure::config_result::ResourceSetResult, + dsc_lib::dscresources::adapted_resource_manifest::AdaptedDscResourceManifest, + dsc_lib::dscresources::dscresource::Capability, + dsc_lib::dscresources::dscresource::DscResource, + dsc_lib::dscresources::invoke_result::DeleteResult, + dsc_lib::dscresources::invoke_result::DeleteWhatIfResult, + dsc_lib::dscresources::invoke_result::ExportResult, + dsc_lib::dscresources::invoke_result::GetResult, + dsc_lib::dscresources::invoke_result::ResolveResult, + dsc_lib::dscresources::invoke_result::ResourceGetResponse, + dsc_lib::dscresources::invoke_result::ResourceSetResponse, + dsc_lib::dscresources::invoke_result::ResourceTestResponse, + dsc_lib::dscresources::invoke_result::SetResult, + dsc_lib::dscresources::invoke_result::TestResult, + dsc_lib::dscresources::invoke_result::ValidateResult, + dsc_lib::dscresources::resource_manifest::Adapter, + dsc_lib::dscresources::resource_manifest::DeleteMethod, + dsc_lib::dscresources::resource_manifest::ExportMethod, + dsc_lib::dscresources::resource_manifest::GetArgKind, + dsc_lib::dscresources::resource_manifest::GetMethod, + dsc_lib::dscresources::resource_manifest::InputKind, + dsc_lib::dscresources::resource_manifest::Kind, + dsc_lib::dscresources::resource_manifest::ResolveMethod, + dsc_lib::dscresources::resource_manifest::ResourceManifest, + dsc_lib::dscresources::resource_manifest::ReturnKind, + dsc_lib::dscresources::resource_manifest::SchemaKind, + dsc_lib::dscresources::resource_manifest::SetDeleteArgKind, + dsc_lib::dscresources::resource_manifest::SetMethod, + dsc_lib::dscresources::resource_manifest::TestMethod, + dsc_lib::dscresources::resource_manifest::ValidateMethod, + dsc_lib::extensions::discover::DiscoverMethod, + dsc_lib::extensions::discover::DiscoverResult, + dsc_lib::extensions::dscextension::Capability, + dsc_lib::extensions::dscextension::DscExtension, + dsc_lib::extensions::extension_manifest::ExtensionManifest, + dsc_lib::extensions::import::ImportMethod, + dsc_lib::extensions::secret::SecretMethod, + dsc_lib::functions::FunctionArgKind, + dsc_lib::functions::FunctionCategory, + dsc_lib::functions::FunctionDefinition, + dsc_lib::types::ExitCodesMap, + dsc_lib::types::FullyQualifiedTypeName, + dsc_lib::types::ResourceVersion, + dsc_lib::types::ResourceVersionReq, + dsc_lib::types::SemanticVersion, + dsc_lib::types::SemanticVersionReq, + dsc_lib::types::TagList + ); + + Ok(()) +}