Private/Invoke-InforcerApiRequest.ps1

function Invoke-InforcerApiRequest {
    <#
    .SYNOPSIS
        Sends a request to the Inforcer REST API (Private helper).
    .DESCRIPTION
        Uses the current session (Inf-Api-Key, BaseUrl), unwraps response.data,
        and returns PSObjects or JSON string. All JSON serialization uses -Depth 100.

        Recognizes three error envelope shapes used by the Inforcer API:
          * App-layer: { success, message, errors[], errorCode }
          * APIM gateway: { statusCode, message } (e.g. 404 on unsupported path)
          * RFC 9110: { type, title, status, traceId } (e.g. 415 Unsupported Media Type)

        The x-correlation-id response header is logged to the verbose stream on every
        request and included in error messages when present. Surface it to the API team
        when reporting issues.
    .PARAMETER Endpoint
        API path (e.g. /beta/tenants). Leading slash optional.
    .PARAMETER Method
        HTTP method. Default: GET.
    .PARAMETER Body
        Optional JSON body for POST/PUT.
    .PARAMETER OutputType
        PowerShellObject (return PSObjects) or JsonObject (return JSON string). Default: PowerShellObject.
    .PARAMETER PreserveStructure
        When set, skips the automatic array-unwrapping step. The .data wrapper is still
        unwrapped, but inner structure (e.g. items + continuationToken inside .data) is preserved.
        Use this when the caller needs pagination metadata that lives inside the .data object.
    .PARAMETER PreserveFullResponse
        When set, returns the full parsed API response without any unwrapping. Neither
        .data nor arrays are unwrapped. Use this when pagination metadata (e.g. continuationToken)
        lives at the response root level alongside .data, not inside it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Endpoint,

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

        [Parameter(Mandatory = $false)]
        [string]$Body,

        [Parameter(Mandatory = $false)]
        [ValidateSet('PowerShellObject', 'JsonObject')]
        [string]$OutputType = 'PowerShellObject',

        [Parameter(Mandatory = $false)]
        [switch]$PreserveStructure,

        [Parameter(Mandatory = $false)]
        [switch]$PreserveFullResponse
    )

    if (-not (Test-InforcerSession)) {
        Write-Error -Message 'Not connected. Run Connect-Inforcer first.' `
            -ErrorId 'NotConnected' `
            -Category ConnectionError
        return
    }

    $endpoint = $Endpoint.Trim()
    if (-not $endpoint.StartsWith('/')) {
        $endpoint = '/' + $endpoint
    }

    $uri = $script:InforcerSession.BaseUrl + $endpoint

    $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey

    if ([string]::IsNullOrWhiteSpace($apiKey)) {
        Write-Error -Message 'API key is empty or invalid. Please reconnect.' `
            -ErrorId 'EmptyApiKey' `
            -Category AuthenticationError
        return
    }

    Write-Verbose "Making $Method request to: $uri"

    $headers = @{
        'Inf-Api-Key'    = $apiKey
        'Accept'         = 'application/json'
        'Content-Type'   = 'application/json'
    }

    $responseHeaders = $null
    $params = @{
        Uri                     = $uri
        Method                  = $Method
        Headers                 = $headers
        ResponseHeadersVariable = 'responseHeaders'
    }
    if (-not [string]::IsNullOrWhiteSpace($Body)) {
        $params['Body'] = $Body
    }

    try {
        $rawResponse = Invoke-RestMethod @params
        $correlationId = Get-InforcerHeaderValue -Headers $responseHeaders -Name 'x-correlation-id'
        if ($correlationId) {
            Write-Verbose "x-correlation-id: $correlationId"
        }
    } catch {
        $statusCode = 0
        $detail = $_.Exception.Message
        $correlationId = $null

        if ($_.Exception.Response) {
            $statusCode = [int]$_.Exception.Response.StatusCode
            $correlationId = Get-InforcerHeaderValue -Headers $_.Exception.Response.Headers -Name 'x-correlation-id'
        }

        # Response body: PS7 surfaces it via ErrorDetails.Message; PS5.1 needs a stream read.
        $responseBody = $null
        if ($_.ErrorDetails.Message) {
            $responseBody = $_.ErrorDetails.Message
        } elseif ($_.Exception.Response) {
            $reader = $null
            try {
                $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
                $responseBody = $reader.ReadToEnd()
            } catch { } finally {
                if ($reader) { $reader.Dispose() }
            }
        }

        if (-not [string]::IsNullOrWhiteSpace($responseBody)) {
            $detail = $responseBody
            try {
                $json = $responseBody | ConvertFrom-Json -ErrorAction SilentlyContinue
            } catch {
                $json = $null
            }

            if ($json) {
                # Identify envelope shape and pull out a useful message + errorCode + status.
                $apiMessage = $null
                $errorCode  = $null
                $traceId    = $null

                if ($null -ne $json.PSObject.Properties['errorCode'] -or $null -ne $json.PSObject.Properties['errors'] -or $null -ne $json.PSObject.Properties['success']) {
                    # Shape A: app-layer envelope { success, message, errors[], errorCode }
                    $errorCode  = ($json.PSObject.Properties['errorCode'].Value -as [string])
                    $apiMessage = ($json.PSObject.Properties['message'].Value -as [string])

                    # Surface structured field-level errors. The top-level message is often a
                    # generic placeholder ("Validation failed, see errors for details") — the
                    # useful information lives in the errors[] array.
                    $errorsProp = $json.PSObject.Properties['errors']
                    if ($errorsProp -and $errorsProp.Value) {
                        $errorsJoined = Format-InforcerErrorDetail -Errors $errorsProp.Value
                        if (-not [string]::IsNullOrWhiteSpace($errorsJoined)) {
                            if ([string]::IsNullOrWhiteSpace($apiMessage)) {
                                $apiMessage = $errorsJoined
                            } elseif ($apiMessage -notlike "*$errorsJoined*") {
                                $apiMessage = "$apiMessage — $errorsJoined"
                            }
                        }
                    }
                } elseif ($null -ne $json.PSObject.Properties['statusCode'] -and $null -ne $json.PSObject.Properties['message']) {
                    # Shape B: APIM gateway { statusCode, message } — e.g. 404 on unsupported method/path
                    if ($statusCode -le 0) { $statusCode = [int]$json.statusCode }
                    $apiMessage = ($json.PSObject.Properties['message'].Value -as [string])
                } elseif ($null -ne $json.PSObject.Properties['title'] -and ($null -ne $json.PSObject.Properties['status'] -or $null -ne $json.PSObject.Properties['type'])) {
                    # Shape C: RFC 9110 ProblemDetails { type, title, status, traceId } — e.g. 415
                    if ($statusCode -le 0 -and $null -ne $json.PSObject.Properties['status']) {
                        $statusCode = [int]$json.status
                    }
                    $apiMessage = ($json.PSObject.Properties['title'].Value -as [string])
                    $traceId    = ($json.PSObject.Properties['traceId'].Value -as [string])
                }

                $detail = switch ($true) {
                    ($statusCode -eq 429 -or ($apiMessage -and $apiMessage -match 'quota|rate.?limit|throttl')) {
                        if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { "API rate limit: $apiMessage" } else { 'API rate limit exceeded. Please wait and try again.' }
                    }
                    ($errorCode -match '^forbidden$') {
                        if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { "You don't have permission to access this tenant or resource." }
                    }
                    ($errorCode -match 'notfound|not_found') {
                        if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { 'Tenant or resource not found.' }
                    }
                    default {
                        if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage }
                        elseif ($json.PSObject.Properties['error']) { $json.error -as [string] }
                        else { $detail }
                    }
                }

                if (-not [string]::IsNullOrWhiteSpace($traceId)) {
                    $detail = "$detail (traceId: $traceId)"
                }
            }
        }

        $detail = Protect-InforcerApiKeyInText -Text $detail -ApiKey $apiKey
        $msg = if ($statusCode -gt 0) { "Inforcer API request failed (HTTP $statusCode): $detail" } else { "Inforcer API request failed: $detail" }
        if ($correlationId) {
            $msg = "$msg [correlation-id: $correlationId]"
        }
        $errorId = if ($statusCode -gt 0) { "ApiRequestFailed_$statusCode" } else { 'ApiRequestFailed' }
        Write-Error -Message $msg -ErrorId $errorId -Category ConnectionError
        return
    }

    if ($null -eq $rawResponse) {
        Write-Error -Message 'API returned an empty response.' -ErrorId 'EmptyResponse' -Category InvalidData
        return
    }

    # Invoke-RestMethod auto-parses JSON to PSObject. If the response is a string, the
    # API may have returned a non-JSON Content-Type (e.g. text/plain) despite the body
    # being valid JSON. Try to parse it before reporting as non-JSON.
    if ($rawResponse -is [string]) {
        try {
            $rawResponse = $rawResponse | ConvertFrom-Json -Depth 100 -ErrorAction Stop
        } catch {
            $preview = $rawResponse
            if ($preview.Length -gt 200) { $preview = $preview.Substring(0, 200) + '...' }
            Write-Error -Message "API returned non-JSON response. Base URL may be incorrect. Response starts with: $preview" `
                -ErrorId 'NonJsonResponse' -Category InvalidData
            return
        }
    }
    if ($rawResponse -isnot [PSObject] -and $rawResponse -isnot [array]) {
        $preview = $rawResponse.ToString()
        if ($preview.Length -gt 200) { $preview = $preview.Substring(0, 200) + '...' }
        Write-Error -Message "API returned non-JSON response. Base URL may be incorrect. Response starts with: $preview" `
            -ErrorId 'NonJsonResponse' -Category InvalidData
        return
    }

    # API error payload (success: false) — single, clean message; no stack or raw JSON
    $successValue = $rawResponse.PSObject.Properties['success'].Value
    if ($null -ne $rawResponse.PSObject.Properties['success'] -and $successValue -eq $false) {
        $apiMessage = ($rawResponse.PSObject.Properties['message'].Value -as [string])
        if ([string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage = 'Request failed.' }
        $apiMessage = $apiMessage.Trim()
        $errorCode = ($rawResponse.PSObject.Properties['errorCode'].Value -as [string])
        if ([string]::IsNullOrWhiteSpace($errorCode)) { $errorCode = '' }

        $friendlyMessage = switch -Regex ($errorCode) {
            '^forbidden$'       { "You don't have permission to access this tenant or resource." }
            'notfound|not_found' { "Tenant or resource not found." }
            default             { $apiMessage }
        }
        if ($rawResponse.errors -is [array] -and $rawResponse.errors.Count -gt 0) {
            $extra = ($rawResponse.errors | ForEach-Object { $_.ToString() }) -join '; '
            if (-not [string]::IsNullOrWhiteSpace($extra)) { $friendlyMessage += " $extra" }
        }
        Write-Error -Message $friendlyMessage -ErrorId 'ApiError' -Category InvalidOperation
        return
    }

    if ($PreserveFullResponse) {
        return $rawResponse
    }

    $data = $rawResponse
    $dataProp = $rawResponse.PSObject.Properties['data']
    if ($dataProp -and $null -ne $dataProp.Value) {
        $data = $dataProp.Value
    }

    if ($OutputType -eq 'JsonObject') {
        $json = $data | ConvertTo-Json -Depth 100
        return $json
    }

    if ($PreserveStructure) {
        return $data
    }

    # If data is a single object with one array property (e.g. value, items, policies), unwrap to that array
    if ($data -isnot [array] -and $data -is [PSObject]) {
        $arrayProp = $data.PSObject.Properties | Where-Object { $_.Value -is [array] } | Select-Object -First 1
        if ($arrayProp) {
            $data = $arrayProp.Value
        }
    }

    $data
}