Private/Document/ConvertTo-OpenApiSchema.ps1

function ConvertTo-OpenApiSchema {
    <#
    .SYNOPSIS
        Normalises a raw schema node into the OAS 3.0-vocabulary schema object of the document model.
    .DESCRIPTION
        - $ref is resolved (Resolve-OpenApiSchemaReference); 'description' and 'nullable: true' next to a
          $ref are applied to a copy of the referenced schema.
        - 3.1 type arrays: ['string','null'] -> Type 'string', Nullable; several non-null types -> Type
          $null and OA031. 3.0 'nullable: true' -> Nullable.
        - allOf members are merged into Properties/Required (and Type, Format, Enum, Items,
          AdditionalProperties when the schema has none); AllOf keeps the members.
        - Without a 'type', a schema with properties/additionalProperties is an 'object' and one with
          items is an 'array'.
        - Boolean schemas (3.1 true/false) become a blank schema. Anything that is not an object gives $null.
        - -Shallow reads only the keywords of the node itself (no items, properties, additionalProperties
          schema, allOf/oneOf/anyOf or $ref); it is used for the stub of a circular reference.
        - allOf members that are reference stubs are merged from their full schema.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Context,

        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$Node,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Pointer,

        [switch]$Shallow
    )

    if ($null -eq $Node) {
        return $null
    }
    if ($Node -is [bool]) {
        return Get-OpenApiBlankSchema
    }
    if ($Node -isnot [System.Collections.IDictionary]) {
        return $null
    }

    if ($Node.Contains('$ref')) {
        if ($Shallow) {
            return Get-OpenApiBlankSchema
        }
        $referenced = Resolve-OpenApiSchemaReference -Context $Context -Reference ([string]$Node['$ref']) -Pointer $Pointer
        $hasDescription = $Node.Contains('description') -and $null -ne $Node['description']
        $isNullable = $Node['nullable'] -eq $true
        if (-not $referenced.Recursive -and ($hasDescription -or $isNullable)) {
            $referenced = $referenced.PSObject.Copy()
            if ($hasDescription) {
                $referenced.Description = [string]$Node['description']
            }
            if ($isNullable) {
                $referenced.Nullable = $true
            }
        }
        return $referenced
    }

    $schema = Get-OpenApiBlankSchema
    $hasType = $Node.Contains('type')
    if ($hasType) {
        $rawType = $Node['type']
        if ($rawType -is [System.Collections.IList]) {
            $nonNull = [System.Collections.Generic.List[string]]::new()
            foreach ($item in $rawType) {
                if ([string]$item -ceq 'null') {
                    $schema.Nullable = $true
                }
                elseif (-not $nonNull.Contains([string]$item)) {
                    $nonNull.Add([string]$item)
                }
            }
            if ($nonNull.Count -eq 1) {
                $schema.Type = $nonNull[0]
            }
            elseif ($nonNull.Count -gt 1) {
                Add-OpenApiFinding -Context $Context -Severity Warning -Code 'OA031' -Pointer (Join-OpenApiJsonPointer -Pointer $Pointer -Segment 'type') -Message "Schema allows several types ($($nonNull -join ', ')); it is treated as untyped."
            }
        }
        elseif ([string]$rawType -ceq 'null') {
            $schema.Nullable = $true
        }
        elseif ($null -ne $rawType) {
            $schema.Type = [string]$rawType
        }
    }
    if ($Node['nullable'] -eq $true) {
        $schema.Nullable = $true
    }

    foreach ($pair in @(
            @('format', 'Format'), @('description', 'Description'), @('title', 'Title'), @('pattern', 'Pattern')
        )) {
        if ($null -ne $Node[$pair[0]]) {
            $schema.($pair[1]) = [string]$Node[$pair[0]]
        }
    }
    foreach ($pair in @(
            @('default', 'Default'), @('const', 'Const'), @('minimum', 'Minimum'), @('maximum', 'Maximum'),
            @('exclusiveMinimum', 'ExclusiveMinimum'), @('exclusiveMaximum', 'ExclusiveMaximum'),
            @('minLength', 'MinLength'), @('maxLength', 'MaxLength'), @('minItems', 'MinItems'), @('maxItems', 'MaxItems'),
            @('example', 'Example')
        )) {
        if ($Node.Contains($pair[0])) {
            $schema.($pair[1]) = $Node[$pair[0]]
        }
    }
    if ($null -eq $schema.Example -and $Node['examples'] -is [System.Collections.IList] -and $Node['examples'].Count -gt 0) {
        $schema.Example = $Node['examples'][0]
    }
    if ($Node.Contains('enum') -and $Node['enum'] -is [System.Collections.IList]) {
        $schema.Enum = [object[]]$Node['enum']
    }
    $schema.ReadOnly = $Node['readOnly'] -eq $true
    $schema.WriteOnly = $Node['writeOnly'] -eq $true
    $schema.Deprecated = $Node['deprecated'] -eq $true

    if (-not $Shallow -and $Node['discriminator'] -is [System.Collections.IDictionary]) {
        $schema.Discriminator = [pscustomobject]@{
            PropertyName = [string]$Node['discriminator']['propertyName']
            Mapping      = $Node['discriminator']['mapping']
        }
    }

    if (-not $Shallow -and $Node.Contains('items')) {
        $items = $Node['items']
        $itemsPointer = $Pointer + '/items'
        if ($items -is [System.Collections.IList]) {
            # Draft-4 style tuple: use the first item schema
            if ($items.Count -gt 0) {
                $items = $items[0]
                $itemsPointer += '/0'
            }
            else {
                $items = $null
            }
        }
        $schema.Items = ConvertTo-OpenApiSchema -Context $Context -Node $items -Pointer $itemsPointer
    }

    $properties = $null
    if (-not $Shallow -and $Node['properties'] -is [System.Collections.IDictionary]) {
        $properties = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
        # Pointers are built inline here (the hot path); property names are escaped as JSON pointer segments
        $propertiesPointer = $Pointer + '/properties/'
        foreach ($name in @($Node['properties'].Keys)) {
            $properties[$name] = ConvertTo-OpenApiSchema -Context $Context -Node $Node['properties'][$name] -Pointer ($propertiesPointer + $name.Replace('~', '~0').Replace('/', '~1'))
        }
    }
    $required = [System.Collections.Generic.List[string]]::new()
    if ($Node['required'] -is [System.Collections.IList]) {
        foreach ($name in $Node['required']) {
            if (-not $required.Contains([string]$name)) {
                $required.Add([string]$name)
            }
        }
    }

    if ($Node.Contains('additionalProperties')) {
        $additional = $Node['additionalProperties']
        if ($additional -is [bool]) {
            $schema.AdditionalProperties = $additional
        }
        elseif (-not $Shallow) {
            $schema.AdditionalProperties = ConvertTo-OpenApiSchema -Context $Context -Node $additional -Pointer ($Pointer + '/additionalProperties')
        }
    }

    foreach ($pair in @(@('allOf', 'AllOf'), @('oneOf', 'OneOf'), @('anyOf', 'AnyOf'))) {
        if (-not $Shallow -and $Node[$pair[0]] -is [System.Collections.IList]) {
            $members = [System.Collections.Generic.List[object]]::new()
            $index = 0
            foreach ($member in $Node[$pair[0]]) {
                $converted = ConvertTo-OpenApiSchema -Context $Context -Node $member -Pointer ('{0}/{1}/{2}' -f $Pointer, $pair[0], $index)
                if ($null -ne $converted) {
                    $members.Add($converted)
                }
                $index++
            }
            $schema.($pair[1]) = $members.ToArray()
        }
    }

    if ($null -ne $schema.AllOf) {
        $merged = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
        $mergedRequired = [System.Collections.Generic.List[string]]::new()
        foreach ($reference in $schema.AllOf) {
            # A reference stub has no structure of its own: merge its full schema
            $member = $reference
            if ($null -ne $reference.RefName) {
                $fullKey = Join-OpenApiJsonPointer -Pointer '/components/schemas' -Segment $reference.RefName
                if ($Context.SchemaCache.ContainsKey($fullKey)) {
                    $member = $Context.SchemaCache[$fullKey]
                }
            }
            if ($null -ne $member.Properties) {
                foreach ($name in @($member.Properties.Keys)) {
                    $merged[$name] = $member.Properties[$name]
                }
            }
            foreach ($name in @($member.Required)) {
                if (-not $mergedRequired.Contains($name)) {
                    $mergedRequired.Add($name)
                }
            }
            if (-not $hasType -and $null -eq $schema.Type -and $null -ne $member.Type) {
                $schema.Type = $member.Type
            }
            foreach ($field in 'Format', 'Enum', 'Items', 'AdditionalProperties') {
                if ($null -eq $schema.$field -and $null -ne $member.$field) {
                    $schema.$field = $member.$field
                }
            }
            if ($member.Nullable -and $schema.AllOf.Count -eq 1) {
                $schema.Nullable = $true
            }
        }
        if ($null -ne $properties) {
            foreach ($name in @($properties.Keys)) {
                $merged[$name] = $properties[$name]
            }
        }
        foreach ($name in $required) {
            if (-not $mergedRequired.Contains($name)) {
                $mergedRequired.Add($name)
            }
        }
        if ($merged.Count -gt 0 -or $null -ne $properties) {
            $properties = $merged
        }
        $required = $mergedRequired
    }

    $schema.Properties = $properties
    $schema.Required = $required.ToArray()

    if (-not $hasType -and $null -eq $schema.Type) {
        if ($null -ne $schema.Properties -or $null -ne $schema.AdditionalProperties -or ($Shallow -and ($Node.Contains('properties') -or $Node.Contains('additionalProperties')))) {
            $schema.Type = 'object'
        }
        elseif ($null -ne $schema.Items -or ($Shallow -and $Node.Contains('items'))) {
            $schema.Type = 'array'
        }
    }
    return $schema
}