Core/Invoke-TestWebRequest.ps1

function Invoke-TestWebRequest {
    <#
    .SYNOPSIS
        The one HTTP call every REST provider makes, with the encoding Windows PowerShell gets wrong

    .DESCRIPTION
        Windows PowerShell 5.1 corrupts non-ASCII text in both directions, silently, and
        PowerShell 7 hides both faults, so a provider tested only on 7 looks correct. This is the
        one place the four things every HTTP provider needs are done, so a new provider calls it
        rather than copying them:

        - The body goes out as UTF-8 bytes. A string body is sent by 5.1 as ISO-8859-1 when the
          content type names no charset, whatever the machine's code page: against PingOne a plain
          accented e went out as the lone byte E9 and was stored as U+FFFD, and a Han character
          as '?'. A string is encoded, an object is serialised to JSON and encoded, and a byte
          array is sent as it stands.
        - The response is decoded from its raw bytes as UTF-8, never from .Content. 5.1 decodes
          by the declared charset and falls back to Latin-1; Okta declares none, and a service
          that declares UTF-8 today does so with a header this module does not control.
        - TLS 1.2 is added on the Desktop edition, which can still default to TLS 1.0. Only ever
          added to the enabled set: clearing it would change behaviour for everything else in the
          session.
        - The progress bar is suppressed, which on 5.1 costs more than the calls do, and restored
          whether the call succeeded or threw.

        What this does not do is interpret the response or the failure. The status code, headers
        and decoded text are returned for the caller to page through, and an error is left to
        propagate untouched, so a provider's error-detail function still sees the response and
        its retry logic still reads Retry-After from it.

    .PARAMETER Uri
        The full request URI.

    .PARAMETER Method
        The HTTP method.

    .PARAMETER Headers
        Request headers. Authorization and Accept belong to the caller.

    .PARAMETER Body
        A string, a byte array, or an object to serialise as JSON. Nothing is sent when omitted.

    .PARAMETER ContentType
        The content type sent with a body. Defaults to JSON with the charset named.

    .PARAMETER JsonDepth
        How deep an object body is serialised.

    .OUTPUTS
        PSCustomObject with StatusCode, Headers and Content, the response text decoded as UTF-8,
        or an empty Content for a response with no body.

    .EXAMPLE
        PS> $response = Invoke-TestWebRequest -Uri $uri -Method GET -Headers @{ Authorization = "Bearer $token" }
        PS> $page = $response.Content | ConvertFrom-Json

        DESCRIPTION: One read, decoded correctly on both editions
        OUTPUT: The response text and headers
        USE CASE: Every Invoke-*Request in the module, and every token endpoint

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

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

        [Parameter()]
        [hashtable]$Headers,

        [Parameter()]
        [object]$Body,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$ContentType = 'application/json; charset=utf-8',

        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$JsonDepth = 20
    )

    if ($PSVersionTable.PSEdition -eq 'Desktop') {
        $tls12 = [System.Net.SecurityProtocolType]::Tls12
        if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) {
            [System.Net.ServicePointManager]::SecurityProtocol =
                [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12
        }
    }

    $arguments = @{
        Uri             = $Uri
        Method          = $Method
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }
    if ($Headers -and $Headers.Count -gt 0) { $arguments['Headers'] = $Headers }

    if ($null -ne $Body) {
        # Assigned inside each branch, not from the if as an expression: a statement's output is
        # enumerated on the way out, and a byte array assigned that way arrives as an array of
        # objects, which Invoke-WebRequest then sends as text.
        [byte[]]$bytes = $null
        if ($Body -is [byte[]]) {
            $bytes = $Body
        }
        elseif ($Body -is [string]) {
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($Body)
        }
        else {
            $bytes = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth $JsonDepth -Compress))
        }
        $arguments['Body'] = $bytes
        $arguments['ContentType'] = $ContentType
    }

    $previousProgress = $ProgressPreference
    $ProgressPreference = 'SilentlyContinue'
    try {
        Write-Verbose "$Method $Uri"
        $response = Invoke-WebRequest @arguments
    }
    finally {
        $ProgressPreference = $previousProgress
    }

    $content = ''
    if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) {
        $content = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())
    }
    elseif ($response.Content -is [byte[]]) {
        $content = [System.Text.Encoding]::UTF8.GetString($response.Content)
    }
    elseif ($null -ne $response.Content) {
        $content = [string]$response.Content
    }

    [PSCustomObject]@{
        StatusCode = $response.StatusCode
        Headers    = $response.Headers
        Content    = $content
    }
}