Files
GraceSolutions 7669cb01ab Almost complete
2026-08-02 20:57:25 -04:00

438 lines
26 KiB
PowerShell

#region Invoke-RestAPIRequest
Function Invoke-RestAPIRequest
{
<#
.SYNOPSIS
Executes a REST API request using System.Net.Http with status code translation, rate limit awareness, and automatic retry.
.DESCRIPTION
A single transport function that is shared by every service integration within this solution. It intentionally avoids
Invoke-RestMethod so that non-standard authorization header formats (for example the Omada "AccessToken=<value>" scheme)
can be transmitted without client side validation rejecting them.
The returned object always describes the outcome of the request, even when the request failed, so that callers can make
decisions using a Switch statement instead of exception handling.
.PARAMETER URI
The fully qualified request URI including scheme.
.PARAMETER Method
The HTTP method to execute.
.PARAMETER Body
The request body. A String is transmitted as-is. Any other object is serialized to JSON.
.PARAMETER Header
An ordered dictionary of header names and values. Values are added without validation so that custom schemes are preserved.
.PARAMETER ContentType
The content type associated with the request body.
.PARAMETER MaximumRetryCount
The maximum number of additional attempts that will be made when a retryable status code is returned.
.PARAMETER RetryInterval
The base interval between retry attempts. The interval is multiplied by the attempt number to provide linear backoff.
.PARAMETER Timeout
The maximum duration of a single request attempt.
.PARAMETER SkipCertificateCheck
Ignores server certificate validation errors. Required for appliances presenting self signed certificates.
.PARAMETER AcceptableStatusCodeList
One or more status codes that should be treated as successful in addition to the 2xx range. A '*' accepts all status codes.
.EXAMPLE
$InvokeRestAPIRequestParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InvokeRestAPIRequestParameters.URI = 'https://rackpad.example.com/api/labs'
$InvokeRestAPIRequestParameters.Method = 'GET'
$InvokeRestAPIRequestParameters.Header = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$InvokeRestAPIRequestParameters.Header.Authorization = "Bearer $($Token)"
$Response = Invoke-RestAPIRequest @InvokeRestAPIRequestParameters
.NOTES
Author: Grace Solutions
Version: 2026.08.02.0000
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[System.URI]$URI,
[Parameter(Mandatory=$False)]
[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD')]
[String]$Method = 'GET',
[Parameter(Mandatory=$False)]
[AllowNull()]
[System.Object]$Body,
[Parameter(Mandatory=$False)]
[AllowNull()]
[System.Collections.Specialized.OrderedDictionary]$Header,
[Parameter(Mandatory=$False)]
[ValidateNotNullOrEmpty()]
[String]$ContentType = 'application/json',
[Parameter(Mandatory=$False)]
[ValidateRange(0, 10)]
[Int]$MaximumRetryCount = 4,
[Parameter(Mandatory=$False)]
[System.TimeSpan]$RetryInterval = ([System.TimeSpan]::FromSeconds(2)),
[Parameter(Mandatory=$False)]
[System.TimeSpan]$Timeout = ([System.TimeSpan]::FromSeconds(120)),
[Parameter(Mandatory=$False)]
[Switch]$SkipCertificateCheck,
[Parameter(Mandatory=$False)]
[AllowNull()]
[String[]]$AcceptableStatusCodeList
)
Try
{
#region Ensure the required assembly is available on Windows PowerShell
Switch ($PSVersionTable.PSVersion.Major -lt 6)
{
{($_ -eq $True)}
{
$Null = Try {Add-Type -AssemblyName 'System.Net.Http' -ErrorAction SilentlyContinue} Catch {$Null}
}
}
#endregion
#region Define the status code translation table
Switch ($Null -ieq $Script:RestAPIStatusCodeTable)
{
{($_ -eq $True)}
{
$Script:RestAPIStatusCodeTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.Int32], [System.String]]'
$Script:RestAPIStatusCodeTable.Add(200, 'OK - The request completed successfully.')
$Script:RestAPIStatusCodeTable.Add(201, 'Created - The requested object was created successfully.')
$Script:RestAPIStatusCodeTable.Add(202, 'Accepted - The request was accepted for processing.')
$Script:RestAPIStatusCodeTable.Add(204, 'No Content - The request completed successfully and returned no content.')
$Script:RestAPIStatusCodeTable.Add(304, 'Not Modified - The requested object has not changed.')
$Script:RestAPIStatusCodeTable.Add(400, 'Bad Request - The request payload failed server side validation.')
$Script:RestAPIStatusCodeTable.Add(401, 'Unauthorized - The supplied credentials or token were rejected.')
$Script:RestAPIStatusCodeTable.Add(403, 'Forbidden - The authenticated principal lacks permission for this operation.')
$Script:RestAPIStatusCodeTable.Add(404, 'Not Found - The requested resource or route does not exist.')
$Script:RestAPIStatusCodeTable.Add(405, 'Method Not Allowed - The route does not support the requested method.')
$Script:RestAPIStatusCodeTable.Add(408, 'Request Timeout - The server timed out waiting for the request.')
$Script:RestAPIStatusCodeTable.Add(409, 'Conflict - The object already exists or violates a uniqueness constraint.')
$Script:RestAPIStatusCodeTable.Add(413, 'Payload Too Large - The request body exceeded the server limit.')
$Script:RestAPIStatusCodeTable.Add(422, 'Unprocessable Entity - The request was well formed but semantically invalid.')
$Script:RestAPIStatusCodeTable.Add(429, 'Too Many Requests - The client has exceeded the service rate limit.')
$Script:RestAPIStatusCodeTable.Add(500, 'Internal Server Error - The service encountered an unexpected condition.')
$Script:RestAPIStatusCodeTable.Add(502, 'Bad Gateway - An upstream service returned an invalid response.')
$Script:RestAPIStatusCodeTable.Add(503, 'Service Unavailable - The service is temporarily unable to handle the request.')
$Script:RestAPIStatusCodeTable.Add(504, 'Gateway Timeout - An upstream service failed to respond in time.')
}
}
#endregion
#region Define the list of status codes that warrant an automatic retry
$RetryableStatusCodeList = New-Object -TypeName 'System.Collections.Generic.List[Int32]'
$RetryableStatusCodeList.Add(408)
$RetryableStatusCodeList.Add(429)
$RetryableStatusCodeList.Add(500)
$RetryableStatusCodeList.Add(502)
$RetryableStatusCodeList.Add(503)
$RetryableStatusCodeList.Add(504)
#endregion
#region Retrieve or create a cached HTTP client for the requested certificate validation behavior
Switch ($Null -ieq $Script:RestAPIClientTable)
{
{($_ -eq $True)}
{
$Script:RestAPIClientTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.String], [System.Net.Http.HttpClient]]'
}
}
$ClientKey = "SkipCertificateCheck=$($SkipCertificateCheck.IsPresent)"
Switch ($Script:RestAPIClientTable.ContainsKey($ClientKey))
{
{($_ -eq $False)}
{
$HTTPClientHandler = New-Object -TypeName 'System.Net.Http.HttpClientHandler'
$HTTPClientHandler.AllowAutoRedirect = $True
$HTTPClientHandler.UseCookies = $False
Switch ($HTTPClientHandler.SupportsAutomaticDecompression)
{
{($_ -eq $True)}
{
$HTTPClientHandler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bOr [System.Net.DecompressionMethods]::Deflate
}
}
Switch ($SkipCertificateCheck.IsPresent)
{
{($_ -eq $True)}
{
Switch ($PSVersionTable.PSVersion.Major -ge 6)
{
{($_ -eq $True)}
{
$HTTPClientHandler.ServerCertificateCustomValidationCallback = [System.Net.Http.HttpClientHandler]::DangerousAcceptAnyServerCertificateValidator
}
{($_ -eq $False)}
{
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$True}
}
}
}
}
$HTTPClient = New-Object -TypeName 'System.Net.Http.HttpClient' -ArgumentList ($HTTPClientHandler)
$HTTPClient.Timeout = $Timeout
$Script:RestAPIClientTable.Add($ClientKey, $HTTPClient)
}
}
$HTTPClient = $Script:RestAPIClientTable[$ClientKey]
#endregion
#region Serialize the request body
$RequestBodyContent = $Null
Switch ($Null -ine $Body)
{
{($_ -eq $True)}
{
Switch ($Body -is [System.String])
{
{($_ -eq $True)}
{
$RequestBodyContent = "$($Body)"
}
{($_ -eq $False)}
{
$RequestBodyContent = $Body | ConvertTo-Json -Depth 20 -Compress
}
}
}
}
#endregion
#region Execute the request, retrying whenever a retryable status code is returned
$ResponseProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ResponseProperties.RequestURI = $URI
$ResponseProperties.RequestMethod = $Method
$ResponseProperties.StatusCode = 0
$ResponseProperties.StatusDescription = 'The request was never dispatched.'
$ResponseProperties.IsSuccess = $False
$ResponseProperties.AttemptCount = 0
$ResponseProperties.RawContent = $Null
$ResponseProperties.Content = $Null
$ResponseProperties.ResponseHeader = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ResponseProperties.ErrorMessage = $Null
$MaximumAttemptCount = $MaximumRetryCount + 1
:RequestAttemptLoop For ($AttemptIndex = 0; $AttemptIndex -lt $MaximumAttemptCount; $AttemptIndex++)
{
$ResponseProperties.AttemptCount = $AttemptIndex + 1
$HTTPRequestMessage = $Null
$HTTPResponseMessage = $Null
Try
{
$HTTPRequestMessage = New-Object -TypeName 'System.Net.Http.HttpRequestMessage' -ArgumentList (New-Object -TypeName 'System.Net.Http.HttpMethod' -ArgumentList ($Method)), $URI
Switch (($Null -ine $Header) -and ($Header.Count -gt 0))
{
{($_ -eq $True)}
{
ForEach ($HeaderEntry In $Header.GetEnumerator())
{
$Null = $HTTPRequestMessage.Headers.TryAddWithoutValidation("$($HeaderEntry.Key)", "$($HeaderEntry.Value)")
}
}
}
Switch ($Null -ine $RequestBodyContent)
{
{($_ -eq $True)}
{
$HTTPRequestMessage.Content = New-Object -TypeName 'System.Net.Http.StringContent' -ArgumentList ($RequestBodyContent, [System.Text.Encoding]::UTF8, $ContentType)
}
}
$HTTPResponseMessage = $HTTPClient.SendAsync($HTTPRequestMessage).GetAwaiter().GetResult()
$ResponseProperties.StatusCode = [Int32]$HTTPResponseMessage.StatusCode
$ResponseProperties.RawContent = $HTTPResponseMessage.Content.ReadAsStringAsync().GetAwaiter().GetResult()
$ResponseProperties.ErrorMessage = $Null
#region Capture the response headers so that callers can read values such as Set-Cookie
$ResponseProperties.ResponseHeader = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
ForEach ($ResponseHeaderEntry In @($HTTPResponseMessage.Headers) + @($HTTPResponseMessage.Content.Headers))
{
Switch ($Null -ine $ResponseHeaderEntry)
{
{($_ -eq $True)}
{
$ResponseProperties.ResponseHeader["$($ResponseHeaderEntry.Key)"] = "$($ResponseHeaderEntry.Value -Join '; ')"
}
}
}
#endregion
Switch ($Script:RestAPIStatusCodeTable.ContainsKey($ResponseProperties.StatusCode))
{
{($_ -eq $True)}
{
$ResponseProperties.StatusDescription = $Script:RestAPIStatusCodeTable[$ResponseProperties.StatusCode]
}
{($_ -eq $False)}
{
$ResponseProperties.StatusDescription = "$($HTTPResponseMessage.ReasonPhrase)"
}
}
#region Parse the response payload whenever it is JSON
Switch ([String]::IsNullOrWhiteSpace($ResponseProperties.RawContent) -eq $False)
{
{($_ -eq $True)}
{
$ResponseProperties.Content = Try {$ResponseProperties.RawContent | ConvertFrom-Json -ErrorAction Stop} Catch {$ResponseProperties.RawContent}
}
}
#endregion
#region Determine whether the status code should be considered successful
$IsAcceptableStatusCode = $HTTPResponseMessage.IsSuccessStatusCode
Switch (($Null -ine $AcceptableStatusCodeList) -and ($AcceptableStatusCodeList.Count -gt 0))
{
{($_ -eq $True)}
{
Switch (('*' -iin $AcceptableStatusCodeList) -or ("$($ResponseProperties.StatusCode)" -iin $AcceptableStatusCodeList))
{
{($_ -eq $True)}
{
$IsAcceptableStatusCode = $True
}
}
}
}
$ResponseProperties.IsSuccess = $IsAcceptableStatusCode
#endregion
Switch ($IsAcceptableStatusCode)
{
{($_ -eq $True)}
{
Break RequestAttemptLoop
}
}
#region Extract the service supplied error message when one is present
Switch ($Null -ine $ResponseProperties.Content)
{
{($_ -eq $True)}
{
$ServiceErrorMessage = Try {"$($ResponseProperties.Content.error)$($ResponseProperties.Content.msg)$($ResponseProperties.Content.message)"} Catch {''}
Switch ([String]::IsNullOrWhiteSpace($ServiceErrorMessage) -eq $False)
{
{($_ -eq $True)}
{
$ResponseProperties.ErrorMessage = $ServiceErrorMessage
}
}
}
}
#endregion
Switch ($ResponseProperties.StatusCode -iin $RetryableStatusCodeList)
{
{($_ -eq $False)}
{
Break RequestAttemptLoop
}
}
#region Honor the Retry-After header whenever the service supplies one
$RetryDelay = [System.TimeSpan]::FromMilliseconds($RetryInterval.TotalMilliseconds * ($AttemptIndex + 1))
$RetryAfterHeaderValue = Try {($HTTPResponseMessage.Headers.GetValues('Retry-After') | Select-Object -First 1)} Catch {$Null}
Switch ([String]::IsNullOrWhiteSpace($RetryAfterHeaderValue) -eq $False)
{
{($_ -eq $True)}
{
$RetryAfterSeconds = 0
Switch ([Int32]::TryParse("$($RetryAfterHeaderValue)", [Ref]$RetryAfterSeconds))
{
{($_ -eq $True)}
{
$RetryDelay = [System.TimeSpan]::FromSeconds($RetryAfterSeconds)
}
}
}
}
#endregion
Switch ($AttemptIndex -lt ($MaximumAttemptCount - 1))
{
{($_ -eq $True)}
{
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - Attempt $($ResponseProperties.AttemptCount) of $($MaximumAttemptCount) returned status code $($ResponseProperties.StatusCode). Retrying in $($RetryDelay.TotalSeconds) second(s). [URI: $($URI)]"
$Null = Start-Sleep -Milliseconds ($RetryDelay.TotalMilliseconds)
}
}
}
Catch
{
$ResponseProperties.ErrorMessage = "$($_.Exception.Message)"
$ResponseProperties.StatusDescription = 'Transport Failure - The request could not be completed.'
$ResponseProperties.IsSuccess = $False
Switch ($AttemptIndex -lt ($MaximumAttemptCount - 1))
{
{($_ -eq $True)}
{
$Null = Start-Sleep -Milliseconds ($RetryInterval.TotalMilliseconds * ($AttemptIndex + 1))
}
}
}
Finally
{
$Null = Try {$HTTPResponseMessage.Dispose()} Catch {$Null}
$Null = Try {$HTTPRequestMessage.Dispose()} Catch {$Null}
}
}
#endregion
$ResponseObject = New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResponseProperties)
Write-Output -InputObject ($ResponseObject)
}
Catch
{
Throw
}
}
#endregion