diff --git a/Cargo.lock b/Cargo.lock index 7d22d877c..01b9a9d29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,6 +718,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "thiserror 2.0.17", "tracing", "url", "urlencoding", diff --git a/lib/dsc-lib-jsonschema/.clippy.toml b/lib/dsc-lib-jsonschema/.clippy.toml index 9f36c4218..978dfdf21 100644 --- a/lib/dsc-lib-jsonschema/.clippy.toml +++ b/lib/dsc-lib-jsonschema/.clippy.toml @@ -1 +1 @@ -doc-valid-idents = ["IntelliSense", ".."] +doc-valid-idents = ["IntelliSense", "PowerShell", ".."] diff --git a/lib/dsc-lib-jsonschema/.versions.json b/lib/dsc-lib-jsonschema/.versions.json new file mode 100644 index 000000000..1d0158058 --- /dev/null +++ b/lib/dsc-lib-jsonschema/.versions.json @@ -0,0 +1,16 @@ +{ + "latestMajor": "V3", + "latestMinor": "V3_1", + "latestPatch": "V3_1_2", + "all": [ + "V3", + "V3_1", + "V3_1_2", + "V3_1_1", + "V3_1_0", + "V3_0", + "V3_0_2", + "V3_0_1", + "V3_0_0" + ] +} diff --git a/lib/dsc-lib-jsonschema/.versions.ps1 b/lib/dsc-lib-jsonschema/.versions.ps1 new file mode 100644 index 000000000..0c8c94047 --- /dev/null +++ b/lib/dsc-lib-jsonschema/.versions.ps1 @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Generate the version info for the DSC project. + + .DESCRIPTION + This script inspects the git tags for the project and uses them to construct data representing + those versions, saving it to `.versions.json` in the same directory as this script. + + The data file contains every non-prerelease version tag as well as the latest major, minor, and + patch version releases. + + The versions are saved as: + + - `V`, like `V3`, for every major version number. + - `V_`, like `V3_1`, for every minor version number. + - `V__`, like `V3_1_0`, for every non-prerelease version number. + + This data is used by `build.rs` to generate the contents for the `RecognizedSchemaVersion` + enum type definition and trait implementations. +#> + +[CmdletBinding()] +param() + +begin { + function Get-DscProjectTagVersion { + [cmdletbinding()] + [OutputType([semver])] + param() + + process { + $null = git fetch --all --tags + git tag -l + | Where-Object -FilterScript {$_ -match '^v\d+(\.\d+){2}$' } + | ForEach-Object -Process { [semver]($_.Substring(1)) } + } + } + + function ConvertTo-EnumName { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(ValueFromPipeline)] + [semver[]]$Version, + [switch]$Major, + [switch]$Minor + + ) + + process { + foreach ($v in $Version) { + if ($Major) { + 'V{0}' -f $v.Major + } elseif ($Minor) { + 'V{0}_{1}' -f $v.Major, $v.Minor + } else { + 'V{0}_{1}_{2}' -f $v.Major, $v.Minor, $v.Patch + } + } + } + } + + function Export-DscProjectTagVersion { + [cmdletbinding()] + param() + + process { + $publishedVersions = Get-DscProjectTagVersion + | Sort-Object -Descending + + [System.Collections.Generic.HashSet[semver]]$majorVersions = @() + [System.Collections.Generic.HashSet[semver]]$minorVersions = @() + [System.Collections.Generic.HashSet[semver]]$patchVersions = @() + + foreach ($version in $publishedVersions) { + $null = $majorVersions.Add([semver]"$($version.Major)") + $null = $minorVersions.Add([semver]"$($version.Major).$($version.Minor)") + $null = $patchVersions.Add($version) + } + + # Sort the versions with major version, then each child minor version and child patch versions. + [System.Collections.Generic.HashSet[string]]$allVersions = @() + foreach ($major in ($majorVersions | Sort-Object -Descending)) { + $null = $allVersions.Add(($major | ConvertTo-EnumName -Major)) + + $majorMinor = $minorVersions + | Where-Object { $_.Major -eq $major.Major } + | Sort-Object -Descending + + foreach ($minor in $majorMinor) { + $null = $allVersions.Add(($minor | ConvertTo-EnumName -Minor)) + + $majorMinorPatch = $patchVersions + | Where-Object { $_.Major -eq $minor.Major -and $_.Minor -eq $minor.Minor } + | Sort-Object -Descending + + foreach ($patch in $majorMinorPatch) { + $null = $allVersions.Add(($patch | ConvertTo-EnumName)) + } + } + } + + [string]$latestMajorVersion = $majorVersions + | Sort-Object -Descending + | Select-Object -First 1 + | ConvertTo-EnumName -Major + [string]$latestMinorVersion = $minorVersions + | Sort-Object -Descending + | Select-Object -First 1 + | ConvertTo-EnumName -Minor + [string]$latestPatchVersion = $patchVersions + | Sort-Object -Descending + | Select-Object -First 1 + | ConvertTo-EnumName + + $data = [ordered]@{ + latestMajor = $latestMajorVersion + latestMinor = $latestMinorVersion + latestPatch = $latestPatchVersion + all = $allVersions + } + + $dataJson = $data + | ConvertTo-Json + | ForEach-Object -Process { $_ -replace "`r`n", "`n"} + + $dataPath = Join-Path -Path $PSScriptRoot -ChildPath '.versions.json' + $dataContent = Get-Content -Raw -Path $dataPath + + if ($dataJson.Trim() -ne $dataContent.Trim()) { + $dataJson | Set-Content -Path $PSScriptRoot/.versions.json + } + + $dataJson + } + } +} + +process { + Export-DscProjectTagVersion +} \ No newline at end of file diff --git a/lib/dsc-lib-jsonschema/Cargo.toml b/lib/dsc-lib-jsonschema/Cargo.toml index 181dfe3dc..374c376c3 100644 --- a/lib/dsc-lib-jsonschema/Cargo.toml +++ b/lib/dsc-lib-jsonschema/Cargo.toml @@ -13,6 +13,7 @@ rust-i18n = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } @@ -21,5 +22,9 @@ urlencoding = { workspace = true } # Helps review complex comparisons, like schemas pretty_assertions = { workspace = true } +[build-dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + [lints.clippy] pedantic = { level = "deny" } diff --git a/lib/dsc-lib-jsonschema/build.rs b/lib/dsc-lib-jsonschema/build.rs new file mode 100644 index 000000000..26fdcf573 --- /dev/null +++ b/lib/dsc-lib-jsonschema/build.rs @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! This build script generates code for the `RecognizedSchemaVersion` enum. It uses PowerShell to +//! query for the latest tags and generates data in the `.versions.json` file. The build script uses +//! that data to create the enum variants that indicate which versions of DSC a schema is recognized +//! for. +//! +//! Generating this code enables us to use the enum without having to manually update the definition +//! for every release. + +use std::env; +use std::fs; +use std::fs::read_to_string; +use std::path::Path; +use std::process::Command; +use std::process::Output; + +use serde::Deserialize; + +/// Representation of the data in `.versions.json` file. +#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Deserialize)] +#[serde(rename_all="camelCase")] +struct VersionInfo { + pub all: Vec, + pub latest_major: String, + pub latest_minor: String, + pub latest_patch: String, +} + +/// Constructs the enum type definition. It emits a string like: +/// +/// ```rust +/// #[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq)] +/// pub enum RecognizedSchemaVersion { +/// /// Represents the `vNext` schema folder. +/// VNext, +/// /// Represents the `v3` schema folder +/// #[default] +/// V3, +/// /// Represents the `v3.1` schema folder +/// V3_1, +/// /// Represents the `v3.1.2` schema folder +/// V3_1_2, +/// /// Represents the `v3.1.1` schema folder +/// V3_1_1, +/// /// Represents the `v3.1.0` schema folder +/// V3_1_0, +/// /// Represents the `v3.0` schema folder +/// V3_0, +/// /// Represents the `v3.0.2` schema folder +/// V3_0_2, +/// /// Represents the `v3.0.1` schema folder +/// V3_0_1, +/// /// Represents the `v3.0.0` schema folder +/// V3_0_0, +/// } +/// ``` +fn format_type_definition(version_info: &VersionInfo) -> String { + let mut lines = vec![ + "#[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq)]".to_string(), + "pub enum RecognizedSchemaVersion {".to_string(), + " /// Represents the `vNext` schema folder.".to_string(), + " VNext,".to_string(), + ]; + + for (index, version) in version_info.all.iter().enumerate() { + let comment = format!( + " /// Represents the `{}` schema folder", + version.replace('_', ".").to_lowercase() + ); + lines.push(comment); + + if index == 0 { + lines.push(" #[default]".to_string()); + } + + lines.push(format!(" {version},")); + } + + lines.push("}".to_string()); + + lines.join("\n") +} + +/// Constructs the implementation for the [`std::fmt::Display`] trait. It emits a string like: +/// +/// ```rust +/// impl std::fmt::Display for RecognizedSchemaVersion { +/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// match self { +/// Self::VNext => write!(f, "vNext"), +/// Self::V3 => write!(f, "v3"), +/// Self::V3_1 => write!(f, "v3.1"), +/// Self::V3_1_2 => write!(f, "v3.1.2"), +/// Self::V3_1_1 => write!(f, "v3.1.1"), +/// Self::V3_1_0 => write!(f, "v3.1.0"), +/// Self::V3_0 => write!(f, "v3.0"), +/// Self::V3_0_2 => write!(f, "v3.0.2"), +/// Self::V3_0_1 => write!(f, "v3.0.1"), +/// Self::V3_0_0 => write!(f, "v3.0.0"), +/// } +/// } +/// } +/// ``` +fn format_display_trait_impl(version_info: &VersionInfo) -> String { + let mut lines = vec![ + "impl std::fmt::Display for RecognizedSchemaVersion {".to_string(), + " fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {".to_string(), + " match self {".to_string(), + " Self::VNext => write!(f, \"vNext\"),".to_string(), + ]; + + for version in version_info.all.clone() { + lines.push(format!( + " Self::{version} => write!(f, \"{}\"),", + version.replace('_', ".").to_lowercase() + )); + } + lines.push(" }".to_string()); + lines.push(" }".to_string()); + lines.push("}".to_string()); + + lines.join("\n") +} + +/// Emits the definition for the `all()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns every recognized schema version for convenient iteration. +/// #[must_use] +/// pub fn all() -> Vec { +/// vec![ +/// Self::VNext, +/// Self::V3, +/// Self::V3_1, +/// Self::V3_1_2, +/// Self::V3_1_1, +/// Self::V3_1_0, +/// Self::V3_0, +/// Self::V3_0_2, +/// Self::V3_0_1, +/// Self::V3_0_0, +/// ] +/// } +/// ``` +fn format_method_all(version_info: &VersionInfo) -> String { + let mut lines = vec![ + " /// Returns every recognized schema version for convenient iteration.".to_string(), + " #[must_use]".to_string(), + " pub fn all() -> Vec {".to_string(), + " vec![".to_string(), + " Self::VNext,".to_string(), + ]; + + for version in version_info.all.clone() { + lines.push(format!(" Self::{version},")); + } + lines.push(" ]".to_string()); + lines.push(" }".to_string()); + + lines.join("\n") +} + +/// Emits the definition for the `all_major()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns every recognized major version, like `v3`. +/// #[must_use] +/// pub fn all_major() -> Vec { +/// vec![ +/// Self::V3, +/// ] +/// } +/// ``` +fn format_method_all_major(version_info: &VersionInfo) -> String { + let mut lines = vec![ + " /// Returns every recognized major version, like `v3`.".to_string(), + " #[must_use]".to_string(), + " pub fn all_major() -> Vec {".to_string(), + " vec![".to_string(), + ]; + + for version in version_info.all.clone() { + if version.split('_').collect::>().len() == 1 { + lines.push(format!(" Self::{version},")); + } + } + + lines.push(" ]".to_string()); + lines.push(" }".to_string()); + + lines.join("\n") +} + +/// Emits the definition for the `all_minor()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns every recognized minor version, like `v3.1`. +/// #[must_use] +/// pub fn all_minor() -> Vec { +/// vec![ +/// Self::V3_1, +/// Self::V3_0, +/// ] +/// } +/// ``` +fn format_method_all_minor(version_info: &VersionInfo) -> String { + let mut lines = vec![ + " /// Returns every recognized minor version, like `v3.1`.".to_string(), + " #[must_use]".to_string(), + " pub fn all_minor() -> Vec {".to_string(), + " vec![".to_string(), + ]; + + for version in version_info.all.clone() { + if version.split('_').collect::>().len() == 2 { + lines.push(format!(" Self::{version},")); + } + } + + lines.push(" ]".to_string()); + lines.push(" }".to_string()); + + lines.join("\n") +} + +/// Emits the definition for the `all_patch()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns every recognized patch version, like `v3.1.1`. +/// #[must_use] +/// pub fn all_patch() -> Vec { +/// vec![ +/// Self::V3_1_2, +/// Self::V3_1_1, +/// Self::V3_1_0, +/// Self::V3_0_2, +/// Self::V3_0_1, +/// Self::V3_0_0, +/// ] +/// } +/// ``` +fn format_method_all_patch(version_info: &VersionInfo) -> String { + let mut lines = vec![ + " /// Returns every recognized patch version, like `v3.1.1`.".to_string(), + " #[must_use]".to_string(), + " pub fn all_patch() -> Vec {".to_string(), + " vec![".to_string(), + ]; + + for version in version_info.all.clone() { + if version.split('_').collect::>().len() == 3 { + lines.push(format!(" Self::{version},")); + } + } + + lines.push(" ]".to_string()); + lines.push(" }".to_string()); + + lines.join("\n") +} + +/// Emits the definition for the `latest()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns the latest version with major, minor, and patch segments, like `3.0.0`. +/// #[must_use] +/// pub fn latest() -> RecognizedSchemaVersion { +/// Self::V3_1_2 +/// } +/// ``` +fn format_method_latest(version_info: &VersionInfo) -> String { + [ + " /// Returns the latest version with major, minor, and patch segments, like `3.0.0`.".to_string(), + " #[must_use]".to_string(), + " pub fn latest() -> RecognizedSchemaVersion {".to_string(), + format!(" Self::{}", version_info.latest_patch), + " }".to_string(), + ].join("\n") +} + +/// Emits the definition for the `latest_minor()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns the latest minor version for the latest major version, like `3.0`. +/// #[must_use] +/// pub fn latest_minor() -> RecognizedSchemaVersion { +/// Self::V3_1 +/// } +/// ``` +fn format_method_latest_minor(version_info: &VersionInfo) -> String { + [ + " /// Returns the latest minor version for the latest major version, like `3.0`.".to_string(), + " #[must_use]".to_string(), + " pub fn latest_minor() -> RecognizedSchemaVersion {".to_string(), + format!(" Self::{}", version_info.latest_minor), + " }".to_string(), + ].join("\n") +} + +/// Emits the definitions for the `latest_major()` method. Called by the [`format_method_impl`] formatter. +/// +/// Emits a string (indented 4 spaces, because it's placed in an `impl` block) like: +/// +/// ```rust +/// /// Returns the latest major version, like `3` +/// #[must_use] +/// pub fn latest_major() -> RecognizedSchemaVersion { +/// Self::V3 +/// } +/// ``` +fn format_method_latest_major(version_info: &VersionInfo) -> String { + [ + " /// Returns the latest major version, like `3`".to_string(), + " #[must_use]".to_string(), + " pub fn latest_major() -> RecognizedSchemaVersion {".to_string(), + format!(" Self::{}", version_info.latest_major), + " }".to_string(), + ].join("\n") +} + +/// Emits the method implementation block for the enum type. Emits a string like: +/// +/// ```rust +/// impl RecognizedSchemaVersion { +/// /// Returns every recognized schema version for convenient iteration. +/// #[must_use] +/// pub fn all() -> Vec { +/// vec![ +/// Self::VNext, +/// Self::V3, +/// Self::V3_1, +/// Self::V3_1_2, +/// Self::V3_1_1, +/// Self::V3_1_0, +/// Self::V3_0, +/// Self::V3_0_2, +/// Self::V3_0_1, +/// Self::V3_0_0, +/// ] +/// } +/// +/// /// Returns every recognized major version, like `v3`. +/// #[must_use] +/// pub fn all_major() -> Vec { +/// vec![ +/// Self::V3, +/// ] +/// } +/// +/// /// Returns every recognized minor version, like `v3.1`. +/// #[must_use] +/// pub fn all_minor() -> Vec { +/// vec![ +/// Self::V3_1, +/// Self::V3_0, +/// ] +/// } +/// +/// /// Returns every recognized patch version, like `v3.1.1`. +/// #[must_use] +/// pub fn all_patch() -> Vec { +/// vec![ +/// Self::V3_1_2, +/// Self::V3_1_1, +/// Self::V3_1_0, +/// Self::V3_0_2, +/// Self::V3_0_1, +/// Self::V3_0_0, +/// ] +/// } +/// +/// /// Returns the latest version with major, minor, and patch segments, like `3.0.0`. +/// #[must_use] +/// pub fn latest() -> RecognizedSchemaVersion { +/// Self::V3_1_2 +/// } +/// +/// /// Returns the latest minor version for the latest major version, like `3.0`. +/// #[must_use] +/// pub fn latest_minor() -> RecognizedSchemaVersion { +/// Self::V3_1 +/// } +/// +/// /// Returns the latest major version, like `3` +/// #[must_use] +/// pub fn latest_major() -> RecognizedSchemaVersion { +/// Self::V3 +/// } +/// } +/// ``` +fn format_method_impl(version_info: &VersionInfo) -> String { + [ + "impl RecognizedSchemaVersion {".to_string(), + format_method_all(version_info), + String::new(), + format_method_all_major(version_info), + String::new(), + format_method_all_minor(version_info), + String::new(), + format_method_all_patch(version_info), + String::new(), + format_method_latest(version_info), + String::new(), + format_method_latest_minor(version_info), + String::new(), + format_method_latest_major(version_info), + "}".to_string(), + ].join("\n") +} + +/// Emits the documentation string for the enum type. +fn format_type_docs() -> String { + [ + "/// Defines the versions of DSC recognized for schema validation and handling.", + "///", + "/// The DSC schemas are published into three folders:", + "///", + "/// - `v..` always includes the exact JSON Schema that shipped in that release", + "/// of DSC.", + "/// - `v.` always includes the latest JSON Schema compatible with that minor version", + "/// of DSC.", + "/// - `v` always includes the latest JSON Schema compatible with that major version of DSC.", + "///", + "/// Pinning to `v` requires the least-frequent updating of the `$schema` in configuration", + "/// documents and resource manifests, but also introduces changes that affect those schemas", + "/// (without breaking changes) regularly. Some of the added features may not be effective in the", + "/// version of DSC a user has installed.", + "///", + "/// Pinning to `v.` ensures that users always have the latest schemas for the version", + "/// of DSC they're using without schema changes that they may not be able to take advantage of.", + "/// However, it requires updating the resource manifests and configuration documents with each", + "/// minor release of DSC.", + "///", + "/// Pinning to `v..` is the most specific option, but requires the most", + "/// frequent updating on the part of resource and configuration authors.", + "///", + "/// Additionally, we define the `vNext` folder, which always contains the latest schemas for DSC", + "/// types, even when they haven't been released yet. You can use the `vNext` folder when working with", + "/// prerelease versions and building from source between releases." + ].join("\n") +} + +/// Composes the contents for the type definition file using the other formatter functions. +fn format_file_content(version_info: &VersionInfo) -> String { + [ + format_type_docs(), + format_type_definition(version_info), + String::new(), + format_display_trait_impl(version_info), + String::new(), + format_method_impl(version_info), + String::new(), + ].join("\n") +} + +/// Invokes the `.versions.ps1` PowerShell script to query git tags and update the `.versions.json` +/// file if needed. +fn update_versions_data(script_path: &Path) -> Output { + Command::new("pwsh") + .args([ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + &script_path.to_string_lossy(), + ]) + .output() + .unwrap_or_else(|e| { + panic!( + "failed to execute PowerShell script '{}': {}", + script_path.display(), + e + ) + }) +} + +/// Constructs the `recognized_schema_version.rs` file in the output directory, which the library +/// uses as generated code for the `RecognizedSchemaVersion` enum. +fn main() { + let project_dir = env::var_os("CARGO_MANIFEST_DIR") + .expect("env var 'CARGO_MANIFEST_DIR' not defined"); + let data_path = Path::new(&project_dir).join(".versions.json"); + let script_path = Path::new(&project_dir).join(".versions.ps1"); + let out_dir = env::var_os("OUT_DIR") + .expect("env var 'OUT_DIR' not defined"); + let dest_path = Path::new(&out_dir).join("recognized_schema_version.rs"); + + // Update the versions data if needed. + let output = update_versions_data(&script_path); + assert!( + output.status.success(), + "Failed to update versions data via PowerShell script.\nExit code: {:?}\nStdout: {}\nStderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let version_data = read_to_string(data_path) + .expect("Failed to read .versions.json file"); + let version_info: VersionInfo = serde_json::from_str(&version_data) + .expect("Failed to parse version data from .versions.json"); + let contents = format_file_content(&version_info); + + fs::write( + &dest_path, + contents + ).expect("Failed to write recognized_schema_version.rs"); + + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-changed=.versions.json"); + println!("cargo::rerun-if-changed=.versions.ps1"); +} diff --git a/lib/dsc-lib-jsonschema/locales/en-us.toml b/lib/dsc-lib-jsonschema/locales/en-us.toml index d776dd588..3792216b1 100644 --- a/lib/dsc-lib-jsonschema/locales/en-us.toml +++ b/lib/dsc-lib-jsonschema/locales/en-us.toml @@ -1,5 +1,9 @@ _version = 1 +[dsc_repo.dsc_repo_schema] +unrecognizedSchemaUri = "Unrecognized $schema URI" +validSchemaUrisAre = "Valid schema URIs are" + [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}" 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 new file mode 100644 index 000000000..1ee21e230 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use schemars::{JsonSchema, Schema}; +use thiserror::Error; + +use crate::dsc_repo::{ + RecognizedSchemaVersion, + SchemaForm, + SchemaUriPrefix, + get_default_schema_uri, + get_recognized_schema_uri, + get_recognized_schema_uris, + get_recognized_uris_subschema +}; + +/// Defines a reusable trait to simplify managing multiple versions of JSON Schemas for DSC +/// structs and enums. +/// +/// This trait is only intended for use by definitions in the DSC repository. +pub trait DscRepoSchema : JsonSchema { + /// Defines the base name for the exported JSON Schema. + /// + /// For example, for the following `$id`, `document` is the base name: + /// + /// ```json + /// { "$id": "https://aka.ms/dsc/schemas/v3/config/document.json" } + /// ``` + const SCHEMA_FILE_BASE_NAME: &'static str; + + /// Defines the folder path for the schema relative to the published version folder. + /// + /// For example, for the following `$id`, `config` is the folder path: + /// + /// ```json + /// { "$id": "https://aka.ms/dsc/schemas/v3/config/document.json" } + /// ``` + const SCHEMA_FOLDER_PATH: &'static str; + + /// Indicates whether the schema should be published in its bundled form. + /// + /// All bundled schemas are also published with their VS Code form. Schemas that aren't bundled + /// aren't published with the VS Code form. + const SCHEMA_SHOULD_BUNDLE: bool; + + /// Defines the metadata for the `$schema` property of a struct that takes multiple schema + /// versions. + /// + /// This simplifies providing metadata annotation keywords, since we generate the subschema for + /// this property with the [`recognized_schema_uris_subschema`] method. + /// + /// [`recognized_schema_uris_subschema`]: DscRepoSchema::recognized_schema_uris_subschema + fn schema_property_metadata() -> Schema; + + /// Returns the default URI for the schema. + /// + /// An object representing an instance of the schema can specify any valid URI, but the + /// default when creating an instance is the latest major version of the schema with the + /// `aka.ms` prefix. If the schema is published in the bundled form, the default is for the + /// bundled schema. Otherwise, the default is for the canonical (non-bundled) schema. + #[must_use] + fn default_schema_id_uri() -> String { + get_default_schema_uri( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + Self::SCHEMA_SHOULD_BUNDLE + ) + } + + /// Returns the schema URI for a given version, form, and prefix. + #[must_use] + fn get_schema_id_uri( + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm, + uri_prefix: SchemaUriPrefix + ) -> String { + get_recognized_schema_uri( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + schema_version, + schema_form, + uri_prefix + ) + } + + /// Returns the URI for the VS Code form of the schema with the default prefix for a given + /// version. + /// + /// If the type isn't published in bundled form, this function returns `None`. + #[must_use] + fn get_enhanced_schema_id_uri(schema_version: RecognizedSchemaVersion) -> Option { + if !Self::SCHEMA_SHOULD_BUNDLE { + return None; + } + + Some(get_recognized_schema_uri( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + schema_version, + SchemaForm::VSCode, + SchemaUriPrefix::default() + )) + } + + /// Returns the URI for the canonical (non-bundled) form of the schema with the default + /// prefix for a given version. + #[must_use] + fn get_canonical_schema_id_uri(schema_version: RecognizedSchemaVersion) -> String { + get_recognized_schema_uri( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + schema_version, + SchemaForm::Canonical, + SchemaUriPrefix::default() + ) + } + + /// Returns the URI for the bundled form of the schema with the default prefix for a given + /// version. + #[must_use] + fn get_bundled_schema_id_uri(schema_version: RecognizedSchemaVersion) -> Option { + if !Self::SCHEMA_SHOULD_BUNDLE { + return None; + } + + Some(get_recognized_schema_uri( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + schema_version, + SchemaForm::Bundled, + SchemaUriPrefix::default() + )) + } + + /// Returns the list of recognized schema URIs for the struct or enum. + /// + /// This convenience function generates a vector containing every recognized JSON Schema `$id` + /// URI for a specific schema. It handles returning the schemas for every recognized prefix, + /// version, and form. + #[must_use] + fn recognized_schema_uris() -> Vec { + get_recognized_schema_uris( + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + Self::SCHEMA_SHOULD_BUNDLE + ) + } + + /// Returns the subschema to validate a `$schema` keyword pointing to the type. + /// + /// Every schema has a canonical `$id`, but DSC needs to maintain compatibility with schemas + /// within a major version and ensure that previous schema versions can be correctly + /// recognized and validated. This method generates the appropriate subschema with every + /// valid URI for the schema's `$id` without needing to regularly update an enum for each + /// schema and release. + #[must_use] + fn recognized_schema_uris_subschema(_: &mut schemars::SchemaGenerator) -> Schema { + get_recognized_uris_subschema( + &Self::schema_property_metadata(), + Self::SCHEMA_FILE_BASE_NAME, + Self::SCHEMA_FOLDER_PATH, + Self::SCHEMA_SHOULD_BUNDLE + ) + } + + /// Indicates whether a given string is a recognized schema URI. + #[must_use] + fn is_recognized_schema_uri(uri: &String) -> bool { + Self::recognized_schema_uris().contains(uri) + } + + /// Validates the `$schema` keyword for deserializing instances. + /// + /// This method simplifies the validation of a type that has the `$schema` keyword and expects + /// that instances of the type in data indicate which schema version DSC should use to validate + /// them. + /// + /// This method includes a default implementation to avoid requiring the implementation for + /// types that don't define the `$schema` keyword in their serialized form. + /// + /// Any DSC type that serializes with the `$schema` keyword **must** define this + /// method to actually validate the instance. + /// + /// # Errors + /// + /// If the value for the schema field isn't a recognized schema, the method should raise the + /// [`UnrecognizedSchemaUri`] error. + fn validate_schema_uri(&self) -> Result<(), UnrecognizedSchemaUri> { + Ok(()) + } +} + +/// Defines the error when a user-defined JSON Schema references an unrecognized schema URI. +#[derive(Error, Debug)] +#[error( + "{t}: {0}. {t2}: {1:?}", + t = t!("dsc_repo.dsc_repo_schema.unrecognizedSchemaUri"), + t2 = t!("dsc_repo.dsc_repo_schema.validSchemaUrisAre") +)] +pub struct UnrecognizedSchemaUri(pub String, pub Vec); diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs new file mode 100644 index 000000000..9f9dc1eb4 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::{Schema, json_schema}; + +mod dsc_repo_schema; +pub use dsc_repo_schema::DscRepoSchema; +pub use dsc_repo_schema::UnrecognizedSchemaUri; + +mod recognized_schema_version; +pub use recognized_schema_version::RecognizedSchemaVersion; + +mod schema_form; +pub use schema_form::SchemaForm; + +mod schema_uri_prefix; +pub use schema_uri_prefix::SchemaUriPrefix; + +/// Returns the constructed URI for a hosted DSC schema. +/// +/// This convenience function simplifies constructing the URIs for the various published schemas +/// that DSC recognizes, instead of needing to maintain long lists of those recognized schemas. +/// This function should primarily be called by [`get_recognized_schema_uris`], not called +/// directly. +/// +/// Parameters: +/// +/// - `schema_file_base_name` - specify the base name for the schema file, like `document` for +/// the configuration document schema or `manifest` for the resource manifest schema. +/// - `schema_folder_path` - specify the folder path for the schema file relative to the version +/// folder, like `config` for the configuration document schema or `resource` for the resource +/// manifest schema. +/// - `schema_version` - specify the version of the schema. +/// - `schema_form` - specify whether the schema is bundled, for VS Code, or is the canonical +/// (non-bundled) schema. +/// - `uri_prefix` - Specify whether the URI should be prefixed for `aka.ms` or GitHub. +pub(crate) fn get_recognized_schema_uri( + schema_file_base_name: &str, + schema_folder_path: &str, + schema_version: RecognizedSchemaVersion, + schema_form: SchemaForm, + schema_uri_prefix: SchemaUriPrefix +) -> String { + format!( + "{schema_uri_prefix}/{schema_version}/{}{schema_folder_path}/{schema_file_base_name}{}", + schema_form.to_folder_prefix(), + schema_form.to_extension() + ) +} + +/// Returns the vector of recognized URIs for a given schema. +/// +/// This convenience function generates a vector containing every recognized JSON Schema `$id` URI +/// for a specific schema. It handles returning the schemas for every recognized host, version, +/// and form. +/// +/// Parameters: +/// +/// - `schema_file_base_name` - specify the base name for the schema file, like `document` for +/// the configuration document schema or `manifest` for the resource manifest schema. +/// - `schema_folder_path` - specify the folder path for the schema file relative to the version +/// folder, like `config` for the configuration document schema or `resource` for the resource +/// manifest schema. +/// - `should_bundle` - specify whether the schema should be published in its bundled form. All +/// bundled schemas are also published with their VS Code form. Schemas that aren't bundled +/// aren't published with the VS Code form. +pub(crate) fn get_recognized_schema_uris( + schema_file_base_name: &str, + schema_folder_path: &str, + should_bundle: bool +) -> Vec { + let mut uris: Vec = Vec::new(); + let schema_forms = if should_bundle { + SchemaForm::all() + } else { + vec![SchemaForm::Canonical] + }; + for uri_prefix in SchemaUriPrefix::all() { + for schema_form in schema_forms.iter().copied() { + for schema_version in RecognizedSchemaVersion::all() { + uris.push( + get_recognized_schema_uri( + schema_file_base_name, + schema_folder_path, + schema_version, + schema_form, + uri_prefix + ) + ); + } + } + } + + uris +} + +/// Returns the JSON Schema to validate that a `$schema` keyword for a DSC type is one of the +/// recognized URIs. +/// +/// This is a convenience function used by the [`DscRepoSchema`] trait. It's not intended for +/// direct use. +#[must_use] +pub(crate) fn get_recognized_uris_subschema( + metadata: &Schema, + schema_file_base_name: &str, + schema_folder_path: &str, + should_bundle: bool +) -> Schema { + let enums: Vec = get_recognized_schema_uris( + schema_file_base_name, + schema_folder_path, + should_bundle + ).iter().map( + |schema_uri| serde_json::Value::String(schema_uri.clone()) + ).collect(); + + json_schema!({ + "type": "string", + "format": Some("uri".to_string()), + "enum": Some(enums), + "title": metadata.get("title"), + "description": metadata.get("description"), + }) +} + +/// Returns the recognized schema URI for the latest major version with the +/// `aka.ms` URI prefix. +/// +/// If the schema is published in bundled form, this function returns the URI for that form. +/// Otherwise, it returns the URI for the canonical (non-bundled) form. The VS Code form of the +/// schema is never returned as the default. +/// +/// Parameters: +/// +/// - `schema_file_base_name` - specify the base name for the schema file, like `document` for +/// the configuration document schema or `manifest` for the resource manifest schema. +/// - `schema_folder_path` - specify the folder path for the schema file relative to the version +/// folder, like `config` for the configuration document schema or `resource` for the resource +/// manifest schema. +/// - `should_bundle` - specify whether the schema should be published in its bundled form. All +/// bundled schemas are also published with their VS Code form. Schemas that aren't bundled +/// aren't published with the VS Code form. +pub(crate) fn get_default_schema_uri( + schema_file_base_name: &str, + schema_folder_path: &str, + should_bundle: bool +) -> String { + get_recognized_schema_uri( + schema_file_base_name, + schema_folder_path, + RecognizedSchemaVersion::default(), + get_default_schema_form(should_bundle), + SchemaUriPrefix::default() + ) +} + +/// Returns the default form for a schema depending on whether it publishes with its references +/// bundled. +/// +/// If a schema is published in bundled form, the bundled form is the default. Otherwise, the +/// default form is canonical (non-bundled). +pub(crate) fn get_default_schema_form(should_bundle: bool) -> SchemaForm { + if should_bundle { + SchemaForm::Bundled + } else { + SchemaForm::Canonical + } +} diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs new file mode 100644 index 000000000..38ac3161d --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The definition for [`RecognizedSchemaVersion`] is generated with the `build.rs` script. The build +//! script depends on the `.versions.json` and `.versions.ps1` files in the crate root. The script +//! checks the git tags for non-prerelease versions of DSC to generate the enum type with all of the +//! correct values. The enum can be used transparently throughout the rest of the libraries. + +include!(concat!(env!("OUT_DIR"), "/recognized_schema_version.rs")); diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/schema_form.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/schema_form.rs new file mode 100644 index 000000000..768a67115 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/schema_form.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines the different forms of JSON Schema that DSC publishes. +#[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq)] +pub enum SchemaForm { + /// Indicates that the schema is bundled using the 2020-12 schema bundling contract. + /// + /// These schemas include all of their references in the `$defs` keyword where the key for + /// each reference is the `$id` of that subschema and the value is the subschema. + /// + /// The bundled schemas are preferred for offline usage or where network latency is a concern. + #[default] + Bundled, + /// Indicates that the schema is enhanced for interactively viewing, authoring, and editing + /// the data in VS Code. + /// + /// These schemas include keywords not recognized by JSON Schema libraries and clients outside + /// of VS Code, like `markdownDescription` and `defaultSnippets`. The schema references and + /// definitions do not follow the canonical bundling for schema 2020-12, as the VS Code + /// JSON language server doesn't correctly resolve canonically bundled schemas. + VSCode, + /// Indicates that the schema is canonical but not bundled. It may contain references to other + /// JSON Schemas that require resolution by retrieving those schemas over the network. All + /// DSC schemas are published in this form for easier review, reuse, and retrieval. + Canonical, +} + +impl SchemaForm { + /// Returns the file extension for a given form of schema. + /// + /// The extension for [`Bundled`] and [`Canonical`] schemas is `.json` + /// + /// The extension for [`VSCode`] schemas is `.vscode.json` + /// + /// [`Bundled`]: SchemaForm::Bundled + /// [`Canonical`]: SchemaForm::Canonical + /// [`VSCode`]: SchemaForm::VSCode + #[must_use] + pub fn to_extension(&self) -> String { + match self { + Self::Bundled | Self::Canonical => ".json".to_string(), + Self::VSCode => ".vscode.json".to_string(), + } + } + + /// Return the prefix for a schema's folder path. + /// + /// The [`Bundled`] and [`VSCode`] schemas are always published in the `bundled` folder + /// immediately beneath the version folder. The [`Canonical`] schemas use the folder path + /// as defined for that schema. + /// + /// [`Bundled`]: SchemaForm::Bundled + /// [`Canonical`]: SchemaForm::Canonical + /// [`VSCode`]: SchemaForm::VSCode + #[must_use] + pub fn to_folder_prefix(&self) -> String { + match self { + Self::Bundled | Self::VSCode => "bundled/".to_string(), + Self::Canonical => String::new(), + } + } + + /// Returns every schema form for convenient iteration. + #[must_use] + pub fn all() -> Vec { + vec![ + Self::Bundled, + Self::VSCode, + Self::Canonical, + ] + } +} diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/schema_uri_prefix.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/schema_uri_prefix.rs new file mode 100644 index 000000000..5efced22f --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/schema_uri_prefix.rs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines the URI prefix for the hosted schemas. +/// +/// While the schemas are currently hosted in the GitHub repository, DSC provides the shortened +/// `aka.ms` link for convenience. Using this enum simplifies migrating to a new URI for schemas +/// in the future. +#[derive(Debug, Default, Clone, Copy, Hash, Eq, PartialEq)] +pub enum SchemaUriPrefix { + /// Defines the shortened link URI prefix as `https://aka.ms/dsc/schemas`. + #[default] + AkaDotMs, + /// Defines the canonical URI prefix as `https://raw.githubusercontent.com/PowerShell/DSC/main/schemas`. + Github, +} + +impl std::fmt::Display for SchemaUriPrefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AkaDotMs => write!(f, "https://aka.ms/dsc/schemas"), + Self::Github => write!(f, "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas"), + } + } +} + +impl SchemaUriPrefix { + /// Returns every known URI prefix for convenient iteration. + #[must_use] + pub fn all() -> Vec { + vec![ + Self::AkaDotMs, + Self::Github, + ] + } +} diff --git a/lib/dsc-lib-jsonschema/src/lib.rs b/lib/dsc-lib-jsonschema/src/lib.rs index 1a3c7a871..fa71ca8bc 100644 --- a/lib/dsc-lib-jsonschema/src/lib.rs +++ b/lib/dsc-lib-jsonschema/src/lib.rs @@ -8,6 +8,7 @@ use rust_i18n::i18n; #[macro_use] pub mod macros; +pub mod dsc_repo; pub mod schema_utility_extensions; pub mod transforms; pub mod vscode; diff --git a/lib/dsc-lib-jsonschema/src/tests/dsc_repo.rs b/lib/dsc-lib-jsonschema/src/tests/dsc_repo.rs new file mode 100644 index 000000000..943db2935 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/tests/dsc_repo.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::{JsonSchema, Schema, json_schema}; +use serde::{Deserialize, Serialize}; + +use crate::dsc_repo::{ + DscRepoSchema, + RecognizedSchemaVersion, + SchemaForm, + SchemaUriPrefix, + get_default_schema_uri, + get_recognized_schema_uri +}; + +#[test] +fn test_get_recognized_schema_uri() { + let expected = "https://aka.ms/dsc/schemas/v3/bundled/config/document.json".to_string(); + let actual = get_recognized_schema_uri( + "document", + "config", + RecognizedSchemaVersion::V3, + SchemaForm::Bundled, + SchemaUriPrefix::AkaDotMs + ); + assert_eq!(expected, actual) +} + +#[test] +fn test_get_default_schema_uri() { + let expected_bundled = "https://aka.ms/dsc/schemas/v3/bundled/config/document.json".to_string(); + let expected_canonical = "https://aka.ms/dsc/schemas/v3/config/document.json".to_string(); + + let schema_file_base_name = "document"; + let schema_folder_path = "config"; + + assert_eq!( + expected_bundled, + get_default_schema_uri(schema_file_base_name, schema_folder_path, true) + ); + assert_eq!( + expected_canonical, + get_default_schema_uri(schema_file_base_name, schema_folder_path, false) + ); +} + +#[test] +fn test_dsc_repo_schema_bundled() { + #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] + struct ExampleBundledSchema { + pub schema_version: String, + } + + impl DscRepoSchema for ExampleBundledSchema { + const SCHEMA_FILE_BASE_NAME: &'static str = "schema"; + const SCHEMA_FOLDER_PATH: &'static str = "example"; + const SCHEMA_SHOULD_BUNDLE: bool = true; + + fn schema_property_metadata() -> Schema { + json_schema!({ + "description": "An example schema for testing.", + }) + } + } + + let bundled_uri = "https://aka.ms/dsc/schemas/v3/bundled/example/schema.json".to_string(); + let vscode_uri = "https://aka.ms/dsc/schemas/v3/bundled/example/schema.vscode.json".to_string(); + let canonical_uri = "https://aka.ms/dsc/schemas/v3/example/schema.json".to_string(); + let schema_version = RecognizedSchemaVersion::V3; + + assert_eq!( + bundled_uri, + ExampleBundledSchema::default_schema_id_uri() + ); + + assert_eq!( + Some(bundled_uri), + ExampleBundledSchema::get_bundled_schema_id_uri(schema_version) + ); + + assert_eq!( + Some(vscode_uri), + ExampleBundledSchema::get_enhanced_schema_id_uri(schema_version) + ); + + assert_eq!( + canonical_uri, + ExampleBundledSchema::get_canonical_schema_id_uri(schema_version) + ) +} + +#[test] +fn test_dsc_repo_schema_not_bundled() { + #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] + struct ExampleNotBundledSchema { + pub schema_version: String, + } + + impl DscRepoSchema for ExampleNotBundledSchema { + const SCHEMA_FILE_BASE_NAME: &'static str = "schema"; + const SCHEMA_FOLDER_PATH: &'static str = "example"; + const SCHEMA_SHOULD_BUNDLE: bool = false; + + fn schema_property_metadata() -> Schema { + json_schema!({ + "description": "An example schema for testing.", + }) + } + } + + let canonical_uri = "https://aka.ms/dsc/schemas/v3/example/schema.json".to_string(); + let schema_version = RecognizedSchemaVersion::V3; + assert_eq!( + canonical_uri, + ExampleNotBundledSchema::default_schema_id_uri() + ); + + assert_eq!( + None, + ExampleNotBundledSchema::get_bundled_schema_id_uri(schema_version) + ); + + assert_eq!( + None, + ExampleNotBundledSchema::get_enhanced_schema_id_uri(schema_version) + ); + + assert_eq!( + canonical_uri, + ExampleNotBundledSchema::get_canonical_schema_id_uri(schema_version) + ) +} diff --git a/lib/dsc-lib-jsonschema/src/tests/mod.rs b/lib/dsc-lib-jsonschema/src/tests/mod.rs index 28ddd87a6..3e51fb2ae 100644 --- a/lib/dsc-lib-jsonschema/src/tests/mod.rs +++ b/lib/dsc-lib-jsonschema/src/tests/mod.rs @@ -12,5 +12,6 @@ //! 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 dsc_repo; #[cfg(test)] mod schema_utility_extensions; #[cfg(test)] mod vscode;