private/Invoke-SherwebRequest.ps1

Function Invoke-SherwebRequest {
<#
    .SYNOPSIS
        Sends API requests to Sherweb endpoints with automatic authentication token management and rate limiting handling.
 
    .DESCRIPTION
        This function sends HTTP requests to Sherweb API endpoints, managing authentication tokens automatically.
        It handles rate limiting with exponential backoff retry logic and automatically refreshes expired tokens.
     
    .PARAMETER FilterQuery
        Optional query string to filter the API results. Do not include the leading '?' character.
 
    .PARAMETER API
        Mandatory parameter to specify which Sherweb API to use. Valid values are 'ServiceProvider' or 'Distributor'.
 
    .PARAMETER GatewayBaseURL
        Base URL of the Sherweb API gateway. Defaults to 'https://api.sherweb.com'.
 
    .PARAMETER Endpoint
        Mandatory parameter specifying the API endpoint to call (without the base URL).
 
    .PARAMETER MaxRetries
        Maximum number of retry attempts when rate limited. Defaults to 3.
 
    .PARAMETER InitialRetryDelaySeconds
        Initial delay in seconds before the first retry attempt. Defaults to 3 seconds.
        Actual delay will increase exponentially with each retry.
 
    .PARAMETER Method
        Mandatory HTTP method to use for the request. Valid values are 'GET', 'POST', 'PATCH', or 'DELTE'.
 
    .PARAMETER Body
        Optional JSON body to include with the request for POST, PATCH operations.
 
    .EXAMPLE
        Invoke-SherwebRequest -API ServiceProvider -Endpoint 'customers' -Method GET
     
        Retrieves all customers using the Service Provider API.
 
    .EXAMPLE
        Invoke-SherwebRequest -API ServiceProvider -Endpoint 'billing/subscriptions' -Method GET -FilterQuery 'customerId=c4c56db-03fe-4564-a5b9-173453453'
     
        Gets the subscriptions for a customer with id of 'c4c56db-03fe-4564-a5b9-173453453' using the Service Provider API.
 
    .NOTES
        Before using this function, you must authenticate using Connect-Sherweb.
        The function will automatically refresh the token if it has expired.
 
     .LINK
        https://developers.sherweb.com/
 
#>

    [OutputType([PSCustomObject[]], [PSCustomObject], [Void])]
    [CmdletBinding()]
    Param(

        [Parameter(Mandatory)]
        [ValidateSet("ServiceProvider", "Distributor")]
        [string]$API,

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

        [Parameter(Mandatory)]
        [ValidateSet("GET", "POST", "PATCH", "DELETE")]
        [string]$Method,

        [Parameter()]
        [ValidatePattern('^https?://[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}/?.*$')]
        [string]$GatewayBaseURL = 'https://api.sherweb.com',

        [Parameter()]
        [int]$MaxRetries = 3,

        [Parameter()]
        [ValidatePattern('^[^?].*|^$')]
        [string]$FilterQuery,

        [Parameter()]
        [int]$InitialRetryDelaySeconds = 3,

        [Parameter()]
        [string]$Body
    )

    Begin {
        if ($null -eq $script:SherwebAccessToken){
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.ArgumentException]::new('No access token found. Run Connect-Sherweb first.'),
                    'NoAccessTokenFound',
                    [System.Management.Automation.ErrorCategory]::AuthenticationError,
                    $null
                )
            )
        }
        elseif ([DateTime]::Now -gt $script:SherwebAccessToken.Expiration) {
            $connectSplat = @{
                ClientId     = $script:SherwebAccessToken.ClientId
                ClientSecret = (Convert-SecureStringToPlainText -SecureString $script:SherwebAccessToken.ClientSecret)
                Scope        = $script:SherwebAccessToken.Scope
            }
            Connect-Sherweb @connectSplat
        }
        Write-Verbose -Message  "Beginning Invoke-SherwebRequest Process"

        # Remove leading slash from endpoint if present
        Write-Verbose -Message  "Removing leading slash from endpoint if present"
        $Endpoint = $Endpoint.TrimStart('/')

        $Scope = switch ($API){
            "ServiceProvider" { "service-provider" }
            "Distributor" { "distributor" }
        }

        # Build base URL
        Write-Verbose -Message  "Building base URL"
        $Uri = "$GatewayBaseURL/$Scope/v1/$Endpoint"
        Write-Verbose -Message  "Base URL: $Uri"

        # Add filter query if present
        if ($FilterQuery) {
            Write-Verbose -Message  "Filter Query: $FilterQuery"
            $Uri = "$Uri`?$FilterQuery"
            Write-Verbose -Message  "URL with Filter Query: $Uri"
        }

        $InvokeRestMethodParams = @{
            Headers     = @{
                'Ocp-Apim-Subscription-Key' = (Convert-SecureStringToPlainText -SecureString $script:SherwebAccessToken.GatewaySubscriptionKey)
                'Authorization'             = "Bearer $($script:SherwebAccessToken.AccessToken)"
                'Content-Type'              = 'application/json'
            }
            Method      = $Method
            URI         = $uri
            ErrorAction = "Stop"  # Changed to Stop to ensure we catch errors
        }
        if ($Null -ne $Body){
            $InvokeRestMethodParams.Body = $Body
        }
    }

    Process {
        Write-Verbose -Message  "Performing REST method invocation"
        [int]$retryCount = 0
        [bool]$success = $false

        do {
            try {
                $response = Invoke-RestMethod @InvokeRestMethodParams
                $success = $true
            }
            catch {
                $errorMessage = $_.Exception.Message
                $statusCode = $_.Exception.Response.StatusCode
                $errorResponse = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue

                if ($statusCode -eq 429) {
                    $retryCount++
                    
                    # Extract retry delay from error message if available
                    $retryDelay = $InitialRetryDelaySeconds
                    if ($errorResponse.detail -match "Try again in (\d+) seconds") {
                        $retryDelay = [int]$matches[1]
                    }

                    # Apply exponential backoff
                    $waitTime = $retryDelay * [Math]::Pow(2, ($retryCount - 1))
                    
                    if ($retryCount -le $MaxRetries) {
                        Write-Warning "Rate limit exceeded. Waiting $waitTime seconds before retry $retryCount of $MaxRetries..."
                        Start-Sleep -Seconds $waitTime
                        continue
                    }
                }
                else {
                    if ($errorResponse.detail){
                        $errorMessage = $errorResponse.detail
                    }
                    throw "Error Code ${statusCode}: $errorMessage"
                }        
            }
        } while (-not $success -and $retryCount -le $MaxRetries)
    }

    End {
        $response
    }
}