Files
2026-08-02 21:41:19 -04:00

174 lines
9.5 KiB
PowerShell

#region Invoke-UnifiRequest
Function Invoke-UnifiRequest
{
<#
.SYNOPSIS
Executes a single UniFi request against the network interface, degrading to the integration API when it cannot answer.
.DESCRIPTION
Degradation is per request rather than per run, for the same reason it is in the Omada equivalent. A session that expires or a
route a particular controller release does not implement should cost only the data that request carried.
The two transports disagree about both the path and the envelope. The network interface answers with a meta block carrying a
result code alongside a data array, whereas the integration API answers with a data array alongside paging counters. Both are
unwrapped to the same shape so that the caller never has to care which answered.
Supplying only one path restricts the request to that transport, which is how capabilities unique to the network interface,
such as network definitions and wireless configuration, are expressed.
.PARAMETER Context
The connection context produced by Connect-UnifiController.
.PARAMETER NetworkPath
The path relative to the network application, for example 'api/s/default/stat/device'.
.PARAMETER IntegrationPath
The path relative to the integration API, for example 'v1/sites'.
.PARAMETER QueryString
An optional query string appended to whichever path is used, supplied without a leading delimiter.
.EXAMPLE
$Result = Invoke-UnifiRequest -Context $UnifiContext -NetworkPath "api/s/$($SiteName)/stat/device"
.NOTES
Author: Grace Solutions
Version: 2026.08.02.0000
#>
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[PSObject]$Context,
[Parameter(Mandatory=$False)]
[AllowEmptyString()]
[String]$NetworkPath = '',
[Parameter(Mandatory=$False)]
[AllowEmptyString()]
[String]$IntegrationPath = '',
[Parameter(Mandatory=$False)]
[AllowEmptyString()]
[String]$QueryString = ''
)
Try
{
#region Build the attempt order, honoring the transport that the context selected
$AttemptList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]'
[ScriptBlock]$AddAttempt = {
Param
(
[String]$Transport,
[String]$Path,
[System.Collections.Specialized.OrderedDictionary]$Header,
[Boolean]$PathWasSupplied
)
Switch (($PathWasSupplied -eq $True) -and ($Null -ine $Header))
{
{($_ -eq $True)}
{
$AttemptProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$AttemptProperties.Transport = "$($Transport)"
$AttemptProperties.Path = "$($Path)"
$AttemptProperties.Header = $Header
$AttemptList.Add((New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($AttemptProperties)))
}
}
}
$NetworkURIPath = "$($Context.NetworkPrefix)/$("$($NetworkPath)".TrimStart('/'))"
$IntegrationURIPath = "$($Context.NetworkPrefix)/integration/$("$($IntegrationPath)".TrimStart('/'))"
$NetworkWasSupplied = [String]::IsNullOrWhiteSpace($NetworkPath) -eq $False
$IntegrationWasSupplied = [String]::IsNullOrWhiteSpace($IntegrationPath) -eq $False
Switch ($Context.Mode -ieq 'Controller')
{
{($_ -eq $True)}
{
$Null = $AddAttempt.InvokeReturnAsIs('Network', $NetworkURIPath, $Context.ControllerHeader, $NetworkWasSupplied)
$Null = $AddAttempt.InvokeReturnAsIs('Integration', $IntegrationURIPath, $Context.IntegrationHeader, $IntegrationWasSupplied)
}
{($_ -eq $False)}
{
$Null = $AddAttempt.InvokeReturnAsIs('Integration', $IntegrationURIPath, $Context.IntegrationHeader, $IntegrationWasSupplied)
$Null = $AddAttempt.InvokeReturnAsIs('Network', $NetworkURIPath, $Context.ControllerHeader, $NetworkWasSupplied)
}
}
#endregion
#region Execute each attempt until one produces a usable payload
$ResultProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ResultProperties.IsSuccess = $False
$ResultProperties.Transport = 'None'
$ResultProperties.Result = $Null
$ResultProperties.ErrorMessage = 'No transport was available for this request.'
$QueryDelimiter = Switch ([String]::IsNullOrWhiteSpace($QueryString)) {{($_ -eq $False)} {'?'} Default {''}}
:AttemptLoop For ($AttemptIndex = 0; $AttemptIndex -lt $AttemptList.Count; $AttemptIndex++)
{
$Attempt = $AttemptList[$AttemptIndex]
$Response = Invoke-RestAPIRequest -URI ([System.URI]"$($Context.BaseAddress)$($Attempt.Path)$($QueryDelimiter)$($QueryString)") -Method 'GET' -Header ($Attempt.Header) -SkipCertificateCheck:($Context.SkipCertificateCheck) -AcceptableStatusCodeList @('*') -MaximumRetryCount 1
#region Both transports carry their rows within a data array, but only one reports a result code
$ResponseIsUsable = ($Response.StatusCode -ge 200) -and ($Response.StatusCode -lt 300)
Switch (($ResponseIsUsable -eq $True) -and ([String]::IsNullOrWhiteSpace("$($Response.Content.meta.rc)") -eq $False))
{
{($_ -eq $True)}
{
$ResponseIsUsable = "$($Response.Content.meta.rc)" -ieq 'ok'
}
}
Switch (($ResponseIsUsable -eq $True) -and ($Null -ieq $Response.Content.data))
{
{($_ -eq $True)}
{
$ResponseIsUsable = $False
}
}
#endregion
Switch ($ResponseIsUsable)
{
{($_ -eq $True)}
{
$ResultProperties.IsSuccess = $True
$ResultProperties.Transport = "$($Attempt.Transport)"
$ResultProperties.Result = $Response.Content.data
$ResultProperties.ErrorMessage = $Null
Break AttemptLoop
}
{($_ -eq $False)}
{
$ResultProperties.ErrorMessage = "$($Attempt.Transport) returned status code $($Response.StatusCode). $($Response.Content.meta.msg)$($Response.Content.message)".Trim()
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - $($Attempt.Transport) could not answer `"$($Attempt.Path)`". [$($ResultProperties.ErrorMessage)]"
}
}
}
#endregion
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
}
Catch
{
Throw
}
}
#endregion