functions/Invoke-Method.ps1


function Invoke-Method {
    <#
    .SYNOPSIS
        Makes a call to a Zendesk Api Endpoint
    .DESCRIPTION
        Makes a call to a Zendesk Api Endpoint
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json'

        Calls the List Tickets endpoint
    .EXAMPLE
        PS C:\> Invoke-Method -Method 'Post' -path '/api/v2/tickets.json' -Body $ticket

        Calls the Create Ticket endpoint. `$ticket` is a hashtable or PSCustomObject that is automatically converted to json.
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json' -Pagination $false

        Calls the List Tickets endpoint without pagination.
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json' -SortBy 'id'

        Calls the List Tickets endpoint sorted by id.
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json' -SortBy $null

        Calls the List Tickets endpoint without explicit sorting.
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json' -Retry $false

        Calls the List Tickets endpoint without rety.
    .EXAMPLE
        PS C:\> Invoke-Method -path '/api/v2/tickets.json' -SideLoad 'user'

        Calls the List Tickets endpoint with user sideloading.
    .NOTES
        Method defaults to 'Get'

        Paths begin with '/api' as listed in the Zendesk API doco.

        ContentType defaults to 'application/json'

        If the ContentType is not overriden then the Body is automatically converted to Json.

        Pagination is done by default

        Sortby defaults to 'created_at'. Set to $null to follows the endpoints default sorting.

        Exponential retries api limited calls by default.
    #>

    [OutputType([PSCustomObject])]
    [CMDletBinding()]
    Param (
        # Rest Method
        [Parameter(Mandatory = $false)]
        [ValidateSet('Delete', 'Get', 'Post', 'Put')]
        [String]
        $Method = 'Get',

        # Path of the rest request. eg `/Account`
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $Path,

        # Body of the Rest call
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [PSCustomObject]
        $Body,

        # Content-Type of the body content
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [String]
        $ContentType = 'application/json',

        # Whether to perform pagination.
        [Parameter(Mandatory = $false)]
        [Boolean]
        $Pagination = $true,

        # Property to sort by. Set to `$null` for no sorting. Defaults to `created_at`
        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [String]
        $SortBy = 'created_at',

        # Whether to retry when rate limited.
        [Parameter(Mandatory = $false)]
        [Boolean]
        $Retry = $true,

        # Entities to sideload in the request
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet(
            'abilities',
            'app_installation',
            'brands',
            'categories',
            'comment_count',
            'comment_events',
            'dates',
            'groups',
            'identities',
            'incident_counts',
            'last_audits',
            'metric_events',
            'metric_sets',
            'open_ticket_count',
            'organizations',
            'permissions',
            'roles',
            'sharing_agreements',
            'slas',
            'ticket_forms',
            'tickets',
            'usage_1h',
            'usage_24h',
            'usage_30d',
            'usage_7d',
            'users'
        )]
        [String[]]
        $SideLoad,

        # Zendesk Connection Context from `Get-ZendeskConnection`
        [Parameter(Mandatory = $false)]
        [PSTypeName('ZendeskContext')]
        [PSCustomObject]
        $Context = $null
    )

    # Determine the context
    if ($null -eq $Context) {
        if (Test-Path Variable:\Script:Context) {
            $Context = $Script:Context
        } else {
            throw $Script:NotConnectedMessage
        }
    }

    $params = @{
        Method      = $Method
        ContentType = 'application/json; charset=utf-8'
        Headers     = @{
            Accept = 'application/json; charset=utf-8'
        }
    }

    if ($PSVersionTable.PSEdition -eq 'Core') {
        # PS Core added native Basic Auth support
        $params.Credential = $Context.Credential
        $params.Authentication = 'Basic'
    } else {
        # PS Desktop requires manual header creation. Basic auth is only supported by challenge.
        $raw = '{0}:{1}' -f $Context.Credential.GetNetworkCredential().username, $Context.Credential.GetNetworkCredential().password
        $bytes = [System.Text.Encoding]::ASCII.GetBytes($raw)
        $encoded = [Convert]::ToBase64String($bytes)

        $params.Headers.Authorization = "Basic $encoded"
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        if ($ContentType -eq 'application/json') {
            if ($PSVersionTable.PSVersion -ge '6.2.0') {
                $params.Body = $Body | ConvertTo-Json -Depth 5 -Compress -EscapeHandling EscapeNonAscii
            } else {
                $params.Body = $Body | ConvertTo-Json -Depth 5 -Compress | ConvertTo-UnicodeEscape
            }
        } else {
            $params.Body = $Body
        }
        $params.ContentType = $ContentType

        $params.Body | Out-String | Write-Debug
    }

    $uri = $Context.BaseUrl + $Path

    while (-not [String]::IsNullOrEmpty($uri)) {
        Write-Debug -Message "Uri before SortBy: $uri"
        if (-not [String]::IsNullOrEmpty($SortBy) -and $uri -notmatch 'sort_by=') {
            if ($uri -match '\?') {
                $uri += "&sort_by=$SortBy"
            } else {
                $uri += "?sort_by=$SortBy"
            }
        }

        Write-Debug -Message "Uri before Sideload: $uri"
        if (-not [String]::IsNullOrEmpty($SideLoad) -and $uri -notmatch 'include=') {
            $sideloadJoined = $SideLoad -join ','
            if ($uri -match '\?') {
                $uri += "&include=$sideloadJoined"
            } else {
                $uri += "?include=$sideloadJoined"
            }
        }

        try {
            $result = Invoke-RestMethod @params -Uri $uri -Verbose:$VerbosePreference
            $result
        } catch {
            $errorRecord = $_
        }

        # 429 Too Many Requests - Exponential rety with jitter.
        $multiplier = 1
        while ($Retry -and (Test-Path -Path Variable:/errorRecord) -and $null -ne $errorRecord -and
            $errorRecord.Exception.GetType().Name -in @('HttpResponseException', 'WebException') -and
            $errorRecord.Exception.Response.StatusCode.value__ -eq 429) {

            if ($PSVersionTable.PSEdition -eq 'Core') {
                $retryAfter = $errorRecord.Exception.Response.Headers.RetryAfter.Delta.TotalSeconds
            } else {
                $retryAfter = $errorRecord.Exception.Response.Headers['Retry-After']
            }
            $retryAfter = 1

            $multiplier *= 2
            $jitter = Get-Random -Minimum 0.0 -Maximum 1.0

            $sleep = $retryAfter + $multiplier + $jitter
            Write-Warning -Message "Too many requests! Retrying after $sleep seconds."
            Start-Sleep -Seconds $sleep

            $errorRecord = $null
            try {
                $result = Invoke-RestMethod @params -Uri $uri -Verbose:$VerbosePreference
                $result
            } catch {
                $errorRecord = $_
            }
        }

        if ((Test-Path -Path Variable:\errorRecord) -and $null -ne $errorRecord) {
            # Get just the message without stack trace, category info, or qualified error
            $errorMessage = $errorRecord.ToString()
            Get-PSCallStack | ForEach-Object { $errorMessage += "`n" + $_.Command + ': line ' + $_.ScriptLineNumber }
            throw $errorMessage
        }

        $uri = $null
        if ($Pagination) {
            if ($null -ne $result -and (Get-Member -InputObject $result -Name 'next_page' -MemberType Properties)) {
                $uri = $result.next_page
            }
        }
    }
}