Private/CP/Invoke-CPRestMethod.ps1

class CPException : System.Exception {
    CPException([string]$message) : base($message) { }
    CPException([string]$message, [System.Exception]$innerException) : base($message, $innerException) { }
}

function Format-CPErrorMessage {
    [CmdletBinding()]
    param(
        [String]$ResponseBody,

        [Nullable[int]]$StatusCode
    )

    if ([string]::IsNullOrWhiteSpace($ResponseBody)) {
        if ($StatusCode) {
            return "Catchpoint API request failed with HTTP status $StatusCode."
        }

        return 'Catchpoint API request failed.'
    }

    try {
        $parsed = $ResponseBody | ConvertFrom-Json -ErrorAction Stop
        $traceId = $null
        if ($null -ne $parsed.PSObject.Properties['traceId'] -and -not [string]::IsNullOrWhiteSpace([string]$parsed.traceId)) {
            $traceId = [string]$parsed.traceId
        }

        $errorMessages = @()
        if ($parsed.errors) {
            $errorMessages = @(
                $parsed.errors | ForEach-Object {
                    if ($null -ne $_.PSObject.Properties['message'] -and -not [string]::IsNullOrWhiteSpace([string]$_.message)) {
                        [string]$_.message
                    }
                    else {
                        [string]$_
                    }
                }
            )
        }

        $message = $null
        if ($null -ne $parsed.PSObject.Properties['message'] -and -not [string]::IsNullOrWhiteSpace([string]$parsed.message)) {
            $message = [string]$parsed.message
        }

        $code = if ($StatusCode) { $StatusCode } else { 'Error' }
        $formattedMessage = $null

        if ($errorMessages.Count -gt 0) {
            $details = $errorMessages -join '; '
            if ($message) {
                $formattedMessage = "${code}: ${message} [$details]"
            }
            else {
                $formattedMessage = "${code}: $details"
            }
        }
        elseif ($message) {
            $formattedMessage = "${code}: ${message}"
        }

        if ($formattedMessage) {
            if ($traceId) {
                $formattedMessage = "$formattedMessage (trace id: $traceId)"
            }

            return $formattedMessage
        }
    }
    catch {
        return $ResponseBody
    }

    return $ResponseBody
}

function Resolve-CPException {
    [CmdletBinding()]
    param(
        [Nullable[int]]$StatusCode,

        [String]$ResponseBody
    )

    $message = Format-CPErrorMessage -ResponseBody $ResponseBody -StatusCode $StatusCode
    $result = [PSCustomObject]@{
        Message     = $message
        ErrorType   = 'ClientError'
        ErrorId     = 'CP.ClientError'
        Category    = [System.Management.Automation.ErrorCategory]::InvalidOperation
        ShouldRetry = $false
    }

    switch ($StatusCode) {
        400 {
            $result.ErrorType = 'ClientError'
            $result.ErrorId = 'CP.ClientError'
            $result.Category = [System.Management.Automation.ErrorCategory]::InvalidArgument
        }
        401 {
            $result.ErrorType = 'AuthenticationError'
            $result.ErrorId = 'CP.AuthenticationError'
            $result.Category = [System.Management.Automation.ErrorCategory]::AuthenticationError
            if ($message -notmatch 'Connect-CPAccount') {
                $result.Message = "$message Verify your BearerToken with Connect-CPAccount."
            }
        }
        403 {
            $result.ErrorType = 'AuthorizationError'
            $result.ErrorId = 'CP.AuthorizationError'
            $result.Category = [System.Management.Automation.ErrorCategory]::PermissionDenied
        }
        404 {
            $result.ErrorType = 'NotFoundError'
            $result.ErrorId = 'CP.NotFoundError'
            $result.Category = [System.Management.Automation.ErrorCategory]::ObjectNotFound
        }
        405 {
            $result.ErrorType = 'ClientError'
            $result.ErrorId = 'CP.MethodNotAllowed'
            $result.Category = [System.Management.Automation.ErrorCategory]::InvalidOperation
        }
        429 {
            $result.ErrorType = 'RateLimitError'
            $result.ErrorId = 'CP.RateLimitError'
            $result.Category = [System.Management.Automation.ErrorCategory]::LimitsExceeded
            $result.ShouldRetry = $true
        }
        { $_ -ge 500 } {
            $result.ErrorType = 'ServerError'
            $result.ErrorId = 'CP.ServerError'
            $result.Category = [System.Management.Automation.ErrorCategory]::ResourceUnavailable
            $result.ShouldRetry = $true
        }
    }

    return $result
}

function Get-CPHttpErrorDetails {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $statusCode = $null
    $responseBody = $null

    if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
        $responseBody = $ErrorRecord.ErrorDetails.Message
    }

    $response = $ErrorRecord.Exception.Response
    if ($response) {
        if ($response -is [System.Net.Http.HttpResponseMessage]) {
            $statusCode = [int]$response.StatusCode
            if (-not $responseBody -and $response.Content) {
                $responseBody = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
            }
        }
        elseif ($response -is [System.Net.HttpWebResponse]) {
            $statusCode = [int]$response.StatusCode
            if (-not $responseBody) {
                $reader = [System.IO.StreamReader]::new($response.GetResponseStream())
                try {
                    $responseBody = $reader.ReadToEnd()
                }
                finally {
                    $reader.Dispose()
                }
            }
        }
        else {
            try {
                $statusCode = [int]$response.StatusCode
            }
            catch {
                $statusCode = $null
            }
        }
    }

    if (-not $responseBody) {
        $responseBody = $ErrorRecord.Exception.Message
    }

    return [PSCustomObject]@{
        StatusCode = $statusCode
        Body       = $responseBody
    }
}

function New-CPErrorRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $ResolvedError,

        [Parameter(Mandatory)]
        [String]$Uri
    )

    return [System.Management.Automation.ErrorRecord]::new(
        [CPException]::new($ResolvedError.Message),
        $ResolvedError.ErrorId,
        $ResolvedError.Category,
        $Uri
    )
}

function Invoke-CPRestMethod {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Auth', Justification = 'Reserved for request helpers that share the Catchpoint auth object')]
    param(
        [Parameter(Mandatory)]
        [String]$Uri,

        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
        [String]$Method,

        [Parameter(Mandatory)]
        [Hashtable]$Headers,

        [Parameter(Mandatory)]
        [PSCustomObject]$Auth,

        [String]$Body,

        [ValidateRange(1, 10)]
        [Int]$MaxRetries = 3,

        [Switch]$EnableDebugLogging,

        [System.Management.Automation.PSCmdlet]$CallerPSCmdlet
    )

    $headers = @{} + $Headers
    if ($headers.ContainsKey('__CPMethod')) {
        $headers.Remove('__CPMethod') | Out-Null
    }

    $retryBackoff = 5
    $attempt = 0
    $lastResolvedError = $null

    while ($attempt -lt $MaxRetries) {
        $attempt++
        try {
            $params = @{
                Uri         = $Uri
                Method      = $Method
                Headers     = $headers
                TimeoutSec  = 30
                ErrorAction = 'Stop'
            }

            if ($Body) {
                $params.Body = $Body
            }

            if ($EnableDebugLogging) {
                Write-Debug "Attempt ${attempt}: $Method $Uri"
                if ($Body -and $Body.Length -lt 1000) {
                    Write-Debug "Request Body: $Body"
                }
            }

            $response = Invoke-RestMethod @params

            if ($EnableDebugLogging) {
                Write-Debug "Request successful on attempt $attempt"
            }

            return $response
        }
        catch {
            if ($_.Exception -is [CPException] -or $_.FullyQualifiedErrorId -like 'CP.*') {
                throw $_
            }

            $errorDetails = Get-CPHttpErrorDetails -ErrorRecord $_
            $resolvedError = Resolve-CPException -StatusCode $errorDetails.StatusCode -ResponseBody $errorDetails.Body
            $lastResolvedError = $resolvedError

            if ($resolvedError.ShouldRetry -and $attempt -lt $MaxRetries) {
                if ($EnableDebugLogging -or $VerbosePreference -ne 'SilentlyContinue') {
                    Write-Verbose "Catchpoint API request failed ($($resolvedError.ErrorType)), retrying (attempt $attempt of $MaxRetries)"
                }

                Start-Sleep -Seconds ($retryBackoff * $attempt)
                continue
            }

            if ($errorDetails.StatusCode -ge 400 -and $errorDetails.StatusCode -lt 500 -and $errorDetails.StatusCode -ne 429) {
                $errorRecord = New-CPErrorRecord -ResolvedError $resolvedError -Uri $Uri
                if ($CallerPSCmdlet) {
                    $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
                }

                throw $errorRecord
            }

            if ($attempt -ge $MaxRetries) {
                break
            }

            if ($EnableDebugLogging -or $VerbosePreference -ne 'SilentlyContinue') {
                Write-Verbose "Catchpoint API request failed ($($resolvedError.ErrorType)), retrying (attempt $attempt of $MaxRetries)"
            }

            Start-Sleep -Seconds ($retryBackoff * $attempt)
        }
    }

    $finalResolvedError = if ($lastResolvedError) {
        $lastResolvedError
    }
    else {
        Resolve-CPException -StatusCode $null -ResponseBody 'Maximum Catchpoint API retry attempts exhausted.'
    }

    $finalError = New-CPErrorRecord -ResolvedError $finalResolvedError -Uri $Uri

    if ($CallerPSCmdlet) {
        $CallerPSCmdlet.ThrowTerminatingError($finalError)
    }

    throw $finalError
}