Core/Invoke-TestWebRequest.ps1
|
function Invoke-TestWebRequest { <# .SYNOPSIS The one HTTP call every REST provider makes, done the way this PowerShell does it best .DESCRIPTION Every request the module sends to Entra, Okta, Authentik or PingOne, and every token request, goes through here. Windows PowerShell 5.1 corrupts non-ASCII text in both directions, silently, and hides the body of a failed response on a stream; PowerShell 7 gets the text right and can hand a failed response back like any other. Rather than test the edition in every request function, this reads Get-TestRuntime once and does each thing the way the running PowerShell supports: - The body goes out as UTF-8 bytes with the charset named, on both editions. A string body is sent by 5.1 as ISO-8859-1 when no charset is named, whatever the machine's code page: observed against PingOne, an 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, on both. 5.1 decodes by the declared charset and falls back to Latin-1, and Okta declares none. - A failed response is read the way this PowerShell allows. With -SkipHttpErrorCheck (PowerShell 7) a 4xx or 5xx comes back as a response and its body is decoded like any other; without it (Windows PowerShell) the cmdlet throws and the body is read from the exception's response stream, once. Either way the caller receives the one error shape New-TestWebRequestError describes: the status as an integer, the headers as a case-insensitive hashtable, the body in ErrorDetails. A transport failure with no response propagates untouched. - TLS 1.2 is added on Windows PowerShell, which can still default to TLS 1.0, only ever adding to the enabled set. PowerShell 7 negotiates on its own. - The progress bar is suppressed around the call, which on 5.1 costs more than the call, and the preference is restored whether the call threw or not. Get-TestRuntime detects each of those on the cmdlet itself, not from a version number. .PARAMETER Uri The absolute URI. .PARAMETER Method GET, POST, PUT, PATCH or DELETE. .PARAMETER Headers Request headers, authorization included. Content-Type is set from -ContentType. .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. ConvertTo-Json's default of two silently flattens anything nested further. .EXAMPLE PS> $response = Invoke-TestWebRequest -Uri 'https://api.example.com/users' -Method POST -Headers $auth -Body @{ name = 'José' } PS> $page = $response.Content | ConvertFrom-Json DESCRIPTION: Sends one JSON body and reads the answer OUTPUT: StatusCode, Headers and the decoded Content USE CASE: Called by every provider's Invoke-<Provider>Request .EXAMPLE PS> try { Invoke-TestWebRequest -Uri $uri -Method GET -Headers $auth } catch { $_.Exception.Response.StatusCode; $_.ErrorDetails.Message } DESCRIPTION: Reads a failure the same way on either edition OUTPUT: The status and the body the server sent USE CASE: Every provider's retry and error handling .OUTPUTS PSCustomObject with StatusCode (integer), Headers (case-insensitive hashtable, each value a string) and Content (the body as a string, empty when there was none). .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 ) $runtime = Get-TestRuntime if (-not $runtime.Capability.ModernTls) { $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 ($runtime.Capability.SkipHttpErrorCheck) { $arguments['SkipHttpErrorCheck'] = $true } 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 } # Every header value as one string under a case-insensitive key - a PowerShell hashtable # literal keys that way - whichever collection the response carried: a hashtable, a # WebHeaderCollection, PowerShell 7's dictionary of string arrays, or an HttpResponseMessage's # enumerable of pairs. Multiple values are joined the way the wire joins them, so a # provider's Link parsing reads one string on both editions. $toHeaderTable = { param($source) $table = @{} if ($null -eq $source) { return $table } if ($source -is [System.Collections.IDictionary]) { foreach ($key in @($source.Keys)) { $table[[string]$key] = (@($source[$key]) -join ', ') } } elseif ($source -is [System.Collections.Specialized.NameValueCollection]) { foreach ($key in @($source.AllKeys)) { if ($null -ne $key) { $table[[string]$key] = [string]$source[$key] } } } elseif ($source -is [System.Collections.IEnumerable]) { foreach ($pair in $source) { if ($null -ne $pair -and $pair.PSObject.Properties['Key']) { $table[[string]$pair.Key] = (@($pair.Value) -join ', ') } } } return $table } $decode = { param($response) if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) { return [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) } if ($response.Content -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($response.Content) } if ($null -ne $response.Content) { return [string]$response.Content } return '' } $previousProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' $failure = $null try { Write-Verbose "$Method $Uri" $response = Invoke-WebRequest @arguments } catch { # Windows PowerShell's way: the cmdlet threw, and the status, headers and body are on the # exception's response. The stream can be read once, so this is the only place that reads # it. A record that already carries the body in ErrorDetails - which is what a test's # fake, or PowerShell 7 without the switch, hands over - is read from there instead. An # exception with no response at all is a transport failure and is not this function's # to describe. $thrown = $_ $errorResponse = $null if ($thrown.Exception.PSObject.Properties['Response'] -and $thrown.Exception.Response) { $errorResponse = $thrown.Exception.Response } if ($null -eq $errorResponse) { throw } $status = 0 try { $status = [int]$errorResponse.StatusCode } catch { $status = 0 } if ($status -le 0) { throw } $body = $null if ($thrown.ErrorDetails -and -not [string]::IsNullOrEmpty($thrown.ErrorDetails.Message)) { $body = $thrown.ErrorDetails.Message } elseif ($errorResponse.PSObject.Methods['GetResponseStream']) { try { $stream = $errorResponse.GetResponseStream() if ($stream) { $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) try { $body = $reader.ReadToEnd() } finally { $reader.Dispose() } } } catch { Write-Verbose "Could not read the failed response's body: $($_.Exception.Message)" } } $description = if ($errorResponse.PSObject.Properties['StatusDescription']) { [string]$errorResponse.StatusDescription } elseif ($errorResponse.PSObject.Properties['ReasonPhrase']) { [string]$errorResponse.ReasonPhrase } else { '' } # Assigned inside the if, not from it as an expression: a statement's output is # enumerated on the way out, and a WebHeaderCollection assigned that way arrives as an # array of its key names, with every value gone. $headerSource = $null if ($errorResponse.PSObject.Properties['Headers']) { $headerSource = $errorResponse.Headers } $failure = New-TestWebRequestError -Method $Method -Uri $Uri -StatusCode $status -StatusDescription $description ` -Headers (& $toHeaderTable $headerSource) -Body $body -InnerException $thrown.Exception } finally { $ProgressPreference = $previousProgress } if ($failure) { $PSCmdlet.ThrowTerminatingError($failure) } $content = & $decode $response $headerTable = & $toHeaderTable $response.Headers $statusCode = [int]$response.StatusCode # PowerShell 7's way: with -SkipHttpErrorCheck a failed response arrives here like any other, # body already decoded from its raw bytes, and becomes the same record the catch above builds. if ($statusCode -ge 400) { $description = if ($response.PSObject.Properties['StatusDescription']) { [string]$response.StatusDescription } else { '' } $failure = New-TestWebRequestError -Method $Method -Uri $Uri -StatusCode $statusCode -StatusDescription $description ` -Headers $headerTable -Body $content $PSCmdlet.ThrowTerminatingError($failure) } [PSCustomObject]@{ StatusCode = $statusCode Headers = $headerTable Content = $content } } |