438 lines
26 KiB
PowerShell
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
|