function Write-OrderedJson { <# .SYNOPSIS Given a PSCustomObject, write it as pretty indented json WITH ORDERING for consistency .PARAMETER JsonObject An object that should be written as json. Should be a PSCustomObject or Hashtable .PARAMETER InputPath A path to a given file to reduce need to import file contents prior to running .PARAMETER NoKeyReorder [switch] Should avoid reordering keys .PARAMETER TargetPath A filesystem path for output #> [CmdletBinding(DefaultParameterSetName = 'JsonObject')] [OutputType([string])] param ( # Setting to mandatory false because it can be null and that's ok, we just return null [Parameter(Mandatory = $false, ValueFromPipeline = $true, ParameterSetName = 'JsonObject')] $JsonObject, [Parameter(Mandatory = $true, ParameterSetName = 'InputPath')] [Alias('Input')] [Alias('Source')] [Alias('OriginalFile')] $Path, [switch]$NoKeyReorder, [Parameter(Mandatory = $false)] [Alias('Output')] [Alias('Destination')] $TargetPath ) if ($PSCmdlet.ParameterSetName -eq 'InputPath') { $JsonObject = (Get-Content -Path $Path -Raw) } if ($JsonObject -is [string]) { $JsonObject = (ConvertFrom-Json $JsonObject) } elseif ($JsonObject -is [Array]) { $JsonObject = (ConvertFrom-Json ([string]::Join('',$JsonObject))) } #region innerFunctions $stringBuilder = New-Object System.Text.StringBuilder $quoteString = '"' $commaString = "," $OrderedKeys = !$NoKeyReorder $primitiveMatch = 'byte|short|int32|long|sbyte|ushort|uint32|ulong|float|double|decimal|boolean' function Get-JsonStringLeadsByDepth { param ( $Depth = 0 ) $spacesString = [string]::new(" ", ($Depth + 1) * 2) $shortSpacesString = "" if ($Depth -gt 0) { $shortSpacesString = [string]::new(" ", $Depth * 2) } return ($spacesString, $shortSpacesString) } function Build-JsonArrayNameValuePair { param ( $JsonName, $JsonValue, $Depth = 0 ) $spacesString, $shortSpacesString = (Get-JsonStringLeadsByDepth -Depth $Depth) if ($OrderedKeys) { $JsonValue = $JsonValue | Sort-Object -Property Name,Key,Id } $stringBuilder.Append($shortSpacesString) | Out-Null if (![string]::IsNullOrWhiteSpace($JsonName)) { $stringBuilder.Append("$quoteString$JsonName$quoteString : ") | Out-Null } $stringBuilder.AppendLine("[") | Out-Null if ($OrderedKeys) { if (($JsonValue[0].GetType().Name -match $primitiveMatch) -or ($JsonValue[0] -is [string])) { $JsonValue = $JsonValue | Sort-Object } } foreach ($iter in $JsonValue) { if ($iter.GetType().Name -match $primitiveMatch) { # Write it without quotes $stringBuilder.AppendLine("$spacesString$iter$commaString") | Out-Null } elseif ($iter -is [string]) { # Write it with quotes $stringBuilder.AppendLine("$spacesString$quoteString$iter$quoteString$commaString") | Out-Null } else { # Must be a complex object, but without a name, so write the value Build-JsonObject -JsonName $null -JsonValue $iter -Depth ($Depth + 1) } } $stringBuilder.AppendLine("$shortSpacesString]$commaString") | Out-Null } function Build-JsonObject { param ( $JsonName, $JsonValue, $Depth = 0 ) $spacesString, $shortSpacesString = (Get-JsonStringLeadsByDepth -Depth $Depth) $stringBuilder.Append($shortSpacesString) | Out-Null if (![string]::IsNullOrWhiteSpace($JsonName)) { $stringBuilder.Append("$quoteString$JsonName$quoteString : ") | Out-Null } $stringBuilder.AppendLine("{") | Out-Null if (!(Test-IsCollectionNullOrEmpty $JsonValue.PSObject.Properties.Where({$_.MemberType -eq 'NoteProperty'}))) { $JsonValue = $JsonValue.PSObject.Properties.Where({$_.MemberType -eq 'NoteProperty'}) } if ($OrderedKeys) { $JsonValue = $JsonValue | Sort-Object -Property Name, Key, Id } $keys = $JsonValue.Keys if (Test-IsCollectionNullOrEmpty $keys) { $keys = $JsonValue.Name } foreach ($key in $keys) { $iter = $JsonValue[$key] if ($null -eq $iter) { $posit = $JsonValue.Where({$_.Name -eq $key}) if ($null -ne $posit) { $iter = $posit.Value } } if ($null -eq $iter) { $stringBuilder.AppendLine("$spacesString$quoteString$key$quoteString : null$commaString") | Out-Null } else { if ($iter -ceq "False") { $iter = $false } if ($iter -ceq "True") { $iter = $true } if ($iter.GetType().Name -match $primitiveMatch) { $stringBuilder.AppendLine("$spacesString$quoteString$key$quoteString : $($iter.ToString().ToLower())$commaString") | Out-Null } elseif ($iter -is [string]) { $stringBuilder.AppendLine("$spacesString$quoteString$key$quoteString : $quoteString$iter$quoteString$commaString") | Out-Null } elseif ($iter.GetType().IsArray) { # values are an array, so let's write the array values Build-JsonArrayNameValuePair -JsonName $key -JsonValue $iter -Depth ($Depth + 1) } else { # It was not an array, or a primitive, so it must be _another_ object? Build-JsonObject -JsonName $key -JsonValue $iter -Depth ($Depth + 1) } } } $stringBuilder.AppendLine("$shortSpacesString}$commaString") | Out-Null } #endregion innerFunctions #region primitives should just be written as-is if ($JsonObject -is [string]) { return "$quoteString$JsonObject$quoteString" } elseif ($JsonObject -is [bool]) { return $JsonObject.ToString().ToLower() } elseif ($JsonObject.GetType().Name -match $primitiveMatch) { return $JsonObject.ToString() #endregion primitives should just be written as-is } elseif ($JsonObject.GetType().IsArray) { Build-JsonArrayNameValuePair -JsonName $null -JsonValue $JsonObject } else { Build-JsonObject -JsonName $null -JsonValue $JsonObject } # postprocess to convert this string: ",(\r?\n?\s*?[\]\}])" to this string "$1" (mind the escaping tho) # That string says "any comma followed by any newline character(s) and any number of spaces, followed by a closing brace (indicating the end of an array or object) should remove the comma but retain the rest of the string" # This removes trailing commas in arrays and object notations $regex = New-Object System.Text.RegularExpressions.Regex(",(\r?\n?\s*?[\]\}])") $builtString = $stringBuilder.ToString() $builtString = $regex.Replace($builtString, "`$1") $builtString = $builtString.TrimEnd().TrimEnd(",") if ([string]::IsNullOrWhiteSpace($TargetPath)) { return $builtString } else { Set-Content -Path $TargetPath -Value $builtString -Force | Out-Null } }