Private/Document/ConvertTo-OpenApiOperation.ps1

function ConvertTo-OpenApiOperation {
    <#
    .SYNOPSIS
        Builds the Operation object of the document model from a raw operation and its path item.
    .DESCRIPTION
        Path-level parameters are merged in; an operation-level parameter replaces a path-level one with
        the same (Name, In) (header names compared case-insensitively). Security is $null when the
        operation does not declare it (use the document default), an empty array for 'security: []'.
        Any external $ref met while building the operation (directly or through a cached schema) sets
        Unsupported. Deprecated operations give OA060 (Information).
        A final catch-all path segment ('*name' as in router-style paths, or '{name*}') whose name is a path
        parameter becomes '{name}' in Path and that parameter gets CatchAll = $true: its value is a path of
        its own and keeps its '/' at call time. A path parameter that is not in the path template gives
        OA023 (Warning: it is ignored); a template placeholder without a path parameter gives OA024 (Error:
        the command cannot fill it). Names are compared case-sensitively.
    #>

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

        [Parameter(Mandatory)]
        [string]$OperationId,

        [Parameter(Mandatory)]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Operation,

        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$PathItem,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$PathItemPointer
    )

    $Context.CurrentOperation = $OperationId
    $hitsBefore = $Context.ExternalRefHits
    $pointer = Join-OpenApiJsonPointer -Pointer $PathItemPointer -Segment $Method.ToLowerInvariant()

    $parameters = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
    $parameterPointers = @{}
    foreach ($source in @(@($PathItem['parameters'], $PathItemPointer), @($Operation['parameters'], $pointer))) {
        if ($source[0] -isnot [System.Collections.IList]) {
            continue
        }
        $index = 0
        foreach ($rawParameter in $source[0]) {
            $parameterPointer = Join-OpenApiJsonPointer -Pointer $source[1] -Segment 'parameters', ([string]$index)
            $index++
            $resolved = Resolve-OpenApiComponentReference -Context $Context -Node $rawParameter -Pointer $parameterPointer
            if ($null -eq $resolved -or $resolved.Node -isnot [System.Collections.IDictionary]) {
                continue
            }
            $parameter = ConvertTo-OpenApiParameter -Context $Context -Node $resolved.Node -Pointer $resolved.Pointer
            $name = $parameter.Name
            if ($parameter.In -eq 'header') {
                $name = $name.ToLowerInvariant()
            }
            $parameters['{0}|{1}' -f $parameter.In, $name] = $parameter
            $parameterPointers['{0}|{1}' -f $parameter.In, $name] = $resolved.Pointer
        }
    }

    $requestBody = $null
    if ($Operation.Contains('requestBody')) {
        $resolvedBody = Resolve-OpenApiComponentReference -Context $Context -Node $Operation['requestBody'] -Pointer (Join-OpenApiJsonPointer -Pointer $pointer -Segment 'requestBody')
        if ($null -ne $resolvedBody -and $resolvedBody.Node -is [System.Collections.IDictionary]) {
            $requestBody = ConvertTo-OpenApiRequestBody -Context $Context -Node $resolvedBody.Node -Pointer $resolvedBody.Pointer
        }
    }

    $responses = ConvertTo-OpenApiResponseList -Context $Context -Responses $Operation['responses'] -Pointer (Join-OpenApiJsonPointer -Pointer $pointer -Segment 'responses')

    $security = $null
    if ($Operation.Contains('security')) {
        $security = ConvertTo-OpenApiSecurityRequirement -Context $Context -Requirement $Operation['security'] -Pointer (Join-OpenApiJsonPointer -Pointer $pointer -Segment 'security')
    }

    $tags = [System.Collections.Generic.List[string]]::new()
    if ($Operation['tags'] -is [System.Collections.IList]) {
        foreach ($tag in $Operation['tags']) {
            if ($null -ne $tag) {
                $tags.Add([string]$tag)
            }
        }
    }

    $extensions = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
    foreach ($key in @($Operation.Keys)) {
        if ($key.StartsWith('x-')) {
            $extensions[$key] = $Operation[$key]
        }
    }

    $externalDocsUrl = $null
    if ($Operation['externalDocs'] -is [System.Collections.IDictionary] -and $null -ne $Operation['externalDocs']['url']) {
        $externalDocsUrl = [string]$Operation['externalDocs']['url']
    }

    $deprecated = $Operation['deprecated'] -eq $true
    if ($deprecated) {
        Add-OpenApiFinding -Context $Context -Severity Information -Code 'OA060' -Pointer (Join-OpenApiJsonPointer -Pointer $pointer -Segment 'deprecated') -Message "Operation '$OperationId' is deprecated."
    }

    $unsupported = $Context.ExternalRefHits -gt $hitsBefore
    if ($unsupported) {
        $reported = @($Context.Findings | Where-Object { $_.Code -eq 'OA020' -and $_.Operation -eq $OperationId })
        if ($reported.Count -eq 0) {
            Add-OpenApiFinding -Context $Context -Severity Error -Code 'OA020' -Pointer $pointer -Message "Operation '$OperationId' uses a schema that contains an external `$ref; it is flagged Unsupported."
        }
    }

    # Path template: catch-all segment, parameters missing from the template and placeholders without a parameter
    $pathParameters = @($parameters.Keys | Where-Object -FilterScript { $_.StartsWith('path|') })
    $catchAll = [regex]::Match($Path, '/(?:\*([A-Za-z0-9_.~-]+)|\{([^{}*/]+)\*\})$')
    if ($catchAll.Success) {
        $catchAllName = $catchAll.Groups[1].Value
        if ($catchAllName -eq '') {
            $catchAllName = $catchAll.Groups[2].Value
        }
        $key = 'path|' + $catchAllName
        if ($pathParameters -ccontains $key) {
            $Path = $Path.Substring(0, $catchAll.Index) + '/{' + $catchAllName + '}'
            $parameters[$key].CatchAll = $true
        }
    }
    $placeholders = @([regex]::Matches($Path, '\{([^{}]+)\}') | ForEach-Object -Process { $_.Groups[1].Value })
    foreach ($key in $pathParameters) {
        $name = $key.Substring(5)
        if ($placeholders -cnotcontains $name) {
            Add-OpenApiFinding -Context $Context -Severity Warning -Code 'OA023' -Pointer $parameterPointers[$key] -Message "Path parameter '$name' is not in the path template '$Path'; it is ignored."
        }
    }
    $reportedPlaceholders = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::Ordinal)
    foreach ($name in $placeholders) {
        if ($pathParameters -cnotcontains ('path|' + $name) -and $reportedPlaceholders.Add($name)) {
            Add-OpenApiFinding -Context $Context -Severity Error -Code 'OA024' -Pointer $PathItemPointer -Message "The path template '$Path' has the placeholder '{$name}' but no path parameter '$name'; the command cannot fill it."
        }
    }

    $summary = $null
    if ($null -ne $Operation['summary']) {
        $summary = [string]$Operation['summary']
    }
    $description = $null
    if ($null -ne $Operation['description']) {
        $description = [string]$Operation['description']
    }

    $result = [pscustomobject]@{
        PSTypeName      = 'Tcs.OpenApi.Operation'
        OperationId     = $OperationId
        Method          = $Method.ToUpperInvariant()
        Path            = $Path
        Tags            = $tags.ToArray()
        Summary         = $summary
        Description     = $description
        Deprecated      = $deprecated
        ExternalDocsUrl = $externalDocsUrl
        Parameters      = @($parameters.Values)
        RequestBody     = $requestBody
        Responses       = $responses
        Security        = $security
        Paging          = Get-OpenApiPaging -Operation $Operation -Responses $responses -Parameters @($parameters.Values)
        Extensions      = $extensions
        Unsupported     = $unsupported
    }
    $Context.CurrentOperation = $null
    return $result
}