Private/ConvertTo-JIMOutputObject.ps1

# Copyright (c) Tetron Limited. All rights reserved.
# Licensed under the Tetron Commercial License. See LICENSE file in the project root.

# Property names whose value is a dynamic-key dictionary, where the keys are *data*
# (attribute names, Serilog property names, validation field names) rather than fixed
# DTO field names. The REST API serialises DTO property names as camelCase but leaves
# dictionary keys verbatim (only PropertyNamingPolicy is set, not DictionaryKeyPolicy),
# so the module must do the same: a value keyed by attribute name like 'mail' or
# 'employeeID' must never be rewritten to 'Mail' / 'EmployeeID', which would corrupt it.
#
# The container property itself IS a DTO field and is still renamed to PascalCase
# (e.g. 'attributes' -> 'Attributes'); only the keys *inside* its value are preserved.
#
# A name alone is not sufficient to decide: 'attributes' is a dynamic dictionary on
# MetaverseObjectHeaderDto but a List<DTO> on ConnectedSystemDto and
# MetaverseObjectTypeDetailDto (whose element fields DO need normalising). The
# discriminator is the JSON shape: a dictionary always serialises as a JSON object
# (a PSCustomObject after ConvertFrom-Json), a DTO list as a JSON array. So a property
# is treated as opaque only when its name is listed here AND its value is a JSON object.
#
# MAINTENANCE: when a new API response exposes a dictionary keyed by data (any
# Dictionary<string, ...> whose key is a name/identifier the user controls), add its
# camelCase wire property name here, or its keys will be silently PascalCased. See the
# ConvertTo-JIMOutputObject tests, which pin this list.
$script:JIMOpaqueValueProperties = @(
    'attributes'        # MetaverseObjectHeaderDto.Attributes: keyed by attribute name
    'mvAttributes'      # SyncRuleMappingPreview MV attributes: keyed by attribute name
    'csAttributes'      # SyncRuleMappingPreview CS attributes: keyed by attribute name
    'properties'        # LogEntryDto.Properties: keyed by Serilog property name
    'validationErrors'  # ApiErrorResponse.ValidationErrors: keyed by field name
)

function ConvertTo-JIMPascalCaseName {
    <#
    .SYNOPSIS
        Converts a single camelCase wire property name to PascalCase.
 
    .DESCRIPTION
        The REST API applies System.Text.Json's camelCase naming policy, which only
        lower-cases the leading character of each PascalCase property name. Upper-casing
        the leading character is therefore the exact inverse for every DTO field JIM
        exposes (verified: no API DTO property begins with a multi-letter acronym such
        as 'IPAddress' that the policy would lower-case beyond the first character).
        If such a property is ever introduced, the tail casing would round-trip
        imperfectly (e.g. 'ipAddress' -> 'IpAddress'); the value is still reachable via
        PowerShell's case-insensitive member access, so this is cosmetic, not breaking.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name
    )

    if ([string]::IsNullOrEmpty($Name) -or [char]::IsUpper($Name[0])) {
        return $Name
    }

    return [char]::ToUpperInvariant($Name[0]) + $Name.Substring(1)
}

function ConvertTo-JIMOutputObject {
    <#
    .SYNOPSIS
        Normalises a deserialised REST API response to PascalCase property names.
 
    .DESCRIPTION
        JIM's REST API serialises JSON with camelCase property names (correct for a
        REST/JSON API), but PowerShell cmdlet output is expected to be PascalCase
        (Microsoft Cmdlet Development Guidelines). This helper rehydrates the object
        graph returned by ConvertFrom-Json (via Invoke-RestMethod) into an equivalent
        graph with PascalCase property names, so Get-Member, Format-Table,
        ConvertTo-Json and tab-completion all present the conventional casing.
 
        Dynamic-key dictionary values (see $script:JIMOpaqueValueProperties) are passed
        through with their keys preserved verbatim, because those keys are user data
        (attribute names, log property names), not DTO field names.
 
        Property order, arrays (including single-element and empty), scalars and nulls
        are all preserved. PowerShell member access is case-insensitive, so cmdlets that
        internally read wire-cased properties (e.g. $response.items) continue to work.
 
    .PARAMETER InputObject
        The deserialised value to normalise: a PSCustomObject, an array, a scalar, or null.
 
    .PARAMETER Verbatim
        Internal. When set, property names at this level (and below) are preserved as-is.
        Used to carry the "this subtree is dynamic-key data" state down the recursion.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object]$InputObject,

        [switch]$Verbatim
    )

    if ($null -eq $InputObject) {
        return $null
    }

    # Strings are IEnumerable; treat them (and every other scalar) as leaf values.
    if ($InputObject -is [string] -or $InputObject -is [System.ValueType]) {
        return $InputObject
    }

    # JSON object: rebuild with normalised property names, preserving order.
    if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
        $normalised = [ordered]@{}
        foreach ($property in $InputObject.PSObject.Properties) {
            if ($Verbatim) {
                # Already inside a dynamic-key subtree: keep this key, and keep recursing
                # verbatim (nested values are data too, never DTO shapes).
                $normalised[$property.Name] = ConvertTo-JIMOutputObject -InputObject $property.Value -Verbatim
            }
            else {
                # A property is opaque only when it is both named as a dynamic-key holder
                # and its value is a JSON object (a dictionary), not a JSON array (a DTO
                # list, whose elements must still be normalised).
                $isOpaque = ($script:JIMOpaqueValueProperties -contains $property.Name) -and `
                    ($property.Value -is [System.Management.Automation.PSCustomObject])
                $newName = ConvertTo-JIMPascalCaseName -Name $property.Name
                $normalised[$newName] = ConvertTo-JIMOutputObject -InputObject $property.Value -Verbatim:$isOpaque
            }
        }
        return [PSCustomObject]$normalised
    }

    # JSON array (or any other enumerable): normalise each element, preserving arity.
    if ($InputObject -is [System.Collections.IEnumerable]) {
        $items = [System.Collections.Generic.List[object]]::new()
        foreach ($item in $InputObject) {
            $items.Add((ConvertTo-JIMOutputObject -InputObject $item -Verbatim:$Verbatim))
        }
        # Comma operator: return the array as a single object so assignment and pipeline
        # capture preserve it (including empty and single-element arrays) rather than
        # unrolling it away.
        return , $items.ToArray()
    }

    # Any other reference type (should not occur for JSON payloads): return unchanged.
    return $InputObject
}