Public/Invoke/Invoke-HaloRequest.ps1

#Requires -Version 7

function Invoke-HaloRequest {
    <#
        .SYNOPSIS
            Sends a request to the Halo API.
        .DESCRIPTION
            Wrapper function to send web requests to the Halo API.
 
            Supports the legacy hashtable request format used internally by the module,
            as well as a direct parameter set for clearer public use.
 
            When using -Fragment, slashless values are treated as Halo API fragments and
            are automatically prefixed with /api/.
        .PARAMETER WebRequestParams
            Hashtable containing the web request parameters.
        .PARAMETER Method
            HTTP method for the request.
        .PARAMETER Uri
            Absolute URI for the request.
        .PARAMETER Fragment
            Fragment path to resolve against the Halo base URL. Slashless fragments are
            automatically prefixed with /api/.
        .PARAMETER Headers
            Request headers to merge with the Halo auth headers.
        .PARAMETER Body
            Request body.
        .PARAMETER ContentType
            Content type for the request.
        .PARAMETER ExpandProperty
            Returns the value of a property from the JSON response, such as tickets.
        .PARAMETER RawResult
            Returns the raw web response. Useful for file downloads.
        .EXAMPLE
            Invoke-HaloRequest -WebRequestParams @{ Method = 'GET'; Uri = 'https://example.halo/api/customtable' }
 
            Uses the legacy request hashtable.
        .EXAMPLE
            Invoke-HaloRequest -Method 'POST' -Uri 'https://example.halo/api/customtable' -Body $Payload
 
            Uses the direct parameter set for a POST request with an explicit absolute URI.
        .EXAMPLE
            Invoke-HaloRequest -Method 'GET' -Fragment 'tickets'
 
            Resolves a slashless fragment to /api/tickets before sending the request.
        .EXAMPLE
            Invoke-HaloRequest -Method 'GET' -Fragment 'tickets' -ExpandProperty 'tickets'
 
            Returns only the tickets property from the JSON response.
        .OUTPUTS
            Outputs an object containing the response from the web request.
    #>

    [Cmdletbinding()]
    [OutputType([Object])]
    param (
        # Hashtable containing the web request parameters.
        [Parameter( ParameterSetName = 'WebRequestParams', Mandatory = $True )]
        [Hashtable]$WebRequestParams,
        # HTTP method for the request.
        [Parameter( ParameterSetName = 'RequestParameters', Mandatory = $True )]
        [ValidateSet('GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'HEAD', 'OPTIONS')]
        [string]$Method,
        # URI or path for the request.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [string]$Uri,
        # Fragment path to resolve against the Halo base URL.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [string]$Fragment,
        # Request headers to merge with the Halo auth headers.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [Hashtable]$Headers,
        # Request body.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [Object]$Body,
        # Content type for the request.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [string]$ContentType,
        # Property to expand from the JSON response.
        [Parameter( ParameterSetName = 'RequestParameters' )]
        [Parameter( ParameterSetName = 'WebRequestParams' )]
        [string]$ExpandProperty,
        # Returns the Raw result. Useful for file downloads.
        [Switch]$RawResult
    )
    $ProgressPreference = 'SilentlyContinue'
    Invoke-HaloPreFlightCheck
    $Now = Get-Date
    if ($Script:HAPIAuthToken.Expires -le $Now) {
        Write-Verbose 'The auth token has expired, renewing.'
        $ReconnectParameters = @{
            URL = $Script:HAPIConnectionInformation.URL
            ClientId = $Script:HAPIConnectionInformation.ClientID
            ClientSecret = $Script:HAPIConnectionInformation.ClientSecret
            Scopes = $Script:HAPIConnectionInformation.AuthScopes
            Tenant = $Script:HAPIConnectionInformation.Tenant
        }
        Connect-HaloAPI @ReconnectParameters
    }
    if ($null -ne $Script:HAPIAuthToken) {
        $AuthHeaders = @{
            Authorization = ('{0} {1}' -f $Script:HAPIAuthToken.Type, $Script:HAPIAuthToken.Access)
        }
        if ($null -ne $Script:HAPIConnectionInformation.AdditionalHeaders) {
            $RequestHeaders = $AuthHeaders + $Script:HAPIConnectionInformation.AdditionalHeaders
        } else {
            $RequestHeaders = $AuthHeaders
        }
    } else {
        $RequestHeaders = $null
    }
    if ($PSCmdlet.ParameterSetName -eq 'RequestParameters') {
        $WebRequestParams = @{
            Method = $Method
        }

        if (-not [string]::IsNullOrWhiteSpace($Uri)) {
            $WebRequestParams.Uri = $Uri
        }

        if (-not [string]::IsNullOrWhiteSpace($Fragment)) {
            $WebRequestParams.Fragment = $Fragment
        }

        if ($null -ne $Headers) {
            $WebRequestParams.Headers = $Headers
        }

        if ($PSBoundParameters.ContainsKey('Body')) {
            $WebRequestParams.Body = $Body
        }

        if ($PSBoundParameters.ContainsKey('ContentType')) {
            $WebRequestParams.ContentType = $ContentType
        }

        if (-not [string]::IsNullOrWhiteSpace($ExpandProperty)) {
            $WebRequestParams.ExpandProperty = $ExpandProperty
        }
    }
    $Retries = 0
    $BaseDelay = 5 # Base delay of 5 seconds
    $MaxDelay = 60 # Maximum delay of 60 seconds
    $BaseUri = [System.Uri]$Script:HAPIConnectionInformation.URL
    # Check for a fragment first so callers can supply just the path portion.
    if ($WebRequestParams.Fragment) {
        $FragmentPath = $WebRequestParams.Fragment
        if ($FragmentPath -notmatch '/') {
            $FragmentPath = '/api/{0}' -f $FragmentPath
        }

        $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $FragmentPath).AbsoluteUri
        $WebRequestParams.Remove('Fragment') | Out-Null
    }

    # Check if $WebRequestParams contains a full URI, if not, append the base URL.
    if (-not ([System.Uri]$WebRequestParams.Uri).IsAbsoluteUri) {
        $WebRequestParams.Uri = [System.Uri]::new($BaseUri, $WebRequestParams.Uri).AbsoluteUri
    }
    if ($null -ne $WebRequestParams.Headers) {
        if ($null -ne $RequestHeaders) {
            $RequestHeaders = $RequestHeaders + $WebRequestParams.Headers
        } else {
            $RequestHeaders = $WebRequestParams.Headers
        }

        $WebRequestParams.Remove('Headers') | Out-Null
    }

    $RequestContentType = 'application/json; charset=utf-8'
    if ($WebRequestParams.ContainsKey('ContentType')) {
        $RequestContentType = $WebRequestParams.ContentType
        $WebRequestParams.Remove('ContentType') | Out-Null
    }
    $ResponseExpandProperty = $null
    if ($WebRequestParams.ContainsKey('ExpandProperty')) {
        $ResponseExpandProperty = $WebRequestParams.ExpandProperty
        $WebRequestParams.Remove('ExpandProperty') | Out-Null
    }
    do {
        $Retries++
        Write-Verbose ('Attempt {0} of 10' -f $Retries)
        try {
            Write-Verbose ('Making a {0} request to {1}' -f $WebRequestParams.Method, $WebRequestParams.Uri)
            $Response = Invoke-WebRequest @WebRequestParams -Headers $RequestHeaders -ContentType $RequestContentType
            if ($Response) {
                Write-Debug ('Response headers: {0}' -f ($Response.Headers | Out-String))
                Write-Debug ('Raw Response: {0}' -f ($Response | Out-String))
                Write-Debug ('Response Members: {0}' -f ($Response | Get-Member | Out-String))
                $Success = $True
                if ($RawResult) {
                    $Results = $Response
                } else {
                    $Results = ($Response.Content | ConvertFrom-Json -Depth 100)
                    if ($ResponseExpandProperty) {
                        $Results = $Results | Select-Object -ExpandProperty $ResponseExpandProperty
                    }
                }
            } else {
                Write-Debug 'Response was null.'
            }
        } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
            $Success = $False
            if ($_.Exception.Response.StatusCode.value__ -eq 429) {
                $WaitTime = [math]::Min($BaseDelay * [math]::Pow(2, $Retries - 1), $MaxDelay)
                Write-Warning ('The request was throttled, waiting for {0} seconds.' -f $WaitTime)
                Start-Sleep -Seconds $WaitTime
                continue
            } else {
                throw $_
                break
            }
        } catch {
            throw $_
        }
        Write-Verbose 'Request successful.'
    } while ((-not $Results) -and ($Retries -lt $Script:HAPIConnectionInformation.MaxRetries) -and (-not $Success))
    if ($Results) {
        Write-Verbose 'Request returned results.'
        Return $Results
    } else {
        Write-Verbose 'Request unsuccessful.'
        if ($Retries -gt 1) {
            New-HaloError -ModuleMessage ('Retried request to "{0}" {1} times, request unsuccessful.' -f $WebRequestParams.Uri, $Retries)
        }
    }
}