Skip to content

Commit 45fefaf

Browse files
author
James Brundage
committed
Adding JSONSchema transpiler (Fixes #274)
1 parent c126f6a commit 45fefaf

1 file changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
<#
2+
.SYNOPSIS
3+
json schema protocol
4+
.DESCRIPTION
5+
Converts a json schema to PowerShell.
6+
.EXAMPLE
7+
jsonschema https://aka.ms/terminal-profiles-schema#/$defs/Profile
8+
#>
9+
[ValidateScript({
10+
$commandAst = $_
11+
12+
if ($commandAst.CommandElements.Count -eq 1) { return $false }
13+
# If neither command element contained a URI
14+
if (-not ($commandAst.CommandElements[1].Value -match '^https{0,1}://')) {
15+
return $false
16+
}
17+
18+
return ($commandAst.CommandElements[0].Value -in 'schema', 'jsonschema')
19+
20+
})]
21+
[Alias('JSONSchema')]
22+
param(
23+
# The URI.
24+
[Parameter(Mandatory,ValueFromPipeline,ParameterSetName='Protocol')]
25+
[Parameter(Mandatory,ParameterSetName='ScriptBlock')]
26+
[uri]
27+
$SchemaUri,
28+
29+
# The Command's Abstract Syntax Tree
30+
[Parameter(Mandatory,ParameterSetName='Protocol')]
31+
[Management.Automation.Language.CommandAST]
32+
$CommandAst,
33+
34+
[Parameter(Mandatory,ParameterSetName='ScriptBlock',ValueFromPipeline)]
35+
[scriptblock]
36+
$ScriptBlock = {},
37+
38+
# One or more property prefixes to remove.
39+
# Properties that start with this prefix will become parameters without the prefix.
40+
[Alias('Remove Property Prefix')]
41+
[string[]]
42+
$RemovePropertyPrefix,
43+
44+
# One or more properties to ignore.
45+
# Properties whose name or description is like this keyword will be ignored.
46+
[Alias('Ignore Property', 'IgnoreProperty','SkipProperty', 'Skip Property')]
47+
[string[]]
48+
$ExcludeProperty,
49+
50+
# One or more properties to include.
51+
# Properties whose name or description is like this keyword will be included.
52+
[Alias('Include Property')]
53+
[string[]]
54+
$IncludeProperty,
55+
56+
# If set, will not mark a parameter as required, even if the schema indicates it should be.
57+
[Alias('NoMandatoryParameters','No Mandatory Parameters', 'NoMandatories', 'No Mandatories')]
58+
[switch]
59+
$NoMandatory
60+
)
61+
62+
begin {
63+
# First we declare a few filters we will reuse.
64+
65+
# One resolves schema definitions.
66+
# The inputObject is the schema.
67+
# The arguments are the path within the schema.
68+
filter resolveSchemaDefinition
69+
{
70+
$in = $_.'$defs'
71+
$schemaPath = $args -replace '^#' -replace '^/' -replace '^\$defs' -split '[/\.]' -ne ''
72+
foreach ($sp in $schemaPath) {
73+
$in = $in.$sp
74+
}
75+
$in
76+
}
77+
78+
# Another converts property names into schema parameter names.
79+
filter getSchemaParameterName {
80+
$parameterName = $_
81+
# If we had any prefixes we wished to remove, now is the time.
82+
if ($RemovePropertyPrefix) {
83+
$parameterName = $parameterName -replace "^(?>$(@($RemovePropertyPrefix) -join '|'))"
84+
if ($property.Name -like 'Experimental.*') {
85+
$null = $null
86+
}
87+
}
88+
# Any punctuation followed by a letter should be removed and replaced with an Uppercase letter.
89+
$parameterName = [regex]::Replace($parameterName, "\p{P}(\p{L})", {
90+
param($match)
91+
$match.Groups[1].Value.ToUpper()
92+
}) -replace '\p{P}'
93+
# And we should force the first letter to be uppercase.
94+
$parameterName.Substring(0,1).ToUpper() + $parameterName.Substring(1)
95+
}
96+
97+
# If we have not cached the schema uris, create a collection for it.
98+
if (-not $script:CachedSchemaUris) {
99+
$script:CachedSchemaUris = @{}
100+
}
101+
102+
103+
$myCmd = $MyInvocation.MyCommand
104+
105+
}
106+
107+
process {
108+
# If we are being invoked as a protocol
109+
if ($PSCmdlet.ParameterSetName -eq 'Protocol') {
110+
# we will parse our input as a sentence.
111+
$mySentence = $commandAst.AsSentence($MyInvocation.MyCommand)
112+
# Walk thru all mapped parameters in the sentence
113+
$myParams = [Ordered]@{} + $PSBoundParameters
114+
foreach ($paramName in $mySentence.Parameters.Keys) {
115+
if (-not $myParams.Contains($paramName)) { # If the parameter was not directly supplied
116+
$myParams[$paramName] = $mySentence.Parameters[$paramName] # grab it from the sentence.
117+
foreach ($myParam in $myCmd.Parameters.Values) {
118+
if ($myParam.Aliases -contains $paramName) { # set any variables that share the name of an alias
119+
$ExecutionContext.SessionState.PSVariable.Set($myParam.Name, $mySentence.Parameters[$paramName])
120+
}
121+
}
122+
# and set this variable for this value.
123+
$ExecutionContext.SessionState.PSVariable.Set($paramName, $mySentence.Parameters[$paramName])
124+
}
125+
}
126+
}
127+
128+
# We will try to cache the schema URI at a given scope.
129+
$script:CachedSchemaUris[$SchemaUri] = $schemaObject =
130+
if (-not $script:CachedSchemaUris[$SchemaUri]) {
131+
Invoke-RestMethod -Uri $SchemaUri
132+
} else {
133+
$script:CachedSchemaUris[$SchemaUri]
134+
}
135+
136+
# If we do not have a schema object, error out.
137+
if (-not $schemaObject) {
138+
Write-Error "Could not get Schema from '$schemaUri'"
139+
return
140+
}
141+
142+
# If the object does not look have a JSON schema, error out.
143+
if (-not $schemaObject.'$schema') {
144+
Write-Error "'$schemaUri' is not a JSON Schema"
145+
return
146+
}
147+
148+
# If we do not have a URI fragment or there are no properties
149+
if (-not $SchemaUri.Fragment -and -not $schemaObject.properties) {
150+
Write-Error "No root properties defined and no definition specified"
151+
return # error out.
152+
}
153+
154+
# Resolve the schema object we want to generate.
155+
$schemaDefinition =
156+
if (-not $schemaObject.properties -and $SchemaUri.Fragment) {
157+
$schemaObject | resolveSchemaDefinition $SchemaUri.Fragment
158+
} else {
159+
$schemaObject.properties
160+
}
161+
162+
# Start off by carrying over the description.
163+
$newPipeScriptSplat = @{
164+
description = $schemaDefinition.description
165+
}
166+
167+
# Now walk thru each property in the schema
168+
$newPipeScriptParameters = [Ordered]@{}
169+
:nextProperty foreach ($property in $schemaDefinition.properties.psobject.properties) {
170+
# Filter out excluded properties
171+
if ($ExcludeProperty) {
172+
foreach ($exc in $ExcludeProperty) {
173+
if ($property.Name -like $exc) {
174+
continue nextProperty
175+
}
176+
}
177+
}
178+
# Filter included properties
179+
if ($IncludeProperty) {
180+
$included = foreach ($inc in $IncludeProperty) {
181+
if ($property.Name -like $inc) {
182+
$true;break
183+
}
184+
}
185+
if (-not $included) { continue nextProperty }
186+
}
187+
188+
# Convert the property into a parameter name
189+
$parameterName = $property.Name | getSchemaParameterName
190+
191+
# Collect the parameter attributes
192+
$parameterAttributes = @(
193+
# The description comes first, as inline help
194+
$propertyDescription = $property.value.description
195+
if ($propertyDescription -match '\n') {
196+
" <#"
197+
" " + $($propertyDescription -split '(?>\r\n|\n)' -join ([Environment]::NewLine + (' ' * 4))
198+
)
199+
" #>"
200+
} else {
201+
"# $propertyDescription"
202+
}
203+
"[Parameter($(
204+
if ($property.value.required -and -not $NoMandatory) { "Mandatory,"}
205+
)ValueFromPipelineByPropertyName)]"
206+
207+
# Followed by the defaultBindingProperty (the name of the JSON property)
208+
"[ComponentModel.DefaultBindingProperty('$($property.Name)')]"
209+
210+
# Keep track of if null was allowed and try to resolve the type
211+
$nullAllowed = $false
212+
$propertyTypeInfo =
213+
if ($property.value.'$ref') {
214+
# If there was a reference, try to resolve it
215+
$referencedType = $schemaObject | resolveSchemaDefinition $property.value.'$ref'
216+
if ($referencedType) {
217+
$referencedType
218+
}
219+
} else {
220+
# If there is not a oneOf, return the value
221+
if (-not $property.value.'oneOf') {
222+
$property.value
223+
} else {
224+
# If there is oneOf, see if it's an optional null
225+
$notNullOneOf =
226+
@(foreach ($oneOf in $property.value.oneOf) {
227+
if ($oneOf.type -eq 'null') {
228+
$nullAllowed = $true
229+
continue
230+
}
231+
$oneOf
232+
})
233+
if ($notNullOneOf.Count -eq 1) {
234+
$notNullOneOf = $notNullOneOf[0]
235+
if ($notNullOneOf.'$ref') {
236+
$referencedType = $schemaObject | resolveSchemaDefinition $notNullOneOf.'$ref'
237+
if ($referencedType) {
238+
$referencedType
239+
}
240+
} else {
241+
$notNullOneOf
242+
}
243+
} else {
244+
"[PSObject]"
245+
}
246+
}
247+
}
248+
249+
250+
# If there was a single type with an enum
251+
if ($propertyTypeInfo.enum) {
252+
$validSet = @() + $propertyTypeInfo.enum
253+
if ($nullAllowed) {
254+
$validSet += ''
255+
}
256+
257+
# create a validateset.
258+
"[ValidateSet('$($validSet -join "','")')]"
259+
"[string]"
260+
}
261+
# If there was a validation pattern
262+
elseif ($propertyTypeInfo.pattern)
263+
{
264+
$validPattern = $propertyTypeInfo.pattern
265+
if ($nullAllowed) {
266+
$validPattern = "(?>^\s{0}$|$validPattern)"
267+
}
268+
$validPattern = $validPattern.Replace("'", "''")
269+
# declare a regex.
270+
"[ValidatePattern('$validPattern')]"
271+
}
272+
# If there was a property type
273+
if ($propertyTypeInfo.type) {
274+
# limit the input
275+
switch ($propertyTypeInfo.type) {
276+
"string" {
277+
"[string]"
278+
}
279+
"integer" {
280+
"[int]"
281+
}
282+
"number" {
283+
"[double]"
284+
}
285+
"boolean" {
286+
"[switch]"
287+
}
288+
"array" {
289+
"[PSObject[]]"
290+
}
291+
"object" {
292+
"[PSObject]"
293+
}
294+
}
295+
}
296+
)
297+
298+
$parameterAttributes += "`$$parameterName"
299+
$newPipeScriptParameters[$parameterName] = $parameterAttributes
300+
}
301+
302+
303+
$newPipeScriptSplat.Parameter = $newPipeScriptParameters
304+
305+
# If there was no scriptblock, or it was nothing but an empty param()
306+
if ($ScriptBlock -match '^[\s\r\n]{0,}(?:param\(\))?[\s\r\n]{0,1}$') {
307+
# Create a script that will create the schema object.
308+
$newPipeScriptSplat.Process = [scriptblock]::Create("
309+
`$schemaTypeName = '$("$schemauri".Replace("'","''"))'
310+
" + {
311+
312+
# Get my script block
313+
$myScriptBlock = $MyInvocation.MyCommand.ScriptBlock
314+
# and declare an output object.
315+
$myParamCopy = [Ordered]@{PSTypeName=$schemaTypeName}
316+
# Walk over each parameter in my own AST.
317+
foreach ($param in $myScriptBlock.Ast.ParamBlock.Parameters) {
318+
# if there are not attributes, this parameter will not be mapped.
319+
if (-not $param.Attributes) {
320+
continue
321+
}
322+
# If the parameter was not passed, this parameter will not be mapped.
323+
$paramVariableName = $param.Name.VariablePath.ToString()
324+
if (-not $PSBoundParameters.ContainsKey($paramVariableName)) {
325+
continue
326+
}
327+
# Not walk over each attribute
328+
foreach ($attr in $param.Attributes) {
329+
# If the attribute is not a defaultbindingproperty attribute,
330+
if ($attr.TypeName.GetReflectionType() -ne [ComponentModel.DefaultBindingPropertyAttribute]) {
331+
continue # keep moving.
332+
}
333+
# Otherwise, update our object with the value
334+
$propName = $($attr.PositionalArguments.value)
335+
$myParamCopy[$propName] = $PSBoundParameters[$paramVariableName]
336+
# (don't forget to turn switches into booleans)
337+
if ($myParamCopy[$propName] -is [switch]) {
338+
$myParamCopy[$propName] = $myParamCopy[$propName] -as [bool]
339+
}
340+
341+
}
342+
}
343+
344+
# By converting our modified copy of parameters into an object
345+
# we should have an object that matches the schema.
346+
[PSCustomObject]$myParamCopy
347+
})
348+
}
349+
350+
# If we are transpiling a script block
351+
if ($PSCmdlet.ParameterSetName -eq 'ScriptBlock') {
352+
# join the existing script with the schema information
353+
Join-PipeScript -ScriptBlock $ScriptBlock, (
354+
New-PipeScript @newPipeScriptSplat
355+
)
356+
}
357+
elseif ($PSCmdlet.ParameterSetName -eq 'Protocol')
358+
{
359+
# Otherwise, create a script block that produces the schema.
360+
[ScriptBlock]::create("{
361+
$(New-PipeScript @newPipeScriptSplat)
362+
}")
363+
}
364+
365+
}

0 commit comments

Comments
 (0)