diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ada76..a0ab548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for `[ValidatePattern()]` attributes on DSC properties, emitting the regex as a `pattern` keyword in the generated JSON schema. - Added `-AllowNonEcmaPattern` switch to `New-DscAdaptedResourceManifest` to force-emit patterns containing .NET-specific regex constructs that are not ECMA 262 compatible. +- Added `-ModuleManifestPath` to `New-DscAdaptedResourceManifest` to take the module name, version, author, description and manifest path from a built module manifest while the resource classes are parsed from the file given in `-Path`, so manifests can be generated from resource source files. +- Added support for fully qualified type names such as `System.Boolean` and `System.Int32`, for `Nullable[T]`, for generic collections and for additional CLR types (`Guid`, `TimeSpan`, `SecureString`, unsigned integers, ...) in the generated JSON schema. These previously fell back to `string`. +- Added support for class types defined in the same file. They are emitted once under `$defs` in the embedded schema and referenced with `$ref`, including arrays of a class and classes nested in other classes. A class without `[DscProperty()]` members is described by its public instance properties. +- Added a shared `PSCredential` definition under `$defs` for credential properties, with `username` and `password` as the PowerShell adapter expects. +- Added property descriptions from `[System.ComponentModel.Description()]` attributes when the class comment-based help has no entry for the property. The missing-help warnings are only written when neither source describes a property. +- Added `ConvertTo-DscPropertyOverrideFromConfig` as a public command so the `Create_DscAdaptedResourceManifests` and `Create_DscResourceManifestsList` build tasks can apply `PropertyOverrides` from `build.yaml`. + +### Changed + +- The default `$schema` of generated adapted resource manifests is now `https://aka.ms/dsc/schemas/v3/bundled/resource/adapted/manifest.json`. DSC 3.3 reports the previous URI as deprecated. +- The `export` capability is only emitted for a `static Export()` method, because the PowerShell adapter invokes `Export` on the type rather than on an instance. +- A four-part `ModuleVersion` is emitted as `Major.Minor.Build` because the adapted resource manifest requires a semantic version. +- `New-DscPropertyOverride -JsonSchema` and `DscPropertyOverride.JsonSchema` accept any `IDictionary`, including ordered hashtables. +- `ToJson()` on `DscAdaptedResourceManifest` and `DscResourceManifestList` serializes with a depth of 20 to allow nested definitions and overrides. +- The build tasks write the manifest files as UTF-8 without a byte order mark on every PowerShell edition. +- Added `.OUTPUTS` to the comment-based help of every private function. ### Fixed - Fixed build task import so module aliases are correctly exported when the module is loaded. - Fixed `[ValidateSet()]` attributes on `[string]` DSC properties now being correctly emitted as `enum` in the generated JSON schema. +- Fixed `[ValidateSet()]` attributes on array properties now being emitted under `items` instead of as a top-level `enum`. +- Fixed `[ValidateSet()]` attributes on numeric and boolean properties now keeping the mapped JSON type with the values converted (`"enum": [0, 1]` for an `[int]`) instead of turning the property into a string enum. +- Fixed the Windows PowerShell test job by moving the Pester settings in `build.yaml` to the advanced configuration with `CodeCoverage.UseBreakpoints: true`. The profiler-based tracer that Pester 6 uses by default fails with `Index was out of range` as soon as a PowerShell class is instantiated under Windows PowerShell 5.1; breakpoint-based coverage works on every edition. - Fixed `UTF8BOM` issue on new script. - Fixed tasks not being updated in the manifest. - Added `Configuration` to `RequiredModules.psd1` for latest `ModuleBuilder` version. diff --git a/build.yaml b/build.yaml index 2e38367..c95d33e 100644 --- a/build.yaml +++ b/build.yaml @@ -77,32 +77,42 @@ BuildWorkflow: #################################################### Pester: - OutputFormat: NUnitXML + # Pester advanced configuration. A key that is not set uses the Sampler pipeline default. + Configuration: + # If no path is defined the default is to use all the tests under the project's + # tests folder. Paths can be defined to only run tests in certain folders, to run + # specific test files, or to specify the order tests are run. + Run: + Path: + # - tests/QA/module.tests.ps1 + # - tests/QA + # - tests/Unit + # - tests/Integration + Filter: + Tag: + ExcludeTag: + # - helpQuality + # - FunctionalQuality + # - TestQuality + Output: + Verbosity: Detailed + CodeCoverage: + CoveragePercentTarget: 85 # Set to 0 to bypass + # The profiler-based tracer that Pester 6 uses by default fails with 'Index was out of + # range' as soon as a PowerShell class is instantiated under Windows PowerShell 5.1. + # Breakpoint-based coverage works on every edition. + UseBreakpoints: true + #OutputPath: JaCoCo_$OsShortName.xml + #OutputEncoding: ascii + TestResult: + OutputFormat: NUnitXML # Excludes one or more paths from being used to calculate code coverage. ExcludeFromCodeCoverage: - Assets - tasks - - # If no scripts are defined the default is to use all the tests under the project's - # tests folder or source folder (if present). Test script paths can be defined to - # only run tests in certain folders, or run specific test files, or can be use to - # specify the order tests are run. - Script: - # - tests/QA/module.tests.ps1 - # - tests/QA - # - tests/Unit - # - tests/Integration - ExcludeTag: - # - helpQuality - # - FunctionalQuality - # - TestQuality - Tag: - CodeCoverageThreshold: 85 # Set to 0 to bypass - #CodeCoverageOutputFile: JaCoCo_$OsShortName.xml - #CodeCoverageOutputFileEncoding: ascii # Use this if code coverage should be merged from several pipeline test jobs. # Any existing keys above should be replaced. See also CodeCoverage below. - # CodeCoverageOutputFile is the file that is created for each pipeline test job. + # CodeCoverage.OutputPath is the file that is created for each pipeline test job. #CodeCoverageOutputFile: JaCoCo_Merge.xml # Use this to merged code coverage from several pipeline test jobs. diff --git a/source/Classes/002.DscAdaptedResourceManifest.ps1 b/source/Classes/002.DscAdaptedResourceManifest.ps1 index a795795..560891f 100644 --- a/source/Classes/002.DscAdaptedResourceManifest.ps1 +++ b/source/Classes/002.DscAdaptedResourceManifest.ps1 @@ -27,7 +27,7 @@ class DscAdaptedResourceManifest embedded = $this.ManifestSchema.Embedded } } - return $manifest | ConvertTo-Json -Depth 10 + return $manifest | ConvertTo-Json -Depth 20 } [hashtable] ToHashtable() diff --git a/source/Classes/003.DscPropertyOverride.ps1 b/source/Classes/003.DscPropertyOverride.ps1 index 745d526..b434cf9 100644 --- a/source/Classes/003.DscPropertyOverride.ps1 +++ b/source/Classes/003.DscPropertyOverride.ps1 @@ -3,7 +3,7 @@ class DscPropertyOverride [string] $Name [string] $Description [string] $Title - [hashtable] $JsonSchema + [System.Collections.IDictionary] $JsonSchema [string[]] $RemoveKeys [object] $Required diff --git a/source/Classes/004.DscResourceManifestList.ps1 b/source/Classes/004.DscResourceManifestList.ps1 index c1ff5ad..feada66 100644 --- a/source/Classes/004.DscResourceManifestList.ps1 +++ b/source/Classes/004.DscResourceManifestList.ps1 @@ -45,6 +45,6 @@ class DscResourceManifestList $result['extensions'] = @($this.Extensions) } - return $result | ConvertTo-Json -Depth 15 + return $result | ConvertTo-Json -Depth 20 } } diff --git a/source/Private/Add-AstProperty.ps1 b/source/Private/Add-AstProperty.ps1 index ed01f55..1098021 100644 --- a/source/Private/Add-AstProperty.ps1 +++ b/source/Private/Add-AstProperty.ps1 @@ -9,22 +9,41 @@ first so that derived class properties override them when the list is consumed. + For every property the element type is resolved by stripping an array + suffix and unwrapping Nullable[T]. When that element type is an enum + defined in the same file, or a .NET enum that can be reflected, the enum + member names are returned as EnumValues. When it is a class defined in + the same file, its name is returned as ComplexTypeName so the schema can + reference a shared definition for it. + .PARAMETER AllTypeDefinitions All type definition AST nodes discovered in the script. Used to resolve - base class types and enum types defined in the same file. + base class types, enum types and complex class types defined in the same + file. .PARAMETER TypeAst The type definition AST to collect properties from. .PARAMETER Properties The list to which property hashtables are added. Each hashtable contains - the property Name, TypeName, IsKey, IsMandatory, IsNotConfigurable and EnumValues. + the property Name, TypeName, IsArray, IsKey, IsMandatory, + IsNotConfigurable, EnumValues, PatternValue, ComplexTypeName and + Description. + + .PARAMETER AllProperties + Collect every public instance property instead of only those decorated + with [DscProperty()]. Used for complex classes that describe nested + values and therefore carry no DSC attributes. .EXAMPLE $properties = [System.Collections.Generic.List[hashtable]]::new() Add-AstProperty -AllTypeDefinitions $allTypes -TypeAst $typeAst -Properties $properties Collects all [DscProperty()] decorated properties from $typeAst into $properties. + + .OUTPUTS + Returns no object. The property hashtables are added to the list object + passed in the Properties parameter. #> function Add-AstProperty { @@ -42,7 +61,18 @@ function Add-AstProperty [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.List[hashtable]] - $Properties + $Properties, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllProperties + ) + + $descriptionAttributeNames = @( + 'Description', + 'DescriptionAttribute', + 'System.ComponentModel.Description', + 'System.ComponentModel.DescriptionAttribute' ) foreach ($typeConstraint in $TypeAst.BaseTypes) @@ -50,14 +80,14 @@ function Add-AstProperty $baseType = $AllTypeDefinitions | Where-Object { $_.Name -eq $typeConstraint.TypeName.Name } if ($baseType) { - Add-AstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $baseType -Properties $Properties + Add-AstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $baseType -Properties $Properties -AllProperties:$AllProperties } } foreach ($member in $TypeAst.Members) { $propertyAst = $member -as [System.Management.Automation.Language.PropertyMemberAst] - if (($null -eq $propertyAst) -or ($propertyAst.IsStatic)) + if (($null -eq $propertyAst) -or ($propertyAst.IsStatic) -or ($propertyAst.IsHidden)) { continue } @@ -68,6 +98,7 @@ function Add-AstProperty $isNotConfigurable = $false $validateSetValues = $null $validatePatternValue = $null + $description = $null foreach ($attr in $propertyAst.Attributes) { if ($attr.TypeName.Name -eq 'DscProperty') @@ -93,44 +124,113 @@ function Add-AstProperty { $validatePatternValue = $attr.PositionalArguments[0].Value } + + if ($attr.TypeName.Name -in $descriptionAttributeNames -and $attr.PositionalArguments.Count -gt 0) + { + $argument = $attr.PositionalArguments[0] + if ($argument -is [System.Management.Automation.Language.StringConstantExpressionAst] -or + $argument -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) + { + $description = $argument.Value + } + } } - if (-not $isDscProperty) + if (-not $isDscProperty -and -not $AllProperties) { continue } - $typeName = if ($propertyAst.PropertyType) + $typeName = 'string' + $elementTypeAst = $null + if ($propertyAst.PropertyType) + { + $typeName = $propertyAst.PropertyType.TypeName.Name + $elementTypeAst = $propertyAst.PropertyType.TypeName + } + + # Resolve the element type: strip an array suffix, then unwrap Nullable[T]. + $isArray = $false + $elementTypeName = $typeName + if ($elementTypeName -match '^(.+)\[\]$') + { + $isArray = $true + $elementTypeName = $Matches[1] + } + elseif ($elementTypeName -match '^(?:System\.Collections\.Generic\.)?(?:I?List|IEnumerable|ICollection|HashSet)\[(.+)\]$') + { + $isArray = $true + $elementTypeName = $Matches[1] + } + + if ($elementTypeName -match '^(?:System\.)?Nullable\[(.+)\]$') { - $propertyAst.PropertyType.TypeName.Name + $elementTypeName = $Matches[1] } - else + + if ($elementTypeAst -is [System.Management.Automation.Language.ArrayTypeName]) + { + $elementTypeAst = $elementTypeAst.ElementType + } + + if ($elementTypeAst -is [System.Management.Automation.Language.GenericTypeName] -and + $elementTypeAst.GenericArguments.Count -eq 1) { - 'string' + $elementTypeAst = $elementTypeAst.GenericArguments[0] } - # check if the type is an enum defined in the same file + # check if the type is an enum or a class defined in the same file $enumValues = $null + $complexTypeName = $null $enumAst = $AllTypeDefinitions | Where-Object { - $_.Name -eq $typeName -and $_.IsEnum + $_.Name -eq $elementTypeName -and $_.IsEnum } + $classAst = $AllTypeDefinitions | Where-Object { + $_.Name -eq $elementTypeName -and $_.IsClass + } + if ($enumAst) { $enumValues = @($enumAst.Members | ForEach-Object { $_.Name }) } + elseif ($classAst) + { + $complexTypeName = @($classAst)[0].Name + } elseif ($validateSetValues) { $enumValues = $validateSetValues } + elseif ($null -ne $elementTypeAst) + { + # A .NET enum that is not defined in the file can still be reflected. + $reflectionType = $null + try + { + $reflectionType = $elementTypeAst.GetReflectionType() + } + catch + { + $reflectionType = $null + } + + if ($null -ne $reflectionType -and $reflectionType.IsEnum) + { + $enumValues = @([System.Enum]::GetNames($reflectionType)) + } + } $Properties.Add(@{ Name = $propertyAst.Name TypeName = $typeName + IsArray = $isArray IsKey = $isKey IsMandatory = $isMandatory -or $isKey IsNotConfigurable = $isNotConfigurable EnumValues = $enumValues PatternValue = $validatePatternValue + ComplexTypeName = $complexTypeName + Description = $description }) } } diff --git a/source/Private/Add-JsonSchemaDefinition.ps1 b/source/Private/Add-JsonSchemaDefinition.ps1 new file mode 100644 index 0000000..b771e34 --- /dev/null +++ b/source/Private/Add-JsonSchemaDefinition.ps1 @@ -0,0 +1,96 @@ +<# + .SYNOPSIS + Adds the JSON schema definition of a complex class type to the $defs dictionary. + + .DESCRIPTION + Builds an object schema for a class defined in the parsed file and + stores it in the supplied definitions dictionary under the class name, + so properties can reference it with '#/$defs/'. The schema lists + the [DscProperty()] members of the class and its base classes. A class + without [DscProperty()] members contributes all of its public instance + properties instead. Definitions carry no required list so that nested + values stay as permissive as the class itself. Nested complex types are + added recursively and self-referencing types are guarded against. + + .PARAMETER Name + The name of the class to describe. + + .PARAMETER AllTypeDefinitions + All type definition AST nodes discovered in the script. Used to find the + class and to resolve its base classes and nested types. + + .PARAMETER Definitions + The ordered dictionary that collects shared definitions for the $defs + keyword of the embedded schema. + + .PARAMETER AllowNonEcmaPattern + When specified, [ValidatePattern()] values containing .NET-specific + regex constructs are still emitted as the pattern keyword. + + .EXAMPLE + $definitions = [ordered]@{} + Add-JsonSchemaDefinition -Name 'Segment' -AllTypeDefinitions $allTypes -Definitions $definitions + + Adds the definition of the Segment class to $definitions. + + .OUTPUTS + Returns no object. The definition is added to the ordered dictionary + object passed in the Definitions parameter. +#> +function Add-JsonSchemaDefinition +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [System.Management.Automation.Language.TypeDefinitionAst[]] + $AllTypeDefinitions, + + [Parameter(Mandatory = $true)] + [System.Collections.Specialized.OrderedDictionary] + $Definitions, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowNonEcmaPattern + ) + + if ($Definitions.Contains($Name)) + { + return + } + + $typeAst = @($AllTypeDefinitions | Where-Object { $_.Name -eq $Name -and $_.IsClass }) + if ($typeAst.Count -eq 0) + { + return + } + + # The placeholder stops a type that references itself from recursing forever. + $Definitions[$Name] = $null + + $properties = [System.Collections.Generic.List[hashtable]]::new() + Add-AstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $typeAst[0] -Properties $properties + + if ($properties.Count -eq 0) + { + Add-AstProperty -AllTypeDefinitions $AllTypeDefinitions -TypeAst $typeAst[0] -Properties $properties -AllProperties + } + + $schemaProperties = [ordered]@{} + foreach ($property in $properties) + { + $schemaProperties[$property.Name] = ConvertTo-JsonSchemaProperty -Property $property -AllTypeDefinitions $AllTypeDefinitions ` + -Definitions $Definitions -AllowNonEcmaPattern:$AllowNonEcmaPattern + } + + $Definitions[$Name] = [ordered]@{ + type = 'object' + additionalProperties = $false + properties = $schemaProperties + } +} diff --git a/source/Private/ConvertFrom-CommentBasedHelp.ps1 b/source/Private/ConvertFrom-CommentBasedHelp.ps1 index 647dd73..7010196 100644 --- a/source/Private/ConvertFrom-CommentBasedHelp.ps1 +++ b/source/Private/ConvertFrom-CommentBasedHelp.ps1 @@ -16,6 +16,9 @@ Parses the block comment token text and returns a hashtable with Synopsis, Description and Parameters keys. + + .OUTPUTS + Returns a hashtable object with Synopsis, Description and Parameters keys. #> function ConvertFrom-CommentBasedHelp { diff --git a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 index 57e501e..54bdeb2 100644 --- a/source/Private/ConvertTo-AdaptedResourceManifest.ps1 +++ b/source/Private/ConvertTo-AdaptedResourceManifest.ps1 @@ -16,6 +16,9 @@ Hydrates a hashtable parsed from a .dsc.adaptedResource.json file into a DscAdaptedResourceManifest object. + + .OUTPUTS + Returns a DscAdaptedResourceManifest object populated from the hashtable. #> function ConvertTo-AdaptedResourceManifest { diff --git a/source/Private/ConvertTo-Hashtable.ps1 b/source/Private/ConvertTo-Hashtable.ps1 index 48ce530..4623fc1 100644 --- a/source/Private/ConvertTo-Hashtable.ps1 +++ b/source/Private/ConvertTo-Hashtable.ps1 @@ -18,6 +18,10 @@ Converts the PSCustomObject graph produced by ConvertFrom-Json into nested ordered hashtables. + + .OUTPUTS + Returns an ordered hashtable object for dictionaries and PSCustomObjects, + an array for lists, and the unchanged object for scalar values. #> function ConvertTo-Hashtable { diff --git a/source/Private/ConvertTo-JsonSchemaProperty.ps1 b/source/Private/ConvertTo-JsonSchemaProperty.ps1 new file mode 100644 index 0000000..0c25ec9 --- /dev/null +++ b/source/Private/ConvertTo-JsonSchemaProperty.ps1 @@ -0,0 +1,222 @@ +<# + .SYNOPSIS + Converts a DSC resource property into its JSON schema. + + .DESCRIPTION + Produces the ordered hashtable that describes one property in the + embedded JSON schema of an adapted resource manifest. Complex class + types are emitted as a reference to a shared definition that is added + to the supplied definitions dictionary, enum values are emitted as an + enum (under items for array properties, and converted to the mapped + type for numeric and boolean properties), credentials reference the + shared PSCredential definition, and every other type is mapped with + ConvertTo-JsonSchemaType. The property description comes from the class + comment-based help, then from a [Description()] attribute, then from a + default sentence. + + .PARAMETER Property + The property hashtable produced by Add-AstProperty. + + .PARAMETER AllTypeDefinitions + All type definition AST nodes discovered in the script. Used to build + definitions for complex class types. + + .PARAMETER Definitions + The ordered dictionary that collects shared definitions for the $defs + keyword of the embedded schema. Entries are added as needed. + + .PARAMETER ClassHelp + Optional hashtable produced by Get-ClassCommentBasedHelp containing + per-parameter descriptions. + + .PARAMETER AllowNonEcmaPattern + When specified, [ValidatePattern()] values containing .NET-specific + regex constructs are still emitted as the pattern keyword. + + .EXAMPLE + $definitions = [ordered]@{} + $schema = ConvertTo-JsonSchemaProperty -Property $property -AllTypeDefinitions $allTypes -Definitions $definitions + + Builds the schema for one property and adds any definition it needs to $definitions. + + .OUTPUTS + Returns an ordered hashtable object describing the JSON schema of the property. +#> +function ConvertTo-JsonSchemaProperty +{ + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param + ( + [Parameter(Mandatory = $true)] + [hashtable] + $Property, + + [Parameter()] + [System.Management.Automation.Language.TypeDefinitionAst[]] + $AllTypeDefinitions, + + [Parameter(Mandatory = $true)] + [System.Collections.Specialized.OrderedDictionary] + $Definitions, + + [Parameter()] + [hashtable] + $ClassHelp, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowNonEcmaPattern + ) + + $schemaProperty = [ordered]@{} + $isArray = [bool] $Property['IsArray'] + $itemSchema = $null + + if ($Property['ComplexTypeName'] -and $AllTypeDefinitions) + { + Add-JsonSchemaDefinition -Name $Property['ComplexTypeName'] -AllTypeDefinitions $AllTypeDefinitions ` + -Definitions $Definitions -AllowNonEcmaPattern:$AllowNonEcmaPattern + + $itemSchema = [ordered]@{ '$ref' = '#/$defs/{0}' -f $Property['ComplexTypeName'] } + } + elseif ($Property['EnumValues']) + { + # A [ValidateSet()] on a numeric or boolean property lists its values as strings. Emit the + # mapped type with the values converted, so a configuration can carry the value as a number. + $jsonType = ConvertTo-JsonSchemaType -TypeName $Property['TypeName'] + $leaf = $jsonType + while ($leaf.Contains('items')) + { + $leaf = $leaf['items'] + } + + $leafType = if ($leaf.Contains('type')) { $leaf['type'] } else { 'string' } + + if ($leafType -in @('integer', 'number', 'boolean')) + { + $typedValues = [System.Collections.Generic.List[object]]::new() + $convertible = $true + foreach ($value in $Property['EnumValues']) + { + $text = [string] $value + switch ($leafType) + { + 'integer' + { + $parsedInteger = [System.Int64] 0 + $convertible = [System.Int64]::TryParse($text, [System.Globalization.NumberStyles]::Integer, [System.Globalization.CultureInfo]::InvariantCulture, [ref] $parsedInteger) + $typedValues.Add($parsedInteger) + } + 'number' + { + $parsedNumber = [System.Double] 0 + $convertible = [System.Double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref] $parsedNumber) + $typedValues.Add($parsedNumber) + } + 'boolean' + { + $parsedBoolean = $false + $convertible = [System.Boolean]::TryParse($text, [ref] $parsedBoolean) + $typedValues.Add($parsedBoolean) + } + } + + if (-not $convertible) + { + break + } + } + + $itemSchema = [ordered]@{ type = $leafType } + if ($convertible) + { + $itemSchema['enum'] = @($typedValues) + } + else + { + Write-Verbose "Property '$($Property['Name'])': not every ValidateSet value is a valid $leafType, so the enum keyword is not emitted." + } + } + else + { + $itemSchema = [ordered]@{ type = 'string'; enum = @($Property['EnumValues'] | ForEach-Object { [string] $_ }) } + } + } + + if ($null -ne $itemSchema) + { + if ($isArray) + { + $schemaProperty['type'] = 'array' + $schemaProperty['items'] = $itemSchema + } + else + { + foreach ($key in $itemSchema.Keys) + { + $schemaProperty[$key] = $itemSchema[$key] + } + } + } + else + { + $jsonType = ConvertTo-JsonSchemaType -TypeName $Property['TypeName'] + foreach ($key in $jsonType.Keys) + { + $schemaProperty[$key] = $jsonType[$key] + } + + # Credentials reference one shared definition that the adapter can turn into a PSCredential. + $leaf = $jsonType + while ($leaf.Contains('items')) + { + $leaf = $leaf['items'] + } + + if ($leaf['$ref'] -eq '#/$defs/PSCredential' -and -not $Definitions.Contains('PSCredential')) + { + $Definitions['PSCredential'] = [ordered]@{ + type = 'object' + properties = [ordered]@{ + username = [ordered]@{ type = 'string' } + password = [ordered]@{ type = 'string' } + } + } + } + } + + $schemaProperty['title'] = $Property['Name'] + + if (-not $Property['EnumValues'] -and $Property['PatternValue']) + { + if ($AllowNonEcmaPattern -or (Test-IsEcmaCompatiblePattern -Pattern $Property['PatternValue'])) + { + $schemaProperty['pattern'] = $Property['PatternValue'] + } + else + { + Write-Warning "Property '$($Property['Name'])': ValidatePattern value contains .NET-specific regex constructs that are not ECMA 262 compatible and will not be emitted. Use -AllowNonEcmaPattern to override." + } + } + + if ($Property['IsNotConfigurable']) + { + $schemaProperty['readOnly'] = $true + } + + if ($ClassHelp -and $ClassHelp.Parameters -and $ClassHelp.Parameters.ContainsKey($Property['Name'])) + { + $schemaProperty['description'] = $ClassHelp.Parameters[$Property['Name']] + } + elseif (-not [string]::IsNullOrWhiteSpace($Property['Description'])) + { + $schemaProperty['description'] = $Property['Description'] + } + else + { + $schemaProperty['description'] = "The $($Property['Name']) property." + } + + return $schemaProperty +} diff --git a/source/Private/ConvertTo-JsonSchemaType.ps1 b/source/Private/ConvertTo-JsonSchemaType.ps1 index ab5bc3c..6d82ccb 100644 --- a/source/Private/ConvertTo-JsonSchemaType.ps1 +++ b/source/Private/ConvertTo-JsonSchemaType.ps1 @@ -3,9 +3,15 @@ Converts a PowerShell type name to its JSON Schema type definition. .DESCRIPTION - Maps a PowerShell type name (such as 'string', 'int', 'bool', 'datetime' - or an array form like 'string[]') to a hashtable describing the - equivalent JSON Schema type. Unknown types fall back to 'string'. + Maps a PowerShell type name to an ordered hashtable describing the + equivalent JSON Schema type. The name may be written the short way + ('string', 'int', 'bool') or fully qualified ('System.String', + 'System.Boolean'), wrapped in 'Nullable[T]' or 'System.Nullable[T]', + declared as an array ('string[]', 'System.String[]') or as a generic + list ('System.Collections.Generic.List[string]'). Nullable types map to + their inner type, arrays and lists map to a JSON array of the element + type, credentials map to a reference to the shared PSCredential + definition, and unknown types fall back to 'string'. .PARAMETER TypeName The PowerShell type name to convert. @@ -16,14 +22,22 @@ Returns @{ type = 'boolean' }. .EXAMPLE - ConvertTo-JsonSchemaType -TypeName 'string[]' + ConvertTo-JsonSchemaType -TypeName 'System.Nullable[System.Int32]' + + Returns @{ type = 'integer' }. + + .EXAMPLE + ConvertTo-JsonSchemaType -TypeName 'System.String[]' Returns @{ type = 'array'; items = @{ type = 'string' } }. + + .OUTPUTS + Returns an ordered hashtable object describing the JSON Schema type. #> function ConvertTo-JsonSchemaType { [CmdletBinding()] - [OutputType([hashtable])] + [OutputType([System.Collections.Specialized.OrderedDictionary])] param ( [Parameter(Mandatory = $true)] @@ -31,32 +45,80 @@ function ConvertTo-JsonSchemaType $TypeName ) - switch ($TypeName) + $name = $TypeName.Trim() + + # Nullable[T] and System.Nullable[T] describe the inner type. + if ($name -match '^(?:System\.)?Nullable\[(.+)\]$') + { + return ConvertTo-JsonSchemaType -TypeName $Matches[1] + } + + # Arrays like string[] or System.String[]. + if ($name -match '^(.+)\[\]$') + { + $innerType = ConvertTo-JsonSchemaType -TypeName $Matches[1] + return [ordered]@{ type = 'array'; items = $innerType } + } + + # Generic collections like System.Collections.Generic.List[string]. + if ($name -match '^(?:System\.Collections\.Generic\.)?(?:I?List|IEnumerable|ICollection|HashSet)\[(.+)\]$') + { + $innerType = ConvertTo-JsonSchemaType -TypeName $Matches[1] + return [ordered]@{ type = 'array'; items = $innerType } + } + + # Generic dictionaries like System.Collections.Generic.Dictionary[string, string]. + if ($name -match '^(?:System\.Collections\.Generic\.)?I?Dictionary\[.+\]$') + { + return [ordered]@{ type = 'object' } + } + + # Fully qualified names such as System.Boolean map through their short name. + $shortName = ($name -split '\.')[-1] + + switch ($shortName) { - 'string' { return @{ type = 'string' } } - 'int' { return @{ type = 'integer' } } - 'int32' { return @{ type = 'integer' } } - 'int64' { return @{ type = 'integer' } } - 'long' { return @{ type = 'integer' } } - 'double' { return @{ type = 'number' } } - 'float' { return @{ type = 'number' } } - 'single' { return @{ type = 'number' } } - 'decimal' { return @{ type = 'number' } } - 'bool' { return @{ type = 'boolean' } } - 'boolean' { return @{ type = 'boolean' } } - 'switch' { return @{ type = 'boolean' } } - 'hashtable' { return @{ type = 'object' } } - 'datetime' { return @{ type = 'string'; format = 'date-time' } } + 'string' { return [ordered]@{ type = 'string' } } + 'char' { return [ordered]@{ type = 'string' } } + 'guid' { return [ordered]@{ type = 'string' } } + 'timespan' { return [ordered]@{ type = 'string' } } + 'uri' { return [ordered]@{ type = 'string' } } + 'securestring' { return [ordered]@{ type = 'string' } } + 'int' { return [ordered]@{ type = 'integer' } } + 'int16' { return [ordered]@{ type = 'integer' } } + 'short' { return [ordered]@{ type = 'integer' } } + 'int32' { return [ordered]@{ type = 'integer' } } + 'int64' { return [ordered]@{ type = 'integer' } } + 'long' { return [ordered]@{ type = 'integer' } } + 'uint16' { return [ordered]@{ type = 'integer' } } + 'ushort' { return [ordered]@{ type = 'integer' } } + 'uint32' { return [ordered]@{ type = 'integer' } } + 'uint' { return [ordered]@{ type = 'integer' } } + 'uint64' { return [ordered]@{ type = 'integer' } } + 'ulong' { return [ordered]@{ type = 'integer' } } + 'byte' { return [ordered]@{ type = 'integer' } } + 'sbyte' { return [ordered]@{ type = 'integer' } } + 'double' { return [ordered]@{ type = 'number' } } + 'float' { return [ordered]@{ type = 'number' } } + 'single' { return [ordered]@{ type = 'number' } } + 'decimal' { return [ordered]@{ type = 'number' } } + 'bool' { return [ordered]@{ type = 'boolean' } } + 'boolean' { return [ordered]@{ type = 'boolean' } } + 'switch' { return [ordered]@{ type = 'boolean' } } + 'switchparameter' { return [ordered]@{ type = 'boolean' } } + 'hashtable' { return [ordered]@{ type = 'object' } } + 'ordereddictionary' { return [ordered]@{ type = 'object' } } + 'idictionary' { return [ordered]@{ type = 'object' } } + 'datetime' { return [ordered]@{ type = 'string'; format = 'date-time' } } + 'datetimeoffset' { return [ordered]@{ type = 'string'; format = 'date-time' } } + 'pscredential' { return [ordered]@{ '$ref' = '#/$defs/PSCredential' } } + 'object' { return [ordered]@{} } + 'psobject' { return [ordered]@{} } + 'pscustomobject' { return [ordered]@{} } default { - # arrays like string[] or int[] - if ($TypeName -match '^(.+)\[\]$') - { - $innerType = ConvertTo-JsonSchemaType -TypeName $Matches[1] - return @{ type = 'array'; items = $innerType } - } # default to string for unknown types - return @{ type = 'string' } + return [ordered]@{ type = 'string' } } } } diff --git a/source/Private/Get-ClassCommentBasedHelp.ps1 b/source/Private/Get-ClassCommentBasedHelp.ps1 index bda696d..687508e 100644 --- a/source/Private/Get-ClassCommentBasedHelp.ps1 +++ b/source/Private/Get-ClassCommentBasedHelp.ps1 @@ -18,6 +18,10 @@ Returns a hashtable keyed by class name, where each value contains the parsed Synopsis, Description and Parameters from the block comment preceding that class declaration. + + .OUTPUTS + Returns a hashtable object keyed by class name, where each value is the + parsed comment-based help hashtable for that class. #> function Get-ClassCommentBasedHelp { diff --git a/source/Private/Get-DscResourceCapability.ps1 b/source/Private/Get-DscResourceCapability.ps1 index f960c62..570e5ad 100644 --- a/source/Private/Get-DscResourceCapability.ps1 +++ b/source/Private/Get-DscResourceCapability.ps1 @@ -6,7 +6,9 @@ Inspects the member AST of a class-based DSC resource type definition and returns the DSCv3 capability strings (such as 'get', 'set', 'test', 'whatIf', 'setHandlesExist', 'delete', 'export') corresponding to the - methods implemented on the class. + methods implemented on the class. The 'export' capability is only + returned for a static Export() method, because the PowerShell adapter + invokes Export on the type rather than on an instance. .PARAMETER MemberAst The collection of member AST nodes from the class type definition. @@ -16,6 +18,9 @@ Returns strings such as 'get', 'set' and 'test' for each DSCv3 method implemented on the class. + + .OUTPUTS + Returns a string array object with the unique DSCv3 capability names. #> function Get-DscResourceCapability { @@ -34,9 +39,9 @@ function Get-DscResourceCapability $_ -is [System.Management.Automation.Language.FunctionMemberAst] -and $_.Name -in $availableMethods } - foreach ($method in $methods.Name) + foreach ($method in $methods) { - switch ($method) + switch ($method.Name) { 'Get' { $capabilities.Add('get') } 'Set' { $capabilities.Add('set') } @@ -44,7 +49,13 @@ function Get-DscResourceCapability 'WhatIf' { $capabilities.Add('whatIf') } 'SetHandlesExist' { $capabilities.Add('setHandlesExist') } 'Delete' { $capabilities.Add('delete') } - 'Export' { $capabilities.Add('export') } + 'Export' + { + if ($method.IsStatic) + { + $capabilities.Add('export') + } + } } } diff --git a/source/Private/Get-DscResourceProperty.ps1 b/source/Private/Get-DscResourceProperty.ps1 index 0d278fe..f75ea40 100644 --- a/source/Private/Get-DscResourceProperty.ps1 +++ b/source/Private/Get-DscResourceProperty.ps1 @@ -19,6 +19,9 @@ Returns a list of hashtables describing every [DscProperty()] decorated property on the class and any base classes defined in the same file. + + .OUTPUTS + Returns a list object of hashtables, one per [DscProperty()] decorated property. #> function Get-DscResourceProperty { diff --git a/source/Private/Get-DscResourceTypeDefinition.ps1 b/source/Private/Get-DscResourceTypeDefinition.ps1 index 56617a4..ec7f661 100644 --- a/source/Private/Get-DscResourceTypeDefinition.ps1 +++ b/source/Private/Get-DscResourceTypeDefinition.ps1 @@ -15,6 +15,10 @@ Returns a list of hashtables, each containing the TypeDefinitionAst and AllTypeDefinitions for a class decorated with [DscResource()]. + + .OUTPUTS + Returns a list object of hashtables with TypeDefinitionAst and + AllTypeDefinitions keys, one per class decorated with [DscResource()]. #> function Get-DscResourceTypeDefinition { diff --git a/source/Private/New-EmbeddedJsonSchema.ps1 b/source/Private/New-EmbeddedJsonSchema.ps1 index bbc7b40..80596d9 100644 --- a/source/Private/New-EmbeddedJsonSchema.ps1 +++ b/source/Private/New-EmbeddedJsonSchema.ps1 @@ -6,7 +6,9 @@ Produces an ordered hashtable representing the embedded JSON Schema document for an adapted resource manifest. The schema describes the DSC resource properties and their required-ness, and uses descriptions - from the supplied class comment-based help when available. + from the supplied class comment-based help when available. Complex + class types and credentials are emitted once under the $defs keyword + and referenced from the properties that use them. .PARAMETER ResourceName The fully-qualified resource type name (for example 'MyModule/MyResource') @@ -15,6 +17,10 @@ .PARAMETER Properties The list of property hashtables produced by Get-DscResourceProperty. + .PARAMETER AllTypeDefinitions + All type definition AST nodes discovered in the script. Required to + build definitions for properties typed as a class from the same file. + .PARAMETER Description Optional description to embed in the schema document. @@ -40,6 +46,9 @@ -Properties $properties -Description 'Manages my resource.' -ClassHelp $helpMap Builds the schema using descriptions sourced from the class comment-based help. + + .OUTPUTS + Returns an ordered hashtable object representing the embedded JSON Schema document. #> function New-EmbeddedJsonSchema { @@ -56,6 +65,10 @@ function New-EmbeddedJsonSchema [System.Collections.Generic.List[hashtable]] $Properties, + [Parameter()] + [System.Management.Automation.Language.TypeDefinitionAst[]] + $AllTypeDefinitions, + [Parameter()] [string] $Description, @@ -71,54 +84,12 @@ function New-EmbeddedJsonSchema $schemaProperties = [ordered]@{} $requiredList = [System.Collections.Generic.List[string]]::new() + $definitions = [ordered]@{} foreach ($prop in $Properties) { - $schemaProp = [ordered]@{} - - if ($prop.EnumValues) - { - $schemaProp['type'] = 'string' - $schemaProp['enum'] = $prop.EnumValues - } - else - { - $jsonType = ConvertTo-JsonSchemaType -TypeName $prop.TypeName - foreach ($key in $jsonType.Keys) - { - $schemaProp[$key] = $jsonType[$key] - } - } - - $schemaProp['title'] = $prop.Name - - if (-not $prop.EnumValues -and $prop.PatternValue) - { - if ($AllowNonEcmaPattern -or (Test-IsEcmaCompatiblePattern -Pattern $prop.PatternValue)) - { - $schemaProp['pattern'] = $prop.PatternValue - } - else - { - Write-Warning "Property '$($prop.Name)': ValidatePattern value contains .NET-specific regex constructs that are not ECMA 262 compatible and will not be emitted. Use -AllowNonEcmaPattern to override." - } - } - - if ($prop.IsNotConfigurable) - { - $schemaProp['readOnly'] = $true - } - - if ($ClassHelp -and $ClassHelp.Parameters.ContainsKey($prop.Name)) - { - $schemaProp['description'] = $ClassHelp.Parameters[$prop.Name] - } - else - { - $schemaProp['description'] = "The $($prop.Name) property." - } - - $schemaProperties[$prop.Name] = $schemaProp + $schemaProperties[$prop.Name] = ConvertTo-JsonSchemaProperty -Property $prop -AllTypeDefinitions $AllTypeDefinitions ` + -Definitions $definitions -ClassHelp $ClassHelp -AllowNonEcmaPattern:$AllowNonEcmaPattern if ($prop.IsMandatory) { @@ -135,6 +106,17 @@ function New-EmbeddedJsonSchema properties = $schemaProperties } + if ($definitions.Count -gt 0) + { + $sortedDefinitions = [ordered]@{} + foreach ($definitionName in ($definitions.Keys | Sort-Object)) + { + $sortedDefinitions[$definitionName] = $definitions[$definitionName] + } + + $schema['$defs'] = $sortedDefinitions + } + if (-not [string]::IsNullOrEmpty($Description)) { $schema['description'] = $Description diff --git a/source/Private/Resolve-ModuleInfo.ps1 b/source/Private/Resolve-ModuleInfo.ps1 index dc16e6b..329545b 100644 --- a/source/Private/Resolve-ModuleInfo.ps1 +++ b/source/Private/Resolve-ModuleInfo.ps1 @@ -8,7 +8,9 @@ DSC resources. When a .psd1 path is provided the module manifest is imported and the RootModule is resolved relative to the manifest's directory. When a .ps1 or .psm1 is provided, a sibling .psd1 is used - when present; otherwise default values are returned. + when present; otherwise default values are returned. A four-part + ModuleVersion is returned as Major.Minor.Build because the adapted + resource manifest requires a semantic version. .PARAMETER Path The path to a .ps1, .psm1 or .psd1 file. @@ -23,6 +25,10 @@ $info = Resolve-ModuleInfo -Path './MyResource.psm1' Returns a hashtable with defaults when no companion .psd1 exists. + + .OUTPUTS + Returns a hashtable object with ModuleName, Version, Author, Description, + ScriptPath, Psd1Path and Directory keys. #> function Resolve-ModuleInfo { @@ -43,7 +49,13 @@ function Resolve-ModuleInfo { $manifestData = Import-PowerShellDataFile -Path $resolvedPath $moduleName = [System.IO.Path]::GetFileNameWithoutExtension($resolvedPath) - $version = if ($manifestData.ModuleVersion) { $manifestData.ModuleVersion } else { '0.0.1' } + $version = if ($manifestData.ModuleVersion) { [string] $manifestData.ModuleVersion } else { '0.0.1' } + if ($version -match '^(\d+\.\d+\.\d+)\.\d+$') + { + Write-Verbose "Module version '$version' has four parts. Using '$($Matches[1])' for the adapted resource manifest." + $version = $Matches[1] + } + $author = if ($manifestData.Author) { $manifestData.Author } else { '' } $description = if ($manifestData.Description) { ($manifestData.Description -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) -join ' ' } else { '' } diff --git a/source/Private/Test-IsEcmaCompatiblePattern.ps1 b/source/Private/Test-IsEcmaCompatiblePattern.ps1 index 24423e0..0cb4887 100644 --- a/source/Private/Test-IsEcmaCompatiblePattern.ps1 +++ b/source/Private/Test-IsEcmaCompatiblePattern.ps1 @@ -33,6 +33,10 @@ Test-IsEcmaCompatiblePattern -Pattern '^\A[a-z]+\Z$' Returns $false — `\A` and `\Z` are .NET-only anchors. + + .OUTPUTS + Returns a boolean object: $true when the pattern uses only ECMA 262 + compatible syntax, otherwise $false. #> function Test-IsEcmaCompatiblePattern { diff --git a/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 b/source/Public/ConvertTo-DscPropertyOverrideFromConfig.ps1 similarity index 74% rename from source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 rename to source/Public/ConvertTo-DscPropertyOverrideFromConfig.ps1 index a613478..7cc4541 100644 --- a/source/Private/ConvertTo-DscPropertyOverrideFromConfig.ps1 +++ b/source/Public/ConvertTo-DscPropertyOverrideFromConfig.ps1 @@ -6,18 +6,32 @@ Maps each hashtable entry from the build configuration PropertyOverrides section into a DscPropertyOverride object understood by Update-DscAdaptedResourceManifest. Each entry must contain at least a 'Name' key. Supported optional keys are - 'Description', 'Title', 'JsonSchema', 'RemoveKeys', and 'Required'. + 'Description', 'Title', 'JsonSchema', 'RemoveKeys', and 'Required'. Entries + without a Name are skipped with a warning. - This function must only be called after DscResource.Authoring has been imported - into the session. + The Create_DscAdaptedResourceManifests and Create_DscResourceManifestsList build + tasks call this command for the PropertyOverrides of each resource in build.yaml. .PARAMETER OverrideConfig - An array of hashtables, each describing one property override. + An array of hashtables, each describing one property override with the keys + listed in the description. .EXAMPLE $overrides = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig $configEntries Converts a list of configuration hashtables into DscPropertyOverride objects. + + .EXAMPLE + $overrides = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig @( + @{ Name = 'Count'; JsonSchema = @{ minimum = 0; maximum = 100 } } + @{ Name = 'Tags'; Required = $false } + ) + $manifest | Update-DscAdaptedResourceManifest -PropertyOverride $overrides + + Builds two overrides from inline configuration and applies them to a manifest. + + .OUTPUTS + Returns an array of DscPropertyOverride objects, one per valid entry. #> function ConvertTo-DscPropertyOverrideFromConfig { diff --git a/source/Public/New-DscAdaptedResourceManifest.ps1 b/source/Public/New-DscAdaptedResourceManifest.ps1 index 74f7166..176f463 100644 --- a/source/Public/New-DscAdaptedResourceManifest.ps1 +++ b/source/Public/New-DscAdaptedResourceManifest.ps1 @@ -8,6 +8,13 @@ returns a DscAdaptedResourceManifest object that complies with the DSCv3 adapted resource manifest JSON schema. + Property types are mapped from their short or fully qualified names, Nullable[T] is + unwrapped, classes defined in the same file are emitted once under the $defs keyword + of the embedded schema and referenced from the properties that use them, and + PSCredential properties reference a shared credential definition. Property + descriptions come from the class comment-based help, then from a + [System.ComponentModel.Description()] attribute on the property. + The returned objects can be serialized to JSON using the .ToJson() method and written to `.dsc.adaptedResource.json` files. These manifests enable DSCv3 to discover and use PowerShell DSC resources without running Invoke-DscCacheRefresh. @@ -19,11 +26,24 @@ the version defaults to '0.0.1'. Use the Version parameter to supply the correct version in that case. + .PARAMETER ModuleManifestPath + The path to the module manifest (.psd1) that the adapted resource manifests describe. + When specified, the module name, version, author, description and the manifest path + are taken from this file instead of from the file given in Path, while the classes are + still parsed from Path. Use it to generate manifests from the source files of a module + whose built manifest lives elsewhere. The file is read once per command invocation. + .PARAMETER Version Overrides the version resolved from the module manifest. Must be a valid semantic version string (e.g. '1.2.3' or '1.2.3-preview'). When omitted, the version from the .psd1 ModuleVersion field is used, or '0.0.1' for files without a co-located manifest. + .PARAMETER AllowNonEcmaPattern + When specified, `[ValidatePattern()]` regex values that contain .NET-specific constructs + incompatible with ECMA 262 (e.g. `\A`, `\Z`, atomic groups, inline flags) are still + written into the JSON Schema `pattern` keyword. By default such patterns are skipped + and a warning is written instead. + .EXAMPLE New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 @@ -42,16 +62,17 @@ Discovers all module manifests under `./MyModules` and pipes them into the function to generate adapted resource manifests for every class-based DSC resource found. + .EXAMPLE + Get-ChildItem -Path ./source/Resources -Filter *.psm1 -Recurse | + New-DscAdaptedResourceManifest -ModuleManifestPath ./output/MyModule/MyModule.psd1 + + Parses every resource source file and names the manifests after the built module, + so that each manifest carries type 'MyModule/' and path 'MyModule.psd1'. + .OUTPUTS Returns a DscAdaptedResourceManifest object for each class-based DSC resource found. The object has a .ToJson() method for serialization to the adapted resource manifest JSON format. - - .PARAMETER AllowNonEcmaPattern - When specified, `[ValidatePattern()]` regex values that contain .NET-specific constructs - incompatible with ECMA 262 (e.g. `\A`, `\Z`, atomic groups, inline flags) are still - written into the JSON Schema `pattern` keyword. By default such patterns are skipped - and a warning is written instead. #> function New-DscAdaptedResourceManifest { @@ -76,6 +97,21 @@ function New-DscAdaptedResourceManifest [string] $Path, + [Parameter()] + [ValidateScript({ + if (-not (Test-Path -LiteralPath $_)) + { + throw "Module manifest '$_' does not exist." + } + if ([System.IO.Path]::GetExtension($_) -ne '.psd1') + { + throw "Module manifest '$_' must be a .psd1 file." + } + return $true + })] + [string] + $ModuleManifestPath, + # Semantic version string for PS7: SemanticVersion Class [Parameter()] [ValidateScript({ @@ -93,10 +129,28 @@ function New-DscAdaptedResourceManifest $AllowNonEcmaPattern ) + begin + { + $moduleInfoOverride = $null + + if ($PSBoundParameters.ContainsKey('ModuleManifestPath')) + { + $moduleInfoOverride = Resolve-ModuleInfo -Path $ModuleManifestPath + } + } + process { $moduleInfo = Resolve-ModuleInfo -Path $Path + if ($null -ne $moduleInfoOverride) + { + foreach ($key in @('ModuleName', 'Version', 'Author', 'Description', 'Psd1Path')) + { + $moduleInfo[$key] = $moduleInfoOverride[$key] + } + } + if (-not (Test-Path -LiteralPath $moduleInfo.ScriptPath)) { Write-Error "Cannot find script file '$($moduleInfo.ScriptPath)' to parse." @@ -127,6 +181,15 @@ function New-DscAdaptedResourceManifest $classHelp = $null $resourceDescription = $moduleInfo.Description + $hasDescriptionAttribute = $false + foreach ($prop in $properties) + { + if (-not [string]::IsNullOrWhiteSpace($prop.Description)) + { + $hasDescriptionAttribute = $true + break + } + } if ($classHelpMap.ContainsKey($resourceName)) { @@ -144,7 +207,7 @@ function New-DscAdaptedResourceManifest $missingParams = @() foreach ($prop in $properties) { - if (-not $classHelp.Parameters.ContainsKey($prop.Name)) + if (-not $classHelp.Parameters.ContainsKey($prop.Name) -and [string]::IsNullOrWhiteSpace($prop.Description)) { $missingParams += $prop.Name } @@ -155,7 +218,7 @@ function New-DscAdaptedResourceManifest Write-Warning "Class '$resourceName' comment-based help is missing .PARAMETER documentation for: $($missingParams -join ', ')" } } - else + elseif (-not $hasDescriptionAttribute) { Write-Warning "No comment-based help found above class '$resourceName'. Using default descriptions." } @@ -163,6 +226,7 @@ function New-DscAdaptedResourceManifest $newEmbeddedJsonSchemaParameters = @{ ResourceName = $resourceType Properties = $properties + AllTypeDefinitions = $allTypeDefinitions Description = $resourceDescription ClassHelp = $classHelp AllowNonEcmaPattern = $AllowNonEcmaPattern diff --git a/source/Public/New-DscPropertyOverride.ps1 b/source/Public/New-DscPropertyOverride.ps1 index b42538c..df65960 100644 --- a/source/Public/New-DscPropertyOverride.ps1 +++ b/source/Public/New-DscPropertyOverride.ps1 @@ -16,8 +16,8 @@ Override the property title text. .PARAMETER JsonSchema - A hashtable of JSON schema keywords to merge into the property definition - (e.g., anyOf, oneOf, default, minimum, maximum, pattern, format). + A hashtable or ordered dictionary of JSON schema keywords to merge into the + property definition (e.g., anyOf, oneOf, default, minimum, maximum, pattern, format). .PARAMETER RemoveKeys An array of JSON schema key names to remove from the property before merging @@ -73,7 +73,7 @@ function New-DscPropertyOverride $Title, [Parameter()] - [hashtable] + [System.Collections.IDictionary] $JsonSchema, [Parameter()] diff --git a/source/WikiSource/Command-Reference.md b/source/WikiSource/Command-Reference.md index 715a5ed..cd9207f 100644 --- a/source/WikiSource/Command-Reference.md +++ b/source/WikiSource/Command-Reference.md @@ -5,6 +5,7 @@ 1. Import-DscAdaptedResourceManifest 1. Import-DscResourceManifest 1. New-DscPropertyOverride +1. ConvertTo-DscPropertyOverrideFromConfig 1. New-DscResourceManifest 1. Update-DscAdaptedResourceManifest @@ -15,14 +16,15 @@ This page lists the public commands in `DscResource.Authoring`. The SYNOPSIS text matches the command help used by the module. -| Command | Synopsis | -|-------------------------------------|------------------------------------------------------------------------------------------| -| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | -| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | -| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | -| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | -| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | -| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | +| Command | Synopsis | +|-------------------------------------------|------------------------------------------------------------------------------------------| +| `New-DscAdaptedResourceManifest` | Creates adapted resource manifest objects from class-based PowerShell DSC resources. | +| `Import-DscAdaptedResourceManifest` | Imports adapted resource manifest objects from `.dsc.adaptedResource.json` files. | +| `Import-DscResourceManifest` | Imports a DSC resource manifest list from a `.dsc.manifests.json` file. | +| `New-DscPropertyOverride` | Creates a `DscPropertyOverride` object for use with `Update-DscAdaptedResourceManifest`. | +| `ConvertTo-DscPropertyOverrideFromConfig` | Converts property override configuration entries into `DscPropertyOverride` objects. | +| `New-DscResourceManifest` | Creates a DSC resource manifests list for bundling multiple resources in a single file. | +| `Update-DscAdaptedResourceManifest` | Applies post-processing overrides to adapted resource manifest objects. | --- @@ -38,7 +40,7 @@ resources. ### SYNTAX ```powershell -New-DscAdaptedResourceManifest [-Path] [] +New-DscAdaptedResourceManifest [-Path] [-ModuleManifestPath ] [-Version ] [-AllowNonEcmaPattern] [] ``` ### DESCRIPTION @@ -48,6 +50,19 @@ Parses a `.ps1`, `.psm1`, or `.psd1` file to find classes marked with `DscAdaptedResourceManifest` object that can be serialized to JSON with `.ToJson()`. +Property types may be written the short way (`[string]`, `[bool]`) or fully +qualified (`[System.String]`, `[System.Boolean]`), wrapped in `Nullable[T]`, +or declared as arrays. Classes defined in the same file are emitted once under +`$defs` in the embedded schema and referenced with `$ref`; `PSCredential` +properties reference a shared credential definition. Property descriptions +come from the class comment-based help, then from a +`[System.ComponentModel.Description()]` attribute on the property. + +`-ModuleManifestPath` takes the module name, version, author, description and +manifest path from a built module manifest while the classes are parsed from +`-Path`. `-Version` overrides the version. `-AllowNonEcmaPattern` emits +`[ValidatePattern()]` values that use .NET-only regex constructs. + ### EXAMPLE ```powershell @@ -57,6 +72,17 @@ New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 Creates adapted resource manifest objects for all class-based DSC resources in the module. +### EXAMPLE + +```powershell +Get-ChildItem -Path ./source/Resources -Filter *.psm1 -Recurse | + New-DscAdaptedResourceManifest -ModuleManifestPath ./output/MyModule/MyModule.psd1 +``` + +Parses every resource source file and names the manifests after the built +module, so each manifest carries type `MyModule/` and path +`MyModule.psd1`. + --- @@ -133,7 +159,7 @@ Creates a `DscPropertyOverride` object for use with ### SYNTAX ```powershell -New-DscPropertyOverride [-Name] [[-Description] ] [[-Title] ] [[-JsonSchema] ] [[-RemoveKeys] ] [[-Required] ] [] +New-DscPropertyOverride [-Name] [[-Description] ] [[-Title] ] [[-JsonSchema] ] [[-RemoveKeys] ] [[-Required] ] [] ``` ### DESCRIPTION @@ -152,6 +178,43 @@ Creates an override that adds numeric constraints to the `Count` property. --- + + +## ConvertTo-DscPropertyOverrideFromConfig + +### SYNOPSIS + +Converts property override configuration entries into `DscPropertyOverride` +objects. + +### SYNTAX + +```powershell +ConvertTo-DscPropertyOverrideFromConfig [-OverrideConfig] [] +``` + +### DESCRIPTION + +Maps each hashtable entry of a `PropertyOverrides` configuration, such as the +one read from `build.yaml` by the build tasks, into a `DscPropertyOverride` +object. Each entry needs a `Name` key and may carry `Description`, `Title`, +`JsonSchema`, `RemoveKeys` and `Required`. Entries without a `Name` are +skipped with a warning. + +### EXAMPLE + +```powershell +$overrides = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig @( + @{ Name = 'Count'; JsonSchema = @{ minimum = 0; maximum = 100 } } + @{ Name = 'Tags'; Required = $false } +) +$manifest | Update-DscAdaptedResourceManifest -PropertyOverride $overrides +``` + +Builds two overrides from inline configuration and applies them to a manifest. + +--- + ## New-DscResourceManifest diff --git a/source/WikiSource/Examples.md b/source/WikiSource/Examples.md index 18a0fe4..3169bf2 100644 --- a/source/WikiSource/Examples.md +++ b/source/WikiSource/Examples.md @@ -3,11 +3,16 @@ 1. Overview 1. Generate manifests for one module 1. Generate manifests for multiple modules +1. Generate manifests from resource source files +1. Declare property types +1. Describe nested values with classes +1. Describe properties with the Description attribute 1. Override schema descriptions 1. Add JSON schema constraints 1. Generate read-only properties 1. Restrict property values with ValidateSet 1. Validate property format with ValidatePattern +1. Advertise export with a static Export method 1. Publish manifests with a module @@ -51,6 +56,173 @@ Get-ChildItem -Path ./Modules -Filter *.psd1 -Recurse | } ``` + + +## Generate manifests from resource source files + +A module that keeps one source file per resource can generate the manifests +from those files while naming them after the built module. `-ModuleManifestPath` +supplies the module name, version, author, description and the `path` of the +manifest; the classes are still parsed from each file: + +```powershell +Get-ChildItem -Path ./source/Resources -Filter *.psm1 -Recurse | + New-DscAdaptedResourceManifest -ModuleManifestPath ./output/MyModule/MyModule.psd1 | + ForEach-Object { + $fileName = '{0}.dsc.adaptedResource.json' -f ($_.Type -replace '/', '.') + $_.ToJson() | Set-Content -Path (Join-Path ./output/MyModule $fileName) + } +``` + +Every manifest carries `type: MyModule/` and `path: MyModule.psd1`. A +four-part `ModuleVersion` such as `1.2.3.4` is written as `1.2.3`, because the +manifest requires a semantic version. + + + +## Declare property types + +Property types may be written the short way or fully qualified, wrapped in +`Nullable[T]`, or declared as arrays. All of these map to the expected JSON +schema types: + +```powershell +[DscResource()] +class MyResource +{ + [DscProperty(Key)] + [System.String] $Name + + [DscProperty()] + [System.Boolean] $Enabled + + [DscProperty()] + [System.Nullable[System.Int32]] $Limit + + [DscProperty()] + [System.String[]] $Tags + + [DscProperty()] + [System.Management.Automation.PSCredential] $Credential + + # ... +} +``` + +The generated schema fragment: + +```json +{ + "Name": { "type": "string", "title": "Name" }, + "Enabled": { "type": "boolean", "title": "Enabled" }, + "Limit": { "type": "integer", "title": "Limit" }, + "Tags": { "type": "array", "items": { "type": "string" }, "title": "Tags" }, + "Credential": { "$ref": "#/$defs/PSCredential", "title": "Credential" } +} +``` + +A credential property references one shared definition with `username` and +`password` string properties, which is the shape the PowerShell adapter turns +into a `PSCredential`. + + + +## Describe nested values with classes + +A property typed as a class defined in the same file is described once under +`$defs` and referenced from every property that uses it, including arrays and +classes nested in other classes: + +```powershell +class Segment +{ + [DscProperty(Key)] + [System.String] $Name + + [DscProperty()] + [System.String[]] $AllowedOrigins +} + +[DscResource()] +class MyResource +{ + [DscProperty(Key)] + [System.String] $Name + + [DscProperty()] + [Segment] $Primary + + [DscProperty()] + [Segment[]] $Segments + + # ... +} +``` + +The generated schema fragment: + +```json +{ + "properties": { + "Primary": { "$ref": "#/$defs/Segment", "title": "Primary" }, + "Segments": { + "type": "array", + "items": { "$ref": "#/$defs/Segment" }, + "title": "Segments" + } + }, + "$defs": { + "Segment": { + "type": "object", + "additionalProperties": false, + "properties": { + "Name": { "type": "string", "title": "Name" }, + "AllowedOrigins": { "type": "array", "items": { "type": "string" }, "title": "AllowedOrigins" } + } + } + } +} +``` + +Definitions list the `[DscProperty()]` members of the class. A class without +`[DscProperty()]` members is described by its public instance properties. +Definitions carry no `required` list. + + + +## Describe properties with the Description attribute + +When a class has no comment-based help, or its help has no `.PARAMETER` +entry for a property, a `[System.ComponentModel.Description()]` attribute on +the property supplies the description: + +```powershell +[DscResource()] +class MyResource +{ + [DscProperty(Key)] + [System.ComponentModel.Description('The unique name of the resource.')] + [System.String] $Name + + # ... +} +``` + +The generated schema fragment: + +```json +{ + "Name": { + "type": "string", + "title": "Name", + "description": "The unique name of the resource." + } +} +``` + +A `.PARAMETER` entry in the comment-based help wins over the attribute when +both exist. + ## Override schema descriptions @@ -163,6 +335,12 @@ The generated schema fragment for those two properties: A PowerShell `enum` type defined in the same file is handled identically — you do not need `[ValidateSet()]` when an `enum` type is already used. +On an array property the `enum` is emitted under `items`. On a numeric or +boolean property the values are converted to the mapped type, so +`[ValidateSet('0', '1')] [System.Int32] $Level` becomes +`{ "type": "integer", "enum": [0, 1] }` and a configuration can carry the +value as a number. + ## Validate property format with ValidatePattern @@ -221,6 +399,33 @@ To emit the pattern anyway, pass `-AllowNonEcmaPattern`: New-DscAdaptedResourceManifest -Path ./MyModule/MyModule.psd1 -AllowNonEcmaPattern ``` + + +## Advertise export with a static Export method + +`dsc resource export` invokes `Export()` on the class type, not on an +instance. The `export` capability is therefore only emitted for a static +method: + +```powershell +[DscResource()] +class MyResource +{ + [DscProperty(Key)] + [System.String] $Name + + static [MyResource[]] Export() + { + return @() + } + + # ... +} +``` + +An instance `Export()` method leaves the capability out, so `dsc` does not +advertise an operation the adapter cannot run. + ## Publish manifests with a module diff --git a/source/WikiSource/Getting-Started.md b/source/WikiSource/Getting-Started.md index 69683d7..ed3c73f 100644 --- a/source/WikiSource/Getting-Started.md +++ b/source/WikiSource/Getting-Started.md @@ -91,9 +91,13 @@ class ExampleResource { - `[DscProperty(Mandatory)]` adds the property to the schema `required` list. - `[DscProperty(NotConfigurable)]` marks the property as `readOnly` in the generated JSON schema. -- `Get()`, `Set()`, `Test()`, `Delete()`, `Export()`, `WhatIf()`, and - `SetHandlesExist()` determine the resource capabilities. -- Comment-based help supplies resource and property descriptions. +- `Get()`, `Set()`, `Test()`, `Delete()`, `WhatIf()`, `SetHandlesExist()` and + a static `Export()` determine the resource capabilities. +- Property types may be short (`[string]`) or fully qualified + (`[System.String]`), nullable, arrays, or classes defined in the same file. +- Comment-based help supplies resource and property descriptions; a + `[System.ComponentModel.Description()]` attribute on a property is used when + the help has no entry for it. diff --git a/source/prefix.ps1 b/source/prefix.ps1 index 27bcdeb..9d5c0a8 100644 --- a/source/prefix.ps1 +++ b/source/prefix.ps1 @@ -1,4 +1,4 @@ -$script:AdaptedResourceSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' +$script:AdaptedResourceSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/resource/adapted/manifest.json' $script:ResourceManifestSchemaUri = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' $script:JsonSchemaUri = 'https://json-schema.org/draft/2020-12/schema' $script:DefaultAdapter = 'Microsoft.Adapter/PowerShell' diff --git a/source/tasks/Create_DscAdaptedResourceManifests.build.ps1 b/source/tasks/Create_DscAdaptedResourceManifests.build.ps1 index 09c8f44..462ec39 100644 --- a/source/tasks/Create_DscAdaptedResourceManifests.build.ps1 +++ b/source/tasks/Create_DscAdaptedResourceManifests.build.ps1 @@ -170,7 +170,8 @@ task Create_DscAdaptedResourceManifests { Write-Build -Color 'DarkGray' -Text "`tWriting '$outputFilePath'..." - $manifest.ToJson() | Set-Content -Path $outputFilePath -Encoding 'UTF8' -Force + # Write UTF-8 without a byte order mark on every PowerShell edition; dsc parses the file as JSON. + [System.IO.File]::WriteAllText($outputFilePath, $manifest.ToJson(), [System.Text.UTF8Encoding]::new($false)) Write-Build -Color 'Green' -Text "`tCreated adapted resource manifest '$outputFileName'." } diff --git a/source/tasks/Create_DscResourceManifestsList.build.ps1 b/source/tasks/Create_DscResourceManifestsList.build.ps1 index c7cae55..edfcf2f 100644 --- a/source/tasks/Create_DscResourceManifestsList.build.ps1 +++ b/source/tasks/Create_DscResourceManifestsList.build.ps1 @@ -190,7 +190,8 @@ task Create_DscResourceManifestsList { Write-Build -Color 'DarkGray' -Text "`tWriting '$outputFilePath'..." - $manifestList.ToJson() | Set-Content -Path $outputFilePath -Encoding 'UTF8' -Force + # Write UTF-8 without a byte order mark on every PowerShell edition; dsc parses the file as JSON. + [System.IO.File]::WriteAllText($outputFilePath, $manifestList.ToJson(), [System.Text.UTF8Encoding]::new($false)) Write-Build -Color 'Green' -Text "`tCreated DSC resource manifests list '$outputFileName' with $($manifestList.AdaptedResources.Count) adapted resource(s) in '$BuiltModuleBase'." } \ No newline at end of file diff --git a/tests/Unit/Fixtures/TypedResource/TypedResource.psd1 b/tests/Unit/Fixtures/TypedResource/TypedResource.psd1 new file mode 100644 index 0000000..a467da2 --- /dev/null +++ b/tests/Unit/Fixtures/TypedResource/TypedResource.psd1 @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +@{ + RootModule = 'TypedResource.psm1' + ModuleVersion = '1.2.3.4' + GUID = '5c1f0d2e-9a7b-4c3d-8e2f-1a2b3c4d5e6f' + Author = 'Contoso' + CompanyName = 'Contoso' + Copyright = '(c) Contoso. All rights reserved.' + Description = 'A typed DSC resource for testing.' + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + DscResourcesToExport = @('TypedResource') +} diff --git a/tests/Unit/Fixtures/TypedResource/TypedResource.psm1 b/tests/Unit/Fixtures/TypedResource/TypedResource.psm1 new file mode 100644 index 0000000..9a53fff --- /dev/null +++ b/tests/Unit/Fixtures/TypedResource/TypedResource.psm1 @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +class Cors +{ + [DscProperty()] + [System.ComponentModel.Description('The allowed origins.')] + [System.String[]] $AllowedOrigins + + [DscProperty()] + [System.ComponentModel.Description('Whether credentials are allowed.')] + [System.Boolean] $AllowCredentials +} + +class Segment +{ + [DscProperty(Key)] + [System.ComponentModel.Description('The segment name.')] + [System.String] $Name + + [DscProperty()] + [System.ComponentModel.Description('The CORS settings of the segment.')] + [Cors] $Cors + + [DscProperty()] + [System.ComponentModel.Description('The nested segments.')] + [Segment[]] $Children +} + +class PlainSettings +{ + [System.String] $Region + + [System.Int32] $Retries + + hidden [System.String] $Internal + + static [System.String] $Shared +} + +[DscResource()] +class TypedResource +{ + [DscProperty(Key)] + [System.ComponentModel.Description('The unique name of the resource.')] + [System.String] $Name + + [DscProperty()] + [System.ComponentModel.Description('Whether the resource is enabled.')] + [System.Boolean] $Enabled + + [DscProperty()] + [System.ComponentModel.Description('The number of items.')] + [System.Int32] $Count + + [DscProperty()] + [System.ComponentModel.Description('The port to listen on.')] + [System.UInt16] $Port + + [DscProperty()] + [System.ComponentModel.Description('An optional flag.')] + [System.Nullable[System.Boolean]] $Optional + + [DscProperty()] + [System.ComponentModel.Description('An optional limit.')] + [Nullable[System.Int32]] $Limit + + [DscProperty()] + [System.ComponentModel.Description('The allowed protocols.')] + [ValidateSet('Http', 'Https')] + [System.String[]] $Protocols + + [DscProperty()] + [System.ComponentModel.Description('The key size in bits.')] + [ValidateSet('1024', '2048')] + [System.Nullable[System.UInt16]] $KeySize + + [DscProperty()] + [System.ComponentModel.Description('The day of the week.')] + [System.DayOfWeek] $Day + + [DscProperty()] + [System.ComponentModel.Description('The credential used to connect.')] + [System.Management.Automation.PSCredential] $Credential + + [DscProperty()] + [System.ComponentModel.Description('Arbitrary settings.')] + [System.Collections.Hashtable] $Settings + + [DscProperty()] + [System.ComponentModel.Description('When the resource expires.')] + [System.DateTime] $Expires + + [DscProperty()] + [System.ComponentModel.Description('The primary segment.')] + [Segment] $Primary + + [DscProperty()] + [System.ComponentModel.Description('All segments.')] + [Segment[]] $Segments + + [DscProperty()] + [System.ComponentModel.Description('Settings described by a class without DSC attributes.')] + [PlainSettings] $Plain + + [DscProperty(NotConfigurable)] + [System.ComponentModel.Description('The last known state.')] + [System.String] $State + + [TypedResource] Get() + { + return $this + } + + [System.Boolean] Test() + { + return $true + } + + [void] Set() + { + } + + [System.String] Export() + { + return '' + } +} diff --git a/tests/Unit/Private/Add-AstProperty.Tests.ps1 b/tests/Unit/Private/Add-AstProperty.Tests.ps1 index e9b868b..e942726 100644 --- a/tests/Unit/Private/Add-AstProperty.Tests.ps1 +++ b/tests/Unit/Private/Add-AstProperty.Tests.ps1 @@ -240,4 +240,93 @@ Describe 'Add-AstProperty' { } } } + + Context 'Class with fully qualified types, complex types and Description attributes' { + + BeforeAll { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $script:typedAllTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + $typeAst = $script:typedAllTypes | Where-Object { $_.Name -eq 'TypedResource' } + + $properties = [System.Collections.Generic.List[hashtable]]::new() + Add-AstProperty -AllTypeDefinitions $script:typedAllTypes -TypeAst $typeAst -Properties $properties + $script:typedProperties = $properties + } + } + + It 'Keeps the declared type name' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Enabled' }).TypeName | Should -BeExactly 'System.Boolean' + ($script:typedProperties | Where-Object { $_.Name -eq 'Optional' }).TypeName | Should -BeExactly 'System.Nullable[System.Boolean]' + } + } + + It 'Captures the Description attribute' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Name' }).Description | Should -BeExactly 'The unique name of the resource.' + } + } + + It 'Marks array properties' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Protocols' }).IsArray | Should -BeTrue + ($script:typedProperties | Where-Object { $_.Name -eq 'Name' }).IsArray | Should -BeFalse + } + } + + It 'Resolves ValidateSet values on an array property' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Protocols' }).EnumValues | Should -Be @('Http', 'Https') + } + } + + It 'Resolves a .NET enum through reflection' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Day' }).EnumValues | Should -Contain 'Monday' + } + } + + It 'Resolves a class from the same file as ComplexTypeName' { + InModuleScope 'DscResource.Authoring' { + $primary = $script:typedProperties | Where-Object { $_.Name -eq 'Primary' } + $primary.ComplexTypeName | Should -BeExactly 'Segment' + $primary.IsArray | Should -BeFalse + + $segments = $script:typedProperties | Where-Object { $_.Name -eq 'Segments' } + $segments.ComplexTypeName | Should -BeExactly 'Segment' + $segments.IsArray | Should -BeTrue + } + } + + It 'Leaves ComplexTypeName empty for scalar and credential types' { + InModuleScope 'DscResource.Authoring' { + ($script:typedProperties | Where-Object { $_.Name -eq 'Name' }).ComplexTypeName | Should -BeNullOrEmpty + ($script:typedProperties | Where-Object { $_.Name -eq 'Credential' }).ComplexTypeName | Should -BeNullOrEmpty + } + } + + It 'Collects nothing from a class without DscProperty members by default' { + InModuleScope 'DscResource.Authoring' { + $typeAst = $script:typedAllTypes | Where-Object { $_.Name -eq 'PlainSettings' } + $properties = [System.Collections.Generic.List[hashtable]]::new() + Add-AstProperty -AllTypeDefinitions $script:typedAllTypes -TypeAst $typeAst -Properties $properties + $properties.Count | Should -Be 0 + } + } + + It 'Collects the public instance properties of a plain class with -AllProperties' { + InModuleScope 'DscResource.Authoring' { + $typeAst = $script:typedAllTypes | Where-Object { $_.Name -eq 'PlainSettings' } + $properties = [System.Collections.Generic.List[hashtable]]::new() + Add-AstProperty -AllTypeDefinitions $script:typedAllTypes -TypeAst $typeAst -Properties $properties -AllProperties + $names = @($properties | ForEach-Object { $_.Name }) + $names | Should -Be @('Region', 'Retries') + } + } + } } diff --git a/tests/Unit/Private/Add-JsonSchemaDefinition.Tests.ps1 b/tests/Unit/Private/Add-JsonSchemaDefinition.Tests.ps1 new file mode 100644 index 0000000..3cf81ac --- /dev/null +++ b/tests/Unit/Private/Add-JsonSchemaDefinition.Tests.ps1 @@ -0,0 +1,101 @@ +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'Add-JsonSchemaDefinition' { + + BeforeAll { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $script:definitionAllTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + } + } + + Context 'Class with DscProperty members' { + + BeforeAll { + InModuleScope 'DscResource.Authoring' { + $script:definitions = [ordered]@{} + Add-JsonSchemaDefinition -Name 'Segment' -AllTypeDefinitions $script:definitionAllTypes -Definitions $script:definitions + } + } + + It 'Adds an object definition with the DscProperty members' { + InModuleScope 'DscResource.Authoring' { + $segment = $script:definitions['Segment'] + $segment['type'] | Should -BeExactly 'object' + $segment['additionalProperties'] | Should -BeFalse + @($segment['properties'].Keys) | Should -Be @('Name', 'Cors', 'Children') + $segment['properties']['Name']['type'] | Should -BeExactly 'string' + $segment['properties']['Name']['description'] | Should -BeExactly 'The segment name.' + } + } + + It 'Adds nested definitions recursively' { + InModuleScope 'DscResource.Authoring' { + $script:definitions['Segment']['properties']['Cors']['$ref'] | Should -BeExactly '#/$defs/Cors' + $script:definitions['Cors']['properties']['AllowCredentials']['type'] | Should -BeExactly 'boolean' + $script:definitions['Cors']['properties']['AllowedOrigins']['type'] | Should -BeExactly 'array' + } + } + + It 'Guards against self-referencing classes' { + InModuleScope 'DscResource.Authoring' { + $script:definitions['Segment']['properties']['Children']['type'] | Should -BeExactly 'array' + $script:definitions['Segment']['properties']['Children']['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + $script:definitions.Count | Should -Be 2 + } + } + + It 'Omits the required list' { + InModuleScope 'DscResource.Authoring' { + $script:definitions['Segment'].Contains('required') | Should -BeFalse + } + } + + It 'Does not add a definition twice' { + InModuleScope 'DscResource.Authoring' { + $before = $script:definitions['Segment'] + Add-JsonSchemaDefinition -Name 'Segment' -AllTypeDefinitions $script:definitionAllTypes -Definitions $script:definitions + + $script:definitions.Count | Should -Be 2 + [object]::ReferenceEquals($before, $script:definitions['Segment']) | Should -BeTrue + } + } + } + + Context 'Class without DscProperty members' { + + It 'Uses the public instance properties' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + Add-JsonSchemaDefinition -Name 'PlainSettings' -AllTypeDefinitions $script:definitionAllTypes -Definitions $definitions + + @($definitions['PlainSettings']['properties'].Keys) | Should -Be @('Region', 'Retries') + $definitions['PlainSettings']['properties']['Retries']['type'] | Should -BeExactly 'integer' + } + } + } + + Context 'Unknown class name' { + + It 'Adds nothing' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + Add-JsonSchemaDefinition -Name 'DoesNotExist' -AllTypeDefinitions $script:definitionAllTypes -Definitions $definitions + + $definitions.Count | Should -Be 0 + } + } + } +} diff --git a/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 b/tests/Unit/Private/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Unit/Private/ConvertTo-JsonSchemaProperty.Tests.ps1 b/tests/Unit/Private/ConvertTo-JsonSchemaProperty.Tests.ps1 new file mode 100644 index 0000000..52acc9b --- /dev/null +++ b/tests/Unit/Private/ConvertTo-JsonSchemaProperty.Tests.ps1 @@ -0,0 +1,241 @@ +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-JsonSchemaProperty' { + + Context 'Scalar property' { + + It 'Maps the type and adds the title and a default description' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Count'; TypeName = 'System.Int32' } -Definitions $definitions + + $result['type'] | Should -BeExactly 'integer' + $result['title'] | Should -BeExactly 'Count' + $result['description'] | Should -BeExactly 'The Count property.' + $definitions.Count | Should -Be 0 + } + } + + It 'Uses the Description attribute text' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Count'; TypeName = 'int'; Description = 'From the attribute.' } -Definitions $definitions + + $result['description'] | Should -BeExactly 'From the attribute.' + } + } + + It 'Prefers the comment-based help description' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $classHelp = @{ Parameters = @{ Count = 'From help.' } } + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Count'; TypeName = 'int'; Description = 'From the attribute.' } ` + -Definitions $definitions -ClassHelp $classHelp + + $result['description'] | Should -BeExactly 'From help.' + } + } + + It 'Marks a NotConfigurable property as readOnly' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'State'; TypeName = 'string'; IsNotConfigurable = $true } -Definitions $definitions + + $result['readOnly'] | Should -BeTrue + } + } + } + + Context 'Enum values' { + + It 'Emits a string enum for a scalar property' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Ensure'; TypeName = 'string'; EnumValues = @('Present', 'Absent') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'string' + $result['enum'] | Should -Be @('Present', 'Absent') + } + } + + It 'Emits the enum under items for an array property' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Protocols'; TypeName = 'string[]'; IsArray = $true; EnumValues = @('Http', 'Https') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'array' + $result.Contains('enum') | Should -BeFalse + $result['items']['type'] | Should -BeExactly 'string' + $result['items']['enum'] | Should -Be @('Http', 'Https') + } + } + + It 'Converts a ValidateSet on a numeric property to an integer enum' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Level'; TypeName = 'System.Nullable[System.Int32]'; EnumValues = @('0', '1', '2') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'integer' + $result['enum'] | Should -Be @(0, 1, 2) + $result['enum'][0] | Should -BeOfType [System.Int64] + } + } + + It 'Converts a ValidateSet on a numeric array property under items' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Masks'; TypeName = 'System.Int32[]'; IsArray = $true; EnumValues = @('1', '2', '4') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'array' + $result['items']['type'] | Should -BeExactly 'integer' + $result['items']['enum'] | Should -Be @(1, 2, 4) + } + } + + It 'Converts a ValidateSet on a boolean property to a boolean enum' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Flag'; TypeName = 'System.Boolean'; EnumValues = @('True') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'boolean' + $result['enum'] | Should -Be @($true) + } + } + + It 'Keeps the numeric type and drops the enum when a ValidateSet value is not numeric' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Level'; TypeName = 'int'; EnumValues = @('0', 'High') } -Definitions $definitions + + $result['type'] | Should -BeExactly 'integer' + $result.Contains('enum') | Should -BeFalse + } + } + + It 'Does not emit a pattern when enum values are present' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Ensure'; TypeName = 'string'; EnumValues = @('Present', 'Absent'); PatternValue = '^P' } -Definitions $definitions + + $result.Contains('pattern') | Should -BeFalse + } + } + } + + Context 'Credential property' { + + It 'References and registers the PSCredential definition' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Credential'; TypeName = 'System.Management.Automation.PSCredential' } -Definitions $definitions + + $result['$ref'] | Should -BeExactly '#/$defs/PSCredential' + $result.Contains('type') | Should -BeFalse + $definitions['PSCredential']['type'] | Should -BeExactly 'object' + $definitions['PSCredential']['properties']['username']['type'] | Should -BeExactly 'string' + $definitions['PSCredential']['properties']['password']['type'] | Should -BeExactly 'string' + } + } + + It 'Registers the definition once for an array of credentials' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $null = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Credential'; TypeName = 'PSCredential' } -Definitions $definitions + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Credentials'; TypeName = 'PSCredential[]' } -Definitions $definitions + + $result['type'] | Should -BeExactly 'array' + $result['items']['$ref'] | Should -BeExactly '#/$defs/PSCredential' + $definitions.Count | Should -Be 1 + } + } + } + + Context 'Complex property' { + + BeforeAll { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $script:propertyAllTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + } + } + + It 'References the class definition and adds it' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Primary'; TypeName = 'Segment'; ComplexTypeName = 'Segment' } ` + -AllTypeDefinitions $script:propertyAllTypes -Definitions $definitions + + $result['$ref'] | Should -BeExactly '#/$defs/Segment' + $result.Contains('type') | Should -BeFalse + $definitions['Segment']['type'] | Should -BeExactly 'object' + $definitions['Cors']['type'] | Should -BeExactly 'object' + } + } + + It 'Wraps an array of a class under items' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Segments'; TypeName = 'Segment[]'; IsArray = $true; ComplexTypeName = 'Segment' } ` + -AllTypeDefinitions $script:propertyAllTypes -Definitions $definitions + + $result['type'] | Should -BeExactly 'array' + $result['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + } + } + + It 'Falls back to the type map when no type definitions are supplied' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Primary'; TypeName = 'Segment'; ComplexTypeName = 'Segment' } -Definitions $definitions + + $result['type'] | Should -BeExactly 'string' + $definitions.Count | Should -Be 0 + } + } + } + + Context 'ValidatePattern' { + + It 'Emits an ECMA-compatible pattern' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Id'; TypeName = 'string'; PatternValue = '^[a-z]+$' } -Definitions $definitions + + $result['pattern'] | Should -BeExactly '^[a-z]+$' + } + } + + It 'Warns and skips a .NET-specific pattern' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Id'; TypeName = 'string'; PatternValue = '\Afoo\Z' } ` + -Definitions $definitions -WarningVariable warnings -WarningAction SilentlyContinue + + $result.Contains('pattern') | Should -BeFalse + $warnings | Should -Not -BeNullOrEmpty + } + } + + It 'Emits a .NET-specific pattern with -AllowNonEcmaPattern' { + InModuleScope 'DscResource.Authoring' { + $definitions = [ordered]@{} + $result = ConvertTo-JsonSchemaProperty -Property @{ Name = 'Id'; TypeName = 'string'; PatternValue = '\Afoo\Z' } ` + -Definitions $definitions -AllowNonEcmaPattern + + $result['pattern'] | Should -BeExactly '\Afoo\Z' + } + } + } +} diff --git a/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 b/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 index bd5f876..67122db 100644 --- a/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 +++ b/tests/Unit/Private/ConvertTo-JsonSchemaType.Tests.ps1 @@ -138,4 +138,121 @@ Describe 'ConvertTo-JsonSchemaType' { } } } + + Context 'Fully qualified type names' { + + It 'Maps to ' -ForEach @( + @{ TypeName = 'System.String'; Expected = 'string' } + @{ TypeName = 'System.Boolean'; Expected = 'boolean' } + @{ TypeName = 'System.Int16'; Expected = 'integer' } + @{ TypeName = 'System.Int32'; Expected = 'integer' } + @{ TypeName = 'System.Int64'; Expected = 'integer' } + @{ TypeName = 'System.UInt16'; Expected = 'integer' } + @{ TypeName = 'System.UInt32'; Expected = 'integer' } + @{ TypeName = 'System.UInt64'; Expected = 'integer' } + @{ TypeName = 'System.Byte'; Expected = 'integer' } + @{ TypeName = 'System.Single'; Expected = 'number' } + @{ TypeName = 'System.Double'; Expected = 'number' } + @{ TypeName = 'System.Decimal'; Expected = 'number' } + @{ TypeName = 'System.Collections.Hashtable'; Expected = 'object' } + @{ TypeName = 'System.Collections.Specialized.OrderedDictionary'; Expected = 'object' } + @{ TypeName = 'System.Security.SecureString'; Expected = 'string' } + @{ TypeName = 'System.Guid'; Expected = 'string' } + @{ TypeName = 'System.TimeSpan'; Expected = 'string' } + @{ TypeName = 'System.Char'; Expected = 'string' } + ) { + InModuleScope 'DscResource.Authoring' -Parameters @{ TypeName = $TypeName; Expected = $Expected } { + $result = ConvertTo-JsonSchemaType -TypeName $TypeName + $result['type'] | Should -BeExactly $Expected + } + } + + It 'Maps System.DateTime to { type = string; format = date-time }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.DateTime' + $result['type'] | Should -BeExactly 'string' + $result['format'] | Should -BeExactly 'date-time' + } + } + + It 'Maps System.Management.Automation.PSCredential to a reference to the PSCredential definition' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.Management.Automation.PSCredential' + $result['$ref'] | Should -BeExactly '#/$defs/PSCredential' + $result.Contains('type') | Should -BeFalse + } + } + + It 'Maps PSCredential to the same reference' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'PSCredential' + $result['$ref'] | Should -BeExactly '#/$defs/PSCredential' + } + } + } + + Context 'Nullable type names' { + + It 'Maps System.Nullable[System.Boolean] to { type = boolean }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.Nullable[System.Boolean]' + $result['type'] | Should -BeExactly 'boolean' + } + } + + It 'Maps Nullable[int] to { type = integer }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'Nullable[int]' + $result['type'] | Should -BeExactly 'integer' + } + } + } + + Context 'Qualified arrays and generic collections' { + + It 'Maps System.String[] to { type = array; items = { type = string } }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.String[]' + $result['type'] | Should -BeExactly 'array' + $result['items']['type'] | Should -BeExactly 'string' + } + } + + It 'Maps System.Collections.Generic.List[string] to an array of strings' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.Collections.Generic.List[string]' + $result['type'] | Should -BeExactly 'array' + $result['items']['type'] | Should -BeExactly 'string' + } + } + + It 'Maps System.Collections.Generic.Dictionary[string, int] to { type = object }' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'System.Collections.Generic.Dictionary[string, int]' + $result['type'] | Should -BeExactly 'object' + } + } + + It 'Maps PSCredential[] to an array of credential references' { + InModuleScope 'DscResource.Authoring' { + $result = ConvertTo-JsonSchemaType -TypeName 'PSCredential[]' + $result['type'] | Should -BeExactly 'array' + $result['items']['$ref'] | Should -BeExactly '#/$defs/PSCredential' + } + } + } + + Context 'Untyped values' { + + It 'Maps to a schema without a type keyword' -ForEach @( + @{ TypeName = 'object' } + @{ TypeName = 'System.Object' } + @{ TypeName = 'PSObject' } + ) { + InModuleScope 'DscResource.Authoring' -Parameters @{ TypeName = $TypeName } { + $result = ConvertTo-JsonSchemaType -TypeName $TypeName + $result.Contains('type') | Should -BeFalse + } + } + } } diff --git a/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 b/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 index c1cbecc..bc2e542 100644 --- a/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 +++ b/tests/Unit/Private/Get-DscResourceCapability.Tests.ps1 @@ -60,4 +60,44 @@ Describe 'Get-DscResourceCapability' { } } } + + Context 'Class with a static Export method' { + + It 'Returns the export capability' { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'MultiResource') 'MultiResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $allTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + $typeAst = $allTypes | Where-Object { $_.Name -eq 'ResourceA' } + $capabilities = Get-DscResourceCapability -MemberAst $typeAst.Members + + $capabilities | Should -Contain 'export' + $capabilities | Should -Contain 'delete' + } + } + } + + Context 'Class with an instance Export method' { + + It 'Does not return the export capability because dsc invokes Export on the type' { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $allTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + $typeAst = $allTypes | Where-Object { $_.Name -eq 'TypedResource' } + $capabilities = Get-DscResourceCapability -MemberAst $typeAst.Members + + $capabilities | Should -Contain 'get' + $capabilities | Should -Contain 'set' + $capabilities | Should -Contain 'test' + $capabilities | Should -Not -Contain 'export' + } + } + } } diff --git a/tests/Unit/Private/New-EmbeddedJsonSchema.Tests.ps1 b/tests/Unit/Private/New-EmbeddedJsonSchema.Tests.ps1 index a0149ce..ae01e27 100644 --- a/tests/Unit/Private/New-EmbeddedJsonSchema.Tests.ps1 +++ b/tests/Unit/Private/New-EmbeddedJsonSchema.Tests.ps1 @@ -298,4 +298,154 @@ Describe 'New-EmbeddedJsonSchema' { } } } + + Context 'Description attribute precedence' { + + It 'Uses the Description attribute when there is no comment-based help entry' { + InModuleScope 'DscResource.Authoring' { + $properties = [System.Collections.Generic.List[hashtable]]::new() + $properties.Add(@{ + Name = 'Name' + TypeName = 'String' + IsMandatory = $true + EnumValues = $null + Description = 'From the attribute.' + }) + $result = New-EmbeddedJsonSchema -ResourceName 'TestModule/TestResource' -Properties $properties + $result['properties']['Name']['description'] | Should -BeExactly 'From the attribute.' + } + } + + It 'Prefers the comment-based help entry over the Description attribute' { + InModuleScope 'DscResource.Authoring' { + $properties = [System.Collections.Generic.List[hashtable]]::new() + $properties.Add(@{ + Name = 'Name' + TypeName = 'String' + IsMandatory = $true + EnumValues = $null + Description = 'From the attribute.' + }) + $classHelp = @{ + Synopsis = 'A resource.' + Description = 'Does stuff.' + Parameters = @{ Name = 'From help.' } + } + $result = New-EmbeddedJsonSchema -ResourceName 'TestModule/TestResource' -Properties $properties -ClassHelp $classHelp + $result['properties']['Name']['description'] | Should -BeExactly 'From help.' + } + } + } + + Context 'Array properties with enum values' { + + It 'Emits the enum under items for an array property' { + InModuleScope 'DscResource.Authoring' { + $properties = [System.Collections.Generic.List[hashtable]]::new() + $properties.Add(@{ + Name = 'Protocols' + TypeName = 'System.String[]' + IsArray = $true + IsMandatory = $false + EnumValues = @('Http', 'Https') + }) + $result = New-EmbeddedJsonSchema -ResourceName 'TestModule/TestResource' -Properties $properties + $protocols = $result['properties']['Protocols'] + $protocols['type'] | Should -BeExactly 'array' + $protocols.Contains('enum') | Should -BeFalse + $protocols['items']['type'] | Should -BeExactly 'string' + $protocols['items']['enum'] | Should -Be @('Http', 'Https') + } + } + + It 'Does not emit $defs when no property needs a definition' { + InModuleScope 'DscResource.Authoring' { + $properties = [System.Collections.Generic.List[hashtable]]::new() + $properties.Add(@{ + Name = 'Name' + TypeName = 'String' + IsMandatory = $true + EnumValues = $null + }) + $result = New-EmbeddedJsonSchema -ResourceName 'TestModule/TestResource' -Properties $properties + $result.Contains('$defs') | Should -BeFalse + } + } + } + + Context 'Complex and credential types' { + + BeforeAll { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $path = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + [System.Management.Automation.Language.Token[]] $tokens = $null + [System.Management.Automation.Language.ParseError[]] $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) + $allTypes = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.TypeDefinitionAst] }, $false) + $typeAst = $allTypes | Where-Object { $_.Name -eq 'TypedResource' } + $properties = Get-DscResourceProperty -AllTypeDefinitions $allTypes -TypeDefinitionAst $typeAst + + $script:typedSchema = New-EmbeddedJsonSchema -ResourceName 'TypedResource/TypedResource' ` + -Properties $properties -AllTypeDefinitions $allTypes + } + } + + It 'Adds a $defs section' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema.Contains('$defs') | Should -BeTrue + } + } + + It 'References complex properties by $ref' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema['properties']['Primary']['$ref'] | Should -BeExactly '#/$defs/Segment' + $script:typedSchema['$defs']['Segment']['properties']['Name']['type'] | Should -BeExactly 'string' + } + } + + It 'Wraps arrays of complex types under items' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema['properties']['Segments']['type'] | Should -BeExactly 'array' + $script:typedSchema['properties']['Segments']['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + } + } + + It 'Adds nested definitions once' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema['$defs']['Segment']['properties']['Cors']['$ref'] | Should -BeExactly '#/$defs/Cors' + $script:typedSchema['$defs']['Segment']['properties']['Children']['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + $script:typedSchema['$defs']['Cors']['properties']['AllowedOrigins']['type'] | Should -BeExactly 'array' + } + } + + It 'Adds a PSCredential definition' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema['properties']['Credential']['$ref'] | Should -BeExactly '#/$defs/PSCredential' + $script:typedSchema['$defs']['PSCredential']['properties'].Keys | Should -Contain 'username' + $script:typedSchema['$defs']['PSCredential']['properties'].Keys | Should -Contain 'password' + } + } + + It 'Does not emit required inside definitions' { + InModuleScope 'DscResource.Authoring' { + foreach ($name in $script:typedSchema['$defs'].Keys) + { + $script:typedSchema['$defs'][$name].Contains('required') | Should -BeFalse + } + } + } + + It 'Keeps the top-level required list' { + InModuleScope 'DscResource.Authoring' { + $script:typedSchema['required'] | Should -Be @('Name') + } + } + + It 'Sorts the definitions by name' { + InModuleScope 'DscResource.Authoring' { + @($script:typedSchema['$defs'].Keys) | Should -Be @($script:typedSchema['$defs'].Keys | Sort-Object) + } + } + } } diff --git a/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 b/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 index 27a3a9a..c92a4fe 100644 --- a/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 +++ b/tests/Unit/Private/Resolve-ModuleInfo.Tests.ps1 @@ -103,4 +103,25 @@ Describe 'Resolve-ModuleInfo' { } } } + + Context 'With a .psd1 that has a four-part ModuleVersion' { + + It 'Returns Major.Minor.Build' { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $psd1 = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psd1' + $result = Resolve-ModuleInfo -Path $psd1 + $result.Version | Should -BeExactly '1.2.3' + } + } + + It 'Keeps a three-part ModuleVersion unchanged' { + InModuleScope 'DscResource.Authoring' { + $fixturesPath = Join-Path (Join-Path $PSScriptRoot '..') 'Fixtures' + $psd1 = Join-Path (Join-Path $fixturesPath 'SimpleResource') 'SimpleResource.psd1' + $result = Resolve-ModuleInfo -Path $psd1 + $result.Version | Should -BeExactly '1.0.0' + } + } + } } diff --git a/tests/Unit/Public/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 b/tests/Unit/Public/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 new file mode 100644 index 0000000..f9e4a8d --- /dev/null +++ b/tests/Unit/Public/ConvertTo-DscPropertyOverrideFromConfig.Tests.ps1 @@ -0,0 +1,93 @@ +BeforeAll { + $script:dscModuleName = 'DscResource.Authoring' + + Import-Module -Name $script:dscModuleName -Force +} + +AfterAll { + Get-Module -Name $script:dscModuleName -All | Remove-Module -Force +} + +Describe 'ConvertTo-DscPropertyOverrideFromConfig' { + + Context 'With complete entries' { + + BeforeAll { + $config = @( + @{ + Name = 'Count' + Description = 'The number of items.' + Title = 'Item count' + JsonSchema = @{ minimum = 0; maximum = 100 } + RemoveKeys = @('type') + Required = $true + } + @{ + Name = 'Name' + } + ) + + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig $config + } + + It 'Returns one override per entry' { + $result | Should -HaveCount 2 + } + + It 'Returns DscPropertyOverride objects' { + $result[0].GetType().Name | Should -BeExactly 'DscPropertyOverride' + $result[1].GetType().Name | Should -BeExactly 'DscPropertyOverride' + } + + It 'Maps every supported key' { + $result[0].Name | Should -BeExactly 'Count' + $result[0].Description | Should -BeExactly 'The number of items.' + $result[0].Title | Should -BeExactly 'Item count' + $result[0].JsonSchema['minimum'] | Should -Be 0 + $result[0].JsonSchema['maximum'] | Should -Be 100 + $result[0].RemoveKeys | Should -Be @('type') + $result[0].Required | Should -BeTrue + } + + It 'Leaves optional fields empty when the entry omits them' { + $result[1].Name | Should -BeExactly 'Name' + $result[1].Description | Should -BeNullOrEmpty + $result[1].Title | Should -BeNullOrEmpty + $result[1].RemoveKeys | Should -BeNullOrEmpty + $result[1].Required | Should -BeNullOrEmpty + } + } + + Context 'With an entry that has no Name' { + + It 'Skips the entry and writes a warning' { + $config = @( + @{ Description = 'No name here.' } + @{ Name = 'Kept' } + ) + + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig $config -WarningVariable warnings -WarningAction SilentlyContinue + + $result | Should -HaveCount 1 + $result[0].Name | Should -BeExactly 'Kept' + $warnings | Should -Not -BeNullOrEmpty + $warnings[0] | Should -BeLike '*missing or empty Name*' + } + } + + Context 'Return shape' { + + It 'Returns an array for a single entry' { + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig @(@{ Name = 'Only' }) + + $result -is [System.Array] | Should -BeTrue + @($result).Count | Should -Be 1 + } + + It 'Accepts ordered dictionaries as JsonSchema' { + $result = ConvertTo-DscPropertyOverrideFromConfig -OverrideConfig @(@{ Name = 'Count'; JsonSchema = [ordered]@{ minimum = 1 } }) + + $result[0].JsonSchema['minimum'] | Should -Be 1 + } + } +} diff --git a/tests/Unit/Public/New-DscAdaptedResourceManifest.Tests.ps1 b/tests/Unit/Public/New-DscAdaptedResourceManifest.Tests.ps1 index 85addcb..52b4a24 100644 --- a/tests/Unit/Public/New-DscAdaptedResourceManifest.Tests.ps1 +++ b/tests/Unit/Public/New-DscAdaptedResourceManifest.Tests.ps1 @@ -45,7 +45,7 @@ Describe 'New-DscAdaptedResourceManifest' { } It 'Sets the schema URI' { - $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + $result.Schema | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/resource/adapted/manifest.json' } It 'Sets the require adapter to Microsoft.DSC/PowerShell' { @@ -252,7 +252,7 @@ Describe 'New-DscAdaptedResourceManifest' { } It 'Contains the $schema key' { - $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + $parsed.'$schema' | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/resource/adapted/manifest.json' } It 'Contains the type key' { @@ -297,7 +297,7 @@ Describe 'New-DscAdaptedResourceManifest' { It 'Accepts FileInfo objects from Get-ChildItem via pipeline' { $results = Get-ChildItem -Path $fixturesPath -Filter '*.psd1' -Recurse | New-DscAdaptedResourceManifest - $results | Should -HaveCount 9 + $results | Should -HaveCount 10 } } @@ -375,4 +375,177 @@ Describe 'New-DscAdaptedResourceManifest' { { New-DscAdaptedResourceManifest -Path $psd1 -Version '2026.05.08' } | Should -Throw '*not a valid semantic version*' } } + + Context 'Module with fully qualified types, complex types and Description attributes' { + + BeforeAll { + $psd1 = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psd1' + $result = New-DscAdaptedResourceManifest -Path $psd1 -WarningVariable typedWarnings -WarningAction SilentlyContinue + $properties = $result.ManifestSchema.Embedded['properties'] + $definitions = $result.ManifestSchema.Embedded['$defs'] + } + + It 'Returns exactly one manifest object' { + $result | Should -HaveCount 1 + } + + It 'Truncates a four-part module version to Major.Minor.Build' { + $result.Version | Should -BeExactly '1.2.3' + } + + It 'Maps System.Boolean to boolean' { + $properties['Enabled']['type'] | Should -BeExactly 'boolean' + } + + It 'Maps System.Int32 and System.UInt16 to integer' { + $properties['Count']['type'] | Should -BeExactly 'integer' + $properties['Port']['type'] | Should -BeExactly 'integer' + } + + It 'Unwraps Nullable types' { + $properties['Optional']['type'] | Should -BeExactly 'boolean' + $properties['Limit']['type'] | Should -BeExactly 'integer' + } + + It 'Maps System.Collections.Hashtable to object' { + $properties['Settings']['type'] | Should -BeExactly 'object' + } + + It 'Maps System.DateTime to a date-time string' { + $properties['Expires']['type'] | Should -BeExactly 'string' + $properties['Expires']['format'] | Should -BeExactly 'date-time' + } + + It 'Maps a .NET enum to a string enum' { + $properties['Day']['type'] | Should -BeExactly 'string' + $properties['Day']['enum'] | Should -Contain 'Monday' + } + + It 'Converts a ValidateSet on a numeric property to a typed enum' { + $properties['KeySize']['type'] | Should -BeExactly 'integer' + $properties['KeySize']['enum'] | Should -Be @(1024, 2048) + } + + It 'Puts a ValidateSet on an array property under items' { + $properties['Protocols']['type'] | Should -BeExactly 'array' + $properties['Protocols'].Contains('enum') | Should -BeFalse + $properties['Protocols']['items']['type'] | Should -BeExactly 'string' + $properties['Protocols']['items']['enum'] | Should -Be @('Http', 'Https') + } + + It 'References a shared definition for a complex property' { + $properties['Primary']['$ref'] | Should -BeExactly '#/$defs/Segment' + $properties['Primary'].Contains('type') | Should -BeFalse + } + + It 'References the definition under items for an array of a complex type' { + $properties['Segments']['type'] | Should -BeExactly 'array' + $properties['Segments']['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + } + + It 'Emits nested and self-referencing definitions once' { + $definitions.Keys | Should -Contain 'Segment' + $definitions.Keys | Should -Contain 'Cors' + $definitions['Segment']['type'] | Should -BeExactly 'object' + $definitions['Segment']['additionalProperties'] | Should -BeFalse + $definitions['Segment']['properties']['Cors']['$ref'] | Should -BeExactly '#/$defs/Cors' + $definitions['Segment']['properties']['Children']['items']['$ref'] | Should -BeExactly '#/$defs/Segment' + $definitions['Cors']['properties']['AllowCredentials']['type'] | Should -BeExactly 'boolean' + } + + It 'Describes a class without DscProperty members by its public instance properties' { + $plain = $definitions['PlainSettings']['properties'] + $plain.Keys | Should -Contain 'Region' + $plain.Keys | Should -Contain 'Retries' + $plain.Keys | Should -Not -Contain 'Internal' + $plain.Keys | Should -Not -Contain 'Shared' + $plain['Retries']['type'] | Should -BeExactly 'integer' + } + + It 'Emits definitions without a required list' { + $definitions['Segment'].Contains('required') | Should -BeFalse + } + + It 'Emits PSCredential as a shared definition' { + $properties['Credential']['$ref'] | Should -BeExactly '#/$defs/PSCredential' + $definitions['PSCredential']['type'] | Should -BeExactly 'object' + $definitions['PSCredential']['properties']['username']['type'] | Should -BeExactly 'string' + $definitions['PSCredential']['properties']['password']['type'] | Should -BeExactly 'string' + } + + It 'Sorts the definitions by name' { + @($definitions.Keys) | Should -Be @($definitions.Keys | Sort-Object) + } + + It 'Uses Description attributes for property descriptions' { + $properties['Name']['description'] | Should -BeExactly 'The unique name of the resource.' + } + + It 'Uses Description attributes inside definitions' { + $definitions['Segment']['properties']['Name']['description'] | Should -BeExactly 'The segment name.' + } + + It 'Does not warn about missing comment-based help when Description attributes are present' { + @($typedWarnings | Where-Object { $_ -like 'No comment-based help*' }) | Should -BeNullOrEmpty + } + + It 'Does not advertise export for an instance Export() method' { + $result.Capabilities | Should -Contain 'get' + $result.Capabilities | Should -Contain 'set' + $result.Capabilities | Should -Contain 'test' + $result.Capabilities | Should -Not -Contain 'export' + } + + It 'Marks a NotConfigurable property as readOnly' { + $properties['State']['readOnly'] | Should -BeTrue + } + + It 'Serializes the definitions to JSON' { + $parsed = $result.ToJson() | ConvertFrom-Json + $parsed.schema.embedded.'$defs'.Segment.properties.Children.items.'$ref' | Should -BeExactly '#/$defs/Segment' + } + } + + Context 'ModuleManifestPath parameter' { + + BeforeAll { + $typedPsm1 = Join-Path (Join-Path $fixturesPath 'TypedResource') 'TypedResource.psm1' + $simplePsd1 = Join-Path (Join-Path $fixturesPath 'SimpleResource') 'SimpleResource.psd1' + } + + It 'Takes the module metadata from the given manifest while parsing the classes from Path' { + $result = New-DscAdaptedResourceManifest -Path $typedPsm1 -ModuleManifestPath $simplePsd1 + $result.Type | Should -BeExactly 'SimpleResource/TypedResource' + $result.Path | Should -BeExactly 'SimpleResource.psd1' + $result.Version | Should -BeExactly '1.0.0' + $result.Author | Should -BeExactly 'Microsoft' + $result.Description | Should -BeExactly 'A simple DSC resource for testing.' + $result.ManifestSchema.Embedded['title'] | Should -BeExactly 'SimpleResource/TypedResource' + } + + It 'Applies the manifest to every file piped in' { + $paths = @( + $typedPsm1 + (Join-Path $fixturesPath 'StandaloneResource.ps1') + ) + $results = @($paths | New-DscAdaptedResourceManifest -ModuleManifestPath $simplePsd1) + $results | Should -HaveCount 2 + $results.Type | Should -Contain 'SimpleResource/TypedResource' + $results.Type | Should -Contain 'SimpleResource/StandaloneResource' + $results.Path | Should -Be @('SimpleResource.psd1', 'SimpleResource.psd1') + } + + It 'Lets -Version override the manifest version' { + $result = New-DscAdaptedResourceManifest -Path $typedPsm1 -ModuleManifestPath $simplePsd1 -Version '9.9.9' + $result.Version | Should -BeExactly '9.9.9' + } + + It 'Throws when the module manifest does not exist' { + { New-DscAdaptedResourceManifest -Path $typedPsm1 -ModuleManifestPath 'C:\NonExistent\Fake.psd1' } | Should -Throw '*does not exist*' + } + + It 'Throws when the module manifest is not a .psd1 file' { + { New-DscAdaptedResourceManifest -Path $typedPsm1 -ModuleManifestPath $typedPsm1 } | Should -Throw '*must be a .psd1 file*' + } + } } diff --git a/tests/Unit/Public/New-DscResourceManifest.Tests.ps1 b/tests/Unit/Public/New-DscResourceManifest.Tests.ps1 index 46cde3a..d3f6e21 100644 --- a/tests/Unit/Public/New-DscResourceManifest.Tests.ps1 +++ b/tests/Unit/Public/New-DscResourceManifest.Tests.ps1 @@ -34,7 +34,7 @@ Describe 'New-DscResourceManifest' { } It 'Adapted resource has the correct schema URI' { - $result.AdaptedResources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/adaptedresource/manifest.json' + $result.AdaptedResources[0]['$schema'] | Should -BeExactly 'https://aka.ms/dsc/schemas/v3/bundled/resource/adapted/manifest.json' } }