Private/Json.ps1

# JSON in and out, and the small data helpers the rest of the module shares.
#
# Reading goes through System.Text.Json rather than ConvertFrom-Json, which turns any string that looks like a
# date into a DateTime (PowerShell before 7.5 has no way to stop it). The module sends template-form properties
# back to the engine it read them from, and a date string that came back as another spelling of the same
# instant would read as a change.
#
# Convention: a helper that answers an array answers it as ONE value (return , $array), so an array of one
# element stays an array. Never wrap such a call in @(): that nests the array.

function Get-VMetricDictionaryKey {
    # The key of a dictionary that matches a name, exactly first and then ignoring case; $null when none does.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Dictionary,

        [Parameter(Mandatory)]
        [string] $Name
    )

    $match = $null
    foreach ($key in $Dictionary.Keys) {
        $text = [string]$key
        if ([string]::Equals($text, $Name, [System.StringComparison]::Ordinal)) {
            return $text
        }
        if ($null -eq $match -and [string]::Equals($text, $Name, [System.StringComparison]::OrdinalIgnoreCase)) {
            $match = $text
        }
    }
    return $match
}

function Get-VMetricMember {
    # A property of an object or a key of a dictionary, ignoring case; $null when it has none. Safe under strict
    # mode. An array value comes back as one array (see the convention above).
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        [Parameter(Mandatory)]
        [string] $Name
    )

    if ($null -eq $InputObject) {
        return $null
    }
    $value = $null
    if ($InputObject -is [System.Collections.IDictionary]) {
        $key = Get-VMetricDictionaryKey -Dictionary $InputObject -Name $Name
        if ($null -eq $key) {
            return $null
        }
        $value = $InputObject[$key]
    }
    else {
        $property = $InputObject.psobject.Properties[$Name]
        if ($null -eq $property) {
            return $null
        }
        $value = $property.Value
    }
    if ($null -eq $value) {
        return $null
    }
    if ($value -is [array]) {
        return , $value
    }
    return $value
}

function ConvertTo-VMetricArray {
    # Any value as an array: $null is empty, a list is copied, anything else is one element.
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    if ($null -eq $InputObject) {
        return , @()
    }
    if ($InputObject -is [string] -or $InputObject -is [System.Collections.IDictionary]) {
        return , @($InputObject)
    }
    if ($InputObject -is [System.Collections.IEnumerable]) {
        $list = [System.Collections.Generic.List[object]]::new()
        foreach ($item in $InputObject) {
            $list.Add($item)
        }
        return , $list.ToArray()
    }
    return , @($InputObject)
}

function Test-VMetricObjectLike {
    # True for a dictionary or a PSCustomObject: something with named values.
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    if ($null -eq $InputObject) {
        return $false
    }
    if ($InputObject -is [System.Collections.IDictionary]) {
        return $true
    }
    return ($InputObject -is [System.Management.Automation.PSObject] -and
        $InputObject.psobject.BaseObject -is [System.Management.Automation.PSCustomObject])
}

function ConvertFrom-VMetricJson {
    # JSON text as PSCustomObjects (or ordered dictionaries with -AsHashtable), strings kept as strings,
    # integers as Int64.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Json,

        [switch] $AsHashtable
    )

    if ([string]::IsNullOrWhiteSpace($Json)) {
        return $null
    }

    $options = [System.Text.Json.JsonDocumentOptions]::new()
    $options.MaxDepth = 256
    $options.AllowTrailingCommas = $true
    $options.CommentHandling = [System.Text.Json.JsonCommentHandling]::Skip

    try {
        $document = [System.Text.Json.JsonDocument]::Parse($Json, $options)
    }
    catch {
        $inner = $_.Exception
        while ($inner.InnerException) {
            $inner = $inner.InnerException
        }
        throw (New-VMetricException -Message "The text is not valid JSON: $($inner.Message)" -Code 'InvalidJson' `
                -Category InvalidData -InnerException $_.Exception)
    }

    $hashtable = $AsHashtable.IsPresent
    $convert = {
        param([System.Text.Json.JsonElement] $Element)

        switch ($Element.ValueKind) {
            'Object' {
                $map = [ordered]@{}
                foreach ($property in $Element.EnumerateObject()) {
                    $map[$property.Name] = & $convert $property.Value
                }
                if ($hashtable) {
                    return $map
                }
                return [pscustomobject]$map
            }
            'Array' {
                $list = [System.Collections.Generic.List[object]]::new()
                foreach ($item in $Element.EnumerateArray()) {
                    $list.Add((& $convert $item))
                }
                return , $list.ToArray()
            }
            'String' {
                return $Element.GetString()
            }
            'Number' {
                $long = 0L
                if ($Element.TryGetInt64([ref] $long)) {
                    return $long
                }
                $decimal = 0.0d
                if ($Element.TryGetDecimal([ref] $decimal)) {
                    return $decimal
                }
                return $Element.GetDouble()
            }
            'True' {
                return $true
            }
            'False' {
                return $false
            }
        }
        return $null
    }

    try {
        $root = & $convert $document.RootElement
    }
    finally {
        $document.Dispose()
    }
    if ($root -is [array]) {
        return , $root
    }
    return $root
}

function ConvertTo-VMetricWireValue {
    # A deep copy ready for ConvertTo-Json: a SecureString becomes its text (this is the only place the module
    # reads one), a switch a boolean, an enum its name, and every container a plain ordered dictionary or
    # array, dropping the notes PowerShell attaches to strings read from files.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject,

        # For text a person reads: a SecureString becomes *** instead of its text.
        [switch] $MaskSecureString
    )

    $mask = $MaskSecureString.IsPresent
    $toWire = {
        param($Value)

        if ($null -eq $Value) {
            return $null
        }
        if ($Value -is [System.Security.SecureString]) {
            if ($mask) {
                return '***'
            }
            return [System.Net.NetworkCredential]::new('', $Value).Password
        }
        if ($Value -is [string]) {
            return $Value.ToString()
        }
        if ($Value -is [System.Management.Automation.SwitchParameter]) {
            return $Value.IsPresent
        }
        if ($Value -is [enum] -or $Value -is [guid] -or $Value -is [char] -or $Value -is [uri]) {
            return $Value.ToString()
        }
        if ($Value -is [System.ValueType]) {
            return $Value
        }
        if ($Value -is [System.Collections.IDictionary]) {
            $map = [System.Collections.Specialized.OrderedDictionary]::new()
            foreach ($key in $Value.Keys) {
                $map[[string]$key] = & $toWire $Value[$key]
            }
            return $map
        }
        if ($Value -is [System.Management.Automation.PSObject] -and $Value.psobject.BaseObject -is [System.Management.Automation.PSCustomObject]) {
            $map = [System.Collections.Specialized.OrderedDictionary]::new()
            foreach ($property in $Value.psobject.Properties) {
                if ($property.MemberType -eq 'NoteProperty') {
                    $map[$property.Name] = & $toWire $property.Value
                }
            }
            return $map
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $list = [System.Collections.Generic.List[object]]::new()
            foreach ($item in $Value) {
                $list.Add((& $toWire $item))
            }
            return , $list.ToArray()
        }
        return $Value.ToString()
    }

    $wire = & $toWire $InputObject
    if ($wire -is [array]) {
        return , $wire
    }
    return $wire
}

function ConvertTo-VMetricJson {
    # Compact JSON for a request body. The text holds any secret the body carries: it goes to the transport and
    # nowhere else (never to verbose, debug or an error record).
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object] $InputObject
    )

    $wire = ConvertTo-VMetricWireValue -InputObject $InputObject
    ConvertTo-Json -InputObject $wire -Depth 100 -Compress
}

function Copy-VMetricData {
    # A deep, mutable copy: objects become ordered dictionaries, arrays new arrays, values stay as they are
    # (a SecureString included; it is converted only when the body is serialized).
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $copy = {
        param($Value)

        if ($null -eq $Value) {
            return $null
        }
        if ($Value -is [string] -or $Value -is [System.ValueType] -or $Value -is [System.Security.SecureString]) {
            return $Value
        }
        if ($Value -is [System.Collections.IDictionary]) {
            $map = [ordered]@{}
            foreach ($key in $Value.Keys) {
                $map[[string]$key] = & $copy $Value[$key]
            }
            return $map
        }
        if ($Value -is [System.Management.Automation.PSObject] -and $Value.psobject.BaseObject -is [System.Management.Automation.PSCustomObject]) {
            $map = [ordered]@{}
            foreach ($property in $Value.psobject.Properties) {
                if ($property.MemberType -eq 'NoteProperty') {
                    $map[$property.Name] = & $copy $property.Value
                }
            }
            return $map
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $list = [System.Collections.Generic.List[object]]::new()
            foreach ($item in $Value) {
                $list.Add((& $copy $item))
            }
            return , $list.ToArray()
        }
        return $Value
    }

    $result = & $copy $InputObject
    if ($result -is [array]) {
        return , $result
    }
    return $result
}

function ConvertTo-VMetricPSObject {
    # Ordered dictionaries (deeply) as PSCustomObjects, for output.
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    $convert = {
        param($Value)

        if ($null -eq $Value -or $Value -is [string] -or $Value -is [System.ValueType] -or $Value -is [System.Security.SecureString]) {
            return $Value
        }
        if ($Value -is [System.Collections.IDictionary]) {
            $map = [ordered]@{}
            foreach ($key in $Value.Keys) {
                $map[[string]$key] = & $convert $Value[$key]
            }
            return [pscustomobject]$map
        }
        if ($Value -is [System.Management.Automation.PSObject] -and $Value.psobject.BaseObject -is [System.Management.Automation.PSCustomObject]) {
            return $Value
        }
        if ($Value -is [System.Collections.IEnumerable]) {
            $list = [System.Collections.Generic.List[object]]::new()
            foreach ($item in $Value) {
                $list.Add((& $convert $item))
            }
            return , $list.ToArray()
        }
        return $Value
    }

    $result = & $convert $InputObject
    if ($result -is [array]) {
        return , $result
    }
    return $result
}

function Format-VMetricValue {
    # A value as a what-if line shows it: JSON, so an empty string, null and a number read differently.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [object] $InputObject
    )

    if ($null -eq $InputObject) {
        return 'null'
    }
    $text = ConvertTo-Json -InputObject (ConvertTo-VMetricWireValue -InputObject $InputObject -MaskSecureString) -Depth 20 -Compress
    return (ConvertTo-VMetricSafeText -Text $text)
}