Private/Invoke-InfisicalApi.ps1

# Invoke-InfisicalApi.ps1
# Core HTTP wrapper for all Infisical API calls. Handles authentication headers,
# error classification, rate-limit retries, and verbose logging.
# Called by: All public functions (via Get-InfisicalSession → caller → this function)
# Dependencies: InfisicalSession class

function Invoke-InfisicalApi {
    [CmdletBinding()]
    [OutputType([PSObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')]
        [string] $Method,

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

        [Parameter()]
        [hashtable] $Body,

        [Parameter()]
        [hashtable] $QueryParameters,

        [Parameter(Mandatory)]
        [InfisicalSession] $Session
    )

    # Build full URI
    $baseUri = $Session.ApiUrl.TrimEnd('/')
    $uri = "$baseUri$Endpoint"

    # Append query parameters
    if ($QueryParameters -and $QueryParameters.Count -gt 0) {
        $queryParts = [System.Collections.Generic.List[string]]::new()
        foreach ($key in $QueryParameters.Keys) {
            $encodedKey = [System.Uri]::EscapeDataString($key)
            $encodedValue = [System.Uri]::EscapeDataString([string]$QueryParameters[$key])
            $queryParts.Add("$encodedKey=$encodedValue")
        }
        $uri = "$uri`?$($queryParts -join '&')"
    }

    # Build headers — never log the token value
    $tokenPlainText = $Session.GetAccessTokenPlainText()
    $headers = @{
        'Authorization' = "Bearer $tokenPlainText"
        'Content-Type'  = 'application/json'
        'Accept'        = 'application/json'
    }

    # Build invoke parameters
    $invokeParams = @{
        Uri         = $uri
        Method      = $Method
        Headers     = $headers
        TimeoutSec  = 30
        ErrorAction = 'Stop'
    }

    if ($Method -in @('POST', 'PATCH', 'DELETE')) {
        if ($Body -and $Body.Count -gt 0) {
            $invokeParams['Body'] = ($Body | ConvertTo-Json -Depth 10 -Compress)
        }
        else {
            # Some endpoints require a JSON body even when empty
            $invokeParams['Body'] = '{}'
        }
        $invokeParams['ContentType'] = 'application/json'
    }

    # API version fallback: all public functions use v4 conventions.
    # If the server only supports v3, transparently rewrite endpoints and params.
    $needsV3Fallback = $false
    if ($Endpoint -match '^/api/v4/secrets' -and
        $null -ne $Session.ApiCapabilities -and
        $Session.ApiCapabilities.Count -gt 0 -and
        -not $Session.ApiCapabilities['SecretsV4'] -and
        $Session.ApiCapabilities['SecretsV3']) {

        $needsV3Fallback = $true

        # Rewrite endpoint: /api/v4/secrets → /api/v3/secrets/raw, /api/v4/secrets/bulk → /api/v3/secrets/bulk
        if ($Endpoint -match '^/api/v4/secrets/bulk') {
            $Endpoint = $Endpoint -replace '^/api/v4/', '/api/v3/'
        }
        else {
            $Endpoint = $Endpoint -replace '^/api/v4/secrets', '/api/v3/secrets/raw'
        }

        # Swap projectId → workspaceId in query parameters
        if ($QueryParameters -and $QueryParameters.ContainsKey('projectId')) {
            $QueryParameters['workspaceId'] = $QueryParameters['projectId']
            $QueryParameters.Remove('projectId')
        }

        # Swap projectId → workspaceId in body
        if ($Body -and $Body.ContainsKey('projectId')) {
            $Body['workspaceId'] = $Body['projectId']
            $Body.Remove('projectId')
        }

        # Rebuild URI and body with rewritten values
        $uri = "$baseUri$Endpoint"
        if ($QueryParameters -and $QueryParameters.Count -gt 0) {
            $queryParts = [System.Collections.Generic.List[string]]::new()
            foreach ($key in $QueryParameters.Keys) {
                $encodedKey = [System.Uri]::EscapeDataString($key)
                $encodedValue = [System.Uri]::EscapeDataString([string]$QueryParameters[$key])
                $queryParts.Add("$encodedKey=$encodedValue")
            }
            $uri = "$uri`?$($queryParts -join '&')"
        }
        $invokeParams['Uri'] = $uri

        if ($Method -in @('POST', 'PATCH', 'DELETE') -and $Body -and $Body.Count -gt 0) {
            $invokeParams['Body'] = ($Body | ConvertTo-Json -Depth 10 -Compress)
        }

        Write-Verbose "Invoke-InfisicalApi: v3 fallback — rewritten endpoint to $Endpoint"
    }

    # Check API version compatibility — throws if neither v4 nor v3 is available
    if (-not $needsV3Fallback) {
        Assert-InfisicalApiVersion -Endpoint $Endpoint -Session $Session
    }

    # Verbose logging — endpoint and method only, never secrets or tokens
    Write-Verbose "Invoke-InfisicalApi: $Method $Endpoint"

    # Retry logic for 429 (rate limit) with exponential backoff
    $maxRetries = 3
    $attempt = 0

    while ($true) {
        $attempt++
        try {
            $response = Invoke-RestMethod @invokeParams
            Write-Verbose "Invoke-InfisicalApi: Response received successfully"
            return $response
        }
        catch {
            $statusCode = $null
            $responseBody = $null

            # Extract error details defensively — $_ may be an ErrorRecord
            # or a raw exception depending on how the error was thrown.
            $caughtException = if ($_ -is [System.Management.Automation.ErrorRecord]) {
                $_.Exception
            }
            else {
                $_
            }
            $caughtErrorDetails = if ($_ -is [System.Management.Automation.ErrorRecord]) {
                $_.ErrorDetails
            }
            else {
                $null
            }

            if ($caughtException -is [System.Net.WebException] -and $null -ne $caughtException.Response) {
                # Windows PowerShell 5.1 path
                $webResponse = $caughtException.Response
                $statusCode = [int]$webResponse.StatusCode
                try {
                    $stream = $webResponse.GetResponseStream()
                    if ($null -ne $stream) {
                        $reader = [System.IO.StreamReader]::new($stream)
                        $responseBody = $reader.ReadToEnd()
                        $reader.Dispose()
                    }
                }
                finally {
                    $webResponse.Dispose()
                }
            }
            elseif ($null -ne $caughtException -and $caughtException.GetType().Name -eq 'HttpResponseException') {
                # PowerShell 7+ path — use name check to avoid type load failures on PS 5.1
                $statusCode = [int]$caughtException.Response.StatusCode
                if ($null -ne $caughtErrorDetails) {
                    $responseBody = $caughtErrorDetails.Message
                }
            }
            elseif ($null -ne $caughtErrorDetails -and $caughtErrorDetails.Message) {
                $responseBody = $caughtErrorDetails.Message
                # Try to extract status code from the exception's Response property
                if ($null -ne $caughtException -and $null -ne $caughtException.PSObject.Properties['Response'] -and $null -ne $caughtException.Response) {
                    $statusCode = [int]$caughtException.Response.StatusCode
                }
            }

            Write-Verbose "Invoke-InfisicalApi: Error response — Status: $statusCode"

            switch ($statusCode) {
                401 {
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [System.UnauthorizedAccessException]::new(
                            'Authentication failed or token expired. Run Connect-Infisical to re-authenticate.'
                        ),
                        'InfisicalAuthenticationFailed',
                        [System.Management.Automation.ErrorCategory]::AuthenticationError,
                        $Endpoint
                    )
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
                403 {
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [System.UnauthorizedAccessException]::new(
                            'Access denied. Check machine identity permissions for this resource.'
                        ),
                        'InfisicalAccessDenied',
                        [System.Management.Automation.ErrorCategory]::PermissionDenied,
                        $Endpoint
                    )
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
                404 {
                    return $null
                }
                429 {
                    if ($attempt -lt $maxRetries) {
                        $backoffSeconds = [math]::Pow(2, $attempt)
                        Write-Verbose "Invoke-InfisicalApi: Rate limited (429). Retrying in $backoffSeconds seconds (attempt $attempt of $maxRetries)."
                        Start-Sleep -Seconds $backoffSeconds
                        continue
                    }
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [System.InvalidOperationException]::new(
                            "Rate limit exceeded after $maxRetries retry attempts. Try again later."
                        ),
                        'InfisicalRateLimitExceeded',
                        [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
                        $Endpoint
                    )
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
                { $_ -ge 500 } {
                    # Parse response body for message, but never include raw body
                    # that might contain secrets
                    $errorMsg = "Infisical API returned server error (HTTP $statusCode)."
                    if ($responseBody) {
                        try {
                            $parsed = $responseBody | ConvertFrom-Json
                            if ($parsed.message) {
                                $errorMsg = "Infisical API error (HTTP $statusCode): $($parsed.message)"
                            }
                        }
                        catch {
                            # Could not parse JSON; use generic message
                            $null = $_
                        }
                    }
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [System.InvalidOperationException]::new($errorMsg),
                        'InfisicalServerError',
                        [System.Management.Automation.ErrorCategory]::ConnectionError,
                        $Endpoint
                    )
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
                default {
                    # Network or unknown errors
                    if ($null -eq $statusCode) {
                        $exceptionMsg = if ($null -ne $caughtException) { $caughtException.Message } else { 'Unknown error' }
                        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                            [System.Net.WebException]::new(
                                "Unable to connect to Infisical API at $($Session.ApiUrl). Check your ApiUrl and network connectivity. Details: $exceptionMsg"
                            ),
                            'InfisicalConnectionError',
                            [System.Management.Automation.ErrorCategory]::ConnectionError,
                            $uri
                        )
                        $PSCmdlet.ThrowTerminatingError($errorRecord)
                    }
                    # Unhandled status codes (e.g. 409 Conflict) — try to surface API message
                    $errorMsg = "Infisical API returned HTTP $statusCode."
                    if ($responseBody) {
                        try {
                            $parsed = $responseBody | ConvertFrom-Json
                            if ($parsed.message) {
                                $errorMsg = "Infisical API error (HTTP $statusCode): $($parsed.message)"
                            }
                        }
                        catch {
                            # Could not parse JSON; use generic message
                            $null = $_
                        }
                    }
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [System.InvalidOperationException]::new($errorMsg),
                        'InfisicalApiError',
                        [System.Management.Automation.ErrorCategory]::InvalidResult,
                        $Endpoint
                    )
                    $PSCmdlet.ThrowTerminatingError($errorRecord)
                }
            }
        }
    }
}