Providers/PingOne/Private/Invoke-PingOneRequest.ps1
|
function Invoke-PingOneRequest { <# .SYNOPSIS Calls one PingOne management API method, with pagination, token renewal and a readable error .DESCRIPTION The single path every PingOne call takes, and the one function the unit suite mocks. Nothing else in this provider touches the network except the token request itself, which talks to a different host with a different credential. What it has to know: - A collection comes back wrapped. PingOne answers a list with `count`, `size` and an `_embedded` object holding one property named for the resource - `users`, `groups`, `populations` - so a caller that wants the items would otherwise have to know the property name at every call site. -Paginate unwraps it and follows `_links.next` until there is none, and returns the items themselves. - A single object is not wrapped, so it is returned as it stands. - The access token lives an hour. That is longer than most steps and shorter than a seed of any size, and the symptom of letting it lapse is a 401 halfway through a teardown. The token is renewed here, a minute before expiry, so no caller has to think about it. - An error carries PingOne's own shape: a `code` such as INVALID_DATA or NOT_FOUND, a `message`, and often a `details` array naming the offending field. The status code alone is close to useless - a 400 is returned for a malformed body, an unsupported grant type and a duplicate name alike - so the code, the message and the first detail are all put into the exception, along with the correlation id PingOne returns, which is the only thing their support can act on. - Callers that expect a particular failure - a show that may find nothing, a delete of something already gone - name the PingOne code in -IgnoreError and get $null back. Anything else is thrown. Written for Windows PowerShell 5.1 as well as 7, following the HTTP encoding invariant in CLAUDE.md: bodies go out as UTF-8 bytes, responses are decoded from their raw bytes as UTF-8 whatever charset they declare, TLS 1.2 is added on the Desktop edition, and the progress bar is suppressed. The Authorization header is built by hand rather than with -Authentication, and the error body is read from the exception response stream where ErrorDetails is empty, which is where 5.1 leaves it. .PARAMETER Method The HTTP method. .PARAMETER Path The path below the environment, such as 'users' or 'groups/{id}/memberOfGroups'. A path beginning with a slash is treated as absolute below /v1, which is how the few organisation-scoped calls reach outside the environment. .PARAMETER Body An object serialised as JSON. Omitted for GET and DELETE. .PARAMETER Query Query string values, added to the path. .PARAMETER Paginate Follow `_links.next` and return the items from every page rather than the envelope. .PARAMETER IgnoreError PingOne error codes to treat as an empty result rather than a failure. .PARAMETER Connection The connection to use. Defaults to the session's. .OUTPUTS The response object, or the items from every page under -Paginate, or $null for an ignored error. .EXAMPLE PS> Invoke-PingOneRequest -Method GET -Path 'populations' -Paginate DESCRIPTION: Reads every population, following pagination OUTPUT: The population objects themselves, not the envelope USE CASE: Ownership discovery and the environment report .EXAMPLE PS> Invoke-PingOneRequest -Method DELETE -Path "groups/$id" -IgnoreError 'NOT_FOUND' DESCRIPTION: Deletes a group that another step may already have removed OUTPUT: $null when it was already gone USE CASE: Teardown, where a missing object is success rather than failure .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter()] [object]$Body, [Parameter()] [hashtable]$Query, [Parameter()] [switch]$Paginate, [Parameter()] [string[]]$IgnoreError = @(), [Parameter()] [hashtable]$Connection ) if (-not $Connection) { $Connection = Get-PingOneConnection } $uri = if ($Path -match '^https?://') { $Path } elseif ($Path.StartsWith('/')) { 'https://{0}/v1{1}' -f $Connection.ApiHost, $Path } else { 'https://{0}/v1/environments/{1}/{2}' -f $Connection.ApiHost, $Connection.EnvironmentId, $Path } if ($Query -and $Query.Count -gt 0) { $pairs = foreach ($key in $Query.Keys) { '{0}={1}' -f [uri]::EscapeDataString($key), [uri]::EscapeDataString([string]$Query[$key]) } $separator = if ($uri.Contains('?')) { '&' } else { '?' } $uri = '{0}{1}{2}' -f $uri, $separator, ($pairs -join '&') } $collected = [System.Collections.Generic.List[object]]::new() while ($true) { # Encoding, TLS and the progress bar are Invoke-TestWebRequest's job: the body goes # out as UTF-8 bytes and the response comes back decoded from its raw bytes, which is # what Windows PowerShell 5.1 got wrong here when this function did it itself. $arguments = @{ Method = $Method Uri = $uri Headers = @{ Authorization = 'Bearer {0}' -f (Get-PingOneAccessToken -Connection $Connection -AsPlainText) } } if ($null -ne $Body) { $arguments['Body'] = $Body } try { Write-Verbose "PingOne $Method $uri" $response = Invoke-TestWebRequest @arguments } catch { $detail = Get-PingOneErrorDetail -ErrorRecord $_ if ($detail.Code -and $IgnoreError -contains $detail.Code) { Write-Verbose "PingOne $Method $Path answered $($detail.Code), which the caller asked to ignore" return $null } $message = 'PingOne {0} {1} failed with HTTP {2}: {3}' -f $Method, $Path, $detail.Status, $detail.Summary throw (New-Object System.Exception($message, $_.Exception)) } $content = $response.Content $page = $null if (-not [string]::IsNullOrWhiteSpace($content)) { $page = $content | ConvertFrom-Json } if (-not $Paginate) { return $page } if ($null -eq $page) { break } # The envelope holds exactly one property named for the resource. Taking the first # rather than naming it keeps every caller from having to know the plural PingOne # uses, which is not always the one the path uses. if ($page._embedded) { $items = ($page._embedded.PSObject.Properties | Select-Object -First 1).Value foreach ($item in @($items)) { $collected.Add($item) } } $next = $null if ($page._links -and $page._links.next) { $next = [string]$page._links.next.href } # The self-comparison is the loop guard: a next link identical to the page just fetched # would otherwise fetch it forever. if (-not $next -or $next -eq $uri) { break } $uri = $next } # Emitted item by item, not wrapped. A unary comma here would hand the pipeline the whole # array as one object: foreach over the result still works, which hides the problem, but # "| Where-Object name -eq ..." then sees a single item whose .name is every name at once, # matches it, and passes the entire collection through. Callers wanting a guaranteed array # wrap the call in @(), which is the ordinary idiom and the one this provider uses. foreach ($item in $collected) { $item } } |