Transpilers/Protocols/JSONSchema.Protocol.psx.ps1
<# .SYNOPSIS json schema protocol .DESCRIPTION Converts a json schema to PowerShell. .EXAMPLE jsonschema https://aka.ms/terminal-profiles-schema#/$defs/Profile .EXAMPLE { [JSONSchema(SchemaURI='https://aka.ms/terminal-profiles-schema#/$defs/Profile')] param() }.Transpile() #> [ValidateScript({ $commandAst = $_ if ($commandAst.CommandElements.Count -eq 1) { return $false } # If neither command element contained a URI if (-not ($commandAst.CommandElements[1].Value -match '^https{0,1}://')) { return $false } return ($commandAst.CommandElements[0].Value -in 'schema', 'jsonschema') })] [Alias('JSONSchema')] param( # The URI. [Parameter(Mandatory,ValueFromPipeline,ParameterSetName='Protocol')] [Parameter(Mandatory,ParameterSetName='ScriptBlock')] [uri] $SchemaUri, # The Command's Abstract Syntax Tree [Parameter(Mandatory,ParameterSetName='Protocol')] [Management.Automation.Language.CommandAST] $CommandAst, [Parameter(Mandatory,ParameterSetName='ScriptBlock',ValueFromPipeline)] [scriptblock] $ScriptBlock = {}, # One or more property prefixes to remove. # Properties that start with this prefix will become parameters without the prefix. [Alias('Remove Property Prefix')] [string[]] $RemovePropertyPrefix, # One or more properties to ignore. # Properties whose name or description is like this keyword will be ignored. [Alias('Ignore Property', 'IgnoreProperty','SkipProperty', 'Skip Property')] [string[]] $ExcludeProperty, # One or more properties to include. # Properties whose name or description is like this keyword will be included. [Alias('Include Property')] [string[]] $IncludeProperty, # If set, will not mark a parameter as required, even if the schema indicates it should be. [Alias('NoMandatoryParameters','No Mandatory Parameters', 'NoMandatories', 'No Mandatories')] [switch] $NoMandatory ) begin { # First we declare a few filters we will reuse. # One resolves schema definitions. # The inputObject is the schema. # The arguments are the path within the schema. filter resolveSchemaDefinition { $in = $_.'$defs' $schemaPath = $args -replace '^#' -replace '^/' -replace '^\$defs' -split '[/\.]' -ne '' foreach ($sp in $schemaPath) { $in = $in.$sp } $in } # Another converts property names into schema parameter names. filter getSchemaParameterName { $parameterName = $_ # If we had any prefixes we wished to remove, now is the time. if ($RemovePropertyPrefix) { $parameterName = $parameterName -replace "^(?>$(@($RemovePropertyPrefix) -join '|'))" if ($property.Name -like 'Experimental.*') { $null = $null } } # Any punctuation followed by a letter should be removed and replaced with an Uppercase letter. $parameterName = [regex]::Replace($parameterName, "\p{P}(\p{L})", { param($match) $match.Groups[1].Value.ToUpper() }) -replace '\p{P}' # And we should force the first letter to be uppercase. $parameterName.Substring(0,1).ToUpper() + $parameterName.Substring(1) } # If we have not cached the schema uris, create a collection for it. if (-not $script:CachedSchemaUris) { $script:CachedSchemaUris = @{} } $myCmd = $MyInvocation.MyCommand } process { # If we are being invoked as a protocol if ($PSCmdlet.ParameterSetName -eq 'Protocol') { # we will parse our input as a sentence. $mySentence = $commandAst.AsSentence($MyInvocation.MyCommand) # Walk thru all mapped parameters in the sentence $myParams = [Ordered]@{} + $PSBoundParameters foreach ($paramName in $mySentence.Parameters.Keys) { if (-not $myParams.Contains($paramName)) { # If the parameter was not directly supplied $myParams[$paramName] = $mySentence.Parameters[$paramName] # grab it from the sentence. foreach ($myParam in $myCmd.Parameters.Values) { if ($myParam.Aliases -contains $paramName) { # set any variables that share the name of an alias $ExecutionContext.SessionState.PSVariable.Set($myParam.Name, $mySentence.Parameters[$paramName]) } } # and set this variable for this value. $ExecutionContext.SessionState.PSVariable.Set($paramName, $mySentence.Parameters[$paramName]) } } } # We will try to cache the schema URI at a given scope. $script:CachedSchemaUris[$SchemaUri] = $schemaObject = if (-not $script:CachedSchemaUris[$SchemaUri]) { Invoke-RestMethod -Uri $SchemaUri } else { $script:CachedSchemaUris[$SchemaUri] } # If we do not have a schema object, error out. if (-not $schemaObject) { Write-Error "Could not get Schema from '$schemaUri'" return } # If the object does not look have a JSON schema, error out. if (-not $schemaObject.'$schema') { Write-Error "'$schemaUri' is not a JSON Schema" return } # If we do not have a URI fragment or there are no properties if (-not $SchemaUri.Fragment -and -not $schemaObject.properties) { Write-Error "No root properties defined and no definition specified" return # error out. } # Resolve the schema object we want to generate. $schemaDefinition = if (-not $schemaObject.properties -and $SchemaUri.Fragment) { $schemaObject | resolveSchemaDefinition $SchemaUri.Fragment } else { $schemaObject.properties } # Start off by carrying over the description. $newPipeScriptSplat = @{ description = $schemaDefinition.description } # Now walk thru each property in the schema $newPipeScriptParameters = [Ordered]@{} :nextProperty foreach ($property in $schemaDefinition.properties.psobject.properties) { # Filter out excluded properties if ($ExcludeProperty) { foreach ($exc in $ExcludeProperty) { if ($property.Name -like $exc) { continue nextProperty } } } # Filter included properties if ($IncludeProperty) { $included = foreach ($inc in $IncludeProperty) { if ($property.Name -like $inc) { $true;break } } if (-not $included) { continue nextProperty } } # Convert the property into a parameter name $parameterName = $property.Name | getSchemaParameterName # Collect the parameter attributes $parameterAttributes = @( # The description comes first, as inline help $propertyDescription = $property.value.description if ($propertyDescription -match '\n') { " <#" " " + $($propertyDescription -split '(?>\r\n|\n)' -join ([Environment]::NewLine + (' ' * 4)) ) " #>" } else { "# $propertyDescription" } "[Parameter($( if ($property.value.required -and -not $NoMandatory) { "Mandatory,"} )ValueFromPipelineByPropertyName)]" # Followed by the defaultBindingProperty (the name of the JSON property) "[ComponentModel.DefaultBindingProperty('$($property.Name)')]" # Keep track of if null was allowed and try to resolve the type $nullAllowed = $false $propertyTypeInfo = if ($property.value.'$ref') { # If there was a reference, try to resolve it $referencedType = $schemaObject | resolveSchemaDefinition $property.value.'$ref' if ($referencedType) { $referencedType } } else { # If there is not a oneOf, return the value if (-not $property.value.'oneOf') { $property.value } else { # If there is oneOf, see if it's an optional null $notNullOneOf = @(foreach ($oneOf in $property.value.oneOf) { if ($oneOf.type -eq 'null') { $nullAllowed = $true continue } $oneOf }) if ($notNullOneOf.Count -eq 1) { $notNullOneOf = $notNullOneOf[0] if ($notNullOneOf.'$ref') { $referencedType = $schemaObject | resolveSchemaDefinition $notNullOneOf.'$ref' if ($referencedType) { $referencedType } } else { $notNullOneOf } } else { "[PSObject]" } } } # If there was a single type with an enum if ($propertyTypeInfo.enum) { $validSet = @() + $propertyTypeInfo.enum if ($nullAllowed) { $validSet += '' } # create a validateset. "[ValidateSet('$($validSet -join "','")')]" } # If there was a validation pattern elseif ($propertyTypeInfo.pattern) { $validPattern = $propertyTypeInfo.pattern if ($nullAllowed) { $validPattern = "(?>^\s{0}$|$validPattern)" } $validPattern = $validPattern.Replace("'", "''") # declare a regex. "[ValidatePattern('$validPattern')]" } # If there was a property type if ($propertyTypeInfo.type) { # limit the input switch ($propertyTypeInfo.type) { "string" { "[string]" } "integer" { "[int]" } "number" { "[double]" } "boolean" { "[switch]" } "array" { "[PSObject[]]" } "object" { "[PSObject]" } } } ) $parameterAttributes += "`$$parameterName" $newPipeScriptParameters[$parameterName] = $parameterAttributes } $newPipeScriptSplat.Parameter = $newPipeScriptParameters # If there was no scriptblock, or it was nothing but an empty param() if ($ScriptBlock -match '^[\s\r\n]{0,}(?:param\(\))?[\s\r\n]{0,1}$') { # Create a script that will create the schema object. $newPipeScriptSplat.Process = [scriptblock]::Create(" `$schemaTypeName = '$("$schemauri".Replace("'","''"))' " + { # Get my script block $myScriptBlock = $MyInvocation.MyCommand.ScriptBlock # and declare an output object. $myParamCopy = [Ordered]@{PSTypeName=$schemaTypeName} # Walk over each parameter in my own AST. foreach ($param in $myScriptBlock.Ast.ParamBlock.Parameters) { # if there are not attributes, this parameter will not be mapped. if (-not $param.Attributes) { continue } # If the parameter was not passed, this parameter will not be mapped. $paramVariableName = $param.Name.VariablePath.ToString() if (-not $PSBoundParameters.ContainsKey($paramVariableName)) { continue } # Not walk over each attribute foreach ($attr in $param.Attributes) { # If the attribute is not a defaultbindingproperty attribute, if ($attr.TypeName.GetReflectionType() -ne [ComponentModel.DefaultBindingPropertyAttribute]) { continue # keep moving. } # Otherwise, update our object with the value $propName = $($attr.PositionalArguments.value) $myParamCopy[$propName] = $PSBoundParameters[$paramVariableName] # (don't forget to turn switches into booleans) if ($myParamCopy[$propName] -is [switch]) { $myParamCopy[$propName] = $myParamCopy[$propName] -as [bool] } } } # By converting our modified copy of parameters into an object # we should have an object that matches the schema. [PSCustomObject]$myParamCopy }) } # If we are transpiling a script block if ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') { # join the existing script with the schema information Join-PipeScript -ScriptBlock $ScriptBlock, ( New-PipeScript @newPipeScriptSplat ) } elseif ($PSCmdlet.ParameterSetName -eq 'Protocol') { # Otherwise, create a script block that produces the schema. [ScriptBlock]::create("{ $(New-PipeScript @newPipeScriptSplat) }") } } |