private/Invoke-VSARestMethod.ps1
|
function Invoke-VSARestMethod { <# .SYNOPSIS Invokes VSA REST API methods with automatic paging, token renewal and retry. .DESCRIPTION Executes VSA REST API methods and returns the response or success status. Pages through collections using $skip/$top, renews the session token behind the scenes, and retries transient HTTP errors (handled in Get-RequestData). The Filter value is passed through to the server unchanged except for URL-encoding; the caller is responsible for OData value escaping (see ConvertTo-ODataString). .PARAMETER VSAConnection Specifies the established VSAConnection. If omitted, the persistent connection is used. .PARAMETER URISuffix Specifies the URI suffix appended to the server base URI. .PARAMETER Method Specifies the REST API Method (default is "GET"). .PARAMETER Body Specifies the request body for methods that require it. .PARAMETER ContentType Specifies the content type of the request (default is "application/json"). .PARAMETER MaxRetries Maximum number of retry attempts for transient HTTP errors (429, 502, 503, 504). .PARAMETER ExtendedOutput Returns the full response envelope (with the merged Result) instead of just Result. .PARAMETER Filter Specifies an OData $filter expression. Passed through unchanged except URL-encoding. .PARAMETER RecordsPerPage Number of records requested per page via $top (1-100; the server caps at 100). .PARAMETER Skip Number of records to skip ($skip). Numeric string. .PARAMETER Sort Specifies $orderby. Allowed: letters, digits, spaces, commas, hyphens. .PARAMETER OutFile When set, the response body is downloaded to this path (passed to Get-RequestData). .INPUTS Accepts piped VSAConnection. .OUTPUTS Varies based on the method invoked. .NOTES Version 1.1.0 #> [CmdletBinding()] param ( [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [AllowNull()] [VSAConnection] $VSAConnection = $null, [parameter(DontShow, Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $URISuffix, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateSet("GET", "POST", "PUT", "DELETE", "PATCH")] [string] $Method = 'GET', [parameter(Mandatory = $false)] [ValidateNotNull()] # [string] for JSON request bodies, [byte[]] for raw/multipart bodies (e.g. file uploads). [object] $Body, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string] $ContentType = "application/json", [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [switch] $ExtendedOutput, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Filter, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateRange(1, 100)] [int] $RecordsPerPage = 100, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Skip, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [AllowEmptyString()] [string] $Sort, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [int] $MaxRecordsPerSession = 25000, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateRange(0, 10)] [int] $MaxRetries = 3, [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string] $OutFile ) # Renew the session token BEFORE reading it, so a token that is about to expire is # refreshed and the request below uses the fresh token (F-13). $VSAServerURI = [string]::Empty [bool]$IgnoreCertificateErrors = $false if ( $null -eq $VSAConnection ) { Update-VSAConnection $VSAServerURI = Get-VSAPersistentURI $UsersToken = "Bearer $( Get-VSAPersistentToken )" $IgnoreCertificateErrors = Get-VSAPersistentIgnoreCertErrors } else { Update-VSAConnection -VSAConnection $VSAConnection $VSAServerURI = $VSAConnection.URI $UsersToken = "Bearer $($VSAConnection.Token)" $IgnoreCertificateErrors = $VSAConnection.IgnoreCertificateErrors } # Filter is passed through untouched (only URL-encoded when the query is built). OData # value escaping (doubling single quotes) is the caller's responsibility. # Validate Sort ($orderby): allow only letters, digits, spaces, commas, hyphens. if (-not [string]::IsNullOrEmpty($Sort)) { if ($Sort -notmatch '^[\w\s,\-]+$') { throw "Sort parameter contains invalid characters. Allowed: letters, digits, spaces, commas, hyphens. Received: '$Sort'." } } # Validate Skip (must be a non-negative integer). if (-not [string]::IsNullOrEmpty($Skip)) { if ($Skip -notmatch '^\d+$') { throw "Skip parameter validation failed. Received: '$Skip'. Expected a non-negative integer (e.g. '0', '100', '500')." } } $baseUri = New-Object System.Uri -ArgumentList $VSAServerURI [string]$URI = [System.Uri]::new($baseUri, $URISuffix) | Select-Object -ExpandProperty AbsoluteUri # The URISuffix may already carry its own query string (e.g. '?flag=', '?tenantId='). The OData # params below must then be joined with '&', not a second '?', which would build a malformed # double-'?' URL that the server rejects (F-22). $UriSeparator = if ($URI -match '\?') { '&' } else { '?' } [hashtable]$ApiSearchParams = @{} if (-not [string]::IsNullOrEmpty($Filter)) { $ApiSearchParams['$filter'] = $Filter } if (-not [string]::IsNullOrEmpty($Sort)) { $ApiSearchParams['$orderby'] = $Sort } if (-not [string]::IsNullOrEmpty($Skip)) { $ApiSearchParams['$skip'] = $Skip } # Always transmit the page size as $top (default 100, which is also the server ceiling). $ApiSearchParams['$top'] = $RecordsPerPage if ($ApiSearchParams.Count -eq 0) { $CombinedURI = $URI } else { $CombinedURI = '{0}{1}{2}' -f $URI, $UriSeparator, (ConvertTo-VSAQueryString -Parameters $ApiSearchParams) } [hashtable]$WebRequestParams = @{ Uri = $CombinedURI Method = $Method AuthString = $UsersToken IgnoreCertificateErrors = $IgnoreCertificateErrors MaxRetries = $MaxRetries } if ($Body) { $WebRequestParams.Add('Body', $Body) $WebRequestParams.Add('ContentType', $ContentType) } if ($OutFile) { $WebRequestParams.Add('OutFile', $OutFile) } Write-Debug "$($MyInvocation.MyCommand)" Write-Debug "Invoke-VSARestMethod. Request details:" $WebRequestParams | ConvertTo-Json -Depth 3 | Out-String | Write-Debug Write-Verbose "Invoke-VSARestMethod. Calling Get-RequestData on URI: $($WebRequestParams.Uri)" try { $response = Get-RequestData @WebRequestParams } catch { $contextInfo = @( "Failed to retrieve data from VSA API" "URI Suffix: $URISuffix" "Filter: $(if ($Filter) { $Filter } else { 'None' })" "Sort: $(if ($Sort) { $Sort } else { 'None' })" "Skip: $(if ($Skip) { $Skip } else { '0' })" "Records Per Page: $RecordsPerPage" "Method: $Method" "Error: $($_.Exception.Message)" ) -join "`n" throw $contextInfo } Write-Debug "Invoke-VSARestMethod. Response:`n$($response | Out-String)" # A successful empty-body 2xx (HTTP 204 No Content from DELETE / some PUT) comes back as $null # -- there is no envelope to expand or page. Return nothing rather than trying to expand a # non-existent .Result property, which would throw on the success path (F-21). if ($null -eq $response) { return $null } # Use member access, not Select-Object -ExpandProperty: some write endpoints (e.g. the # 'ask before executing' settings PUT) return a status-only envelope with no 'Result' property, # and -ExpandProperty throws on a missing property. '.Result' yields $null there instead (F-23). [array]$result = $response.Result [bool]$paginated = $false if (-not [string]::IsNullOrEmpty("$($response.TotalRecords)")) { $paginated = $true [int]$TotalRecords = $response.TotalRecords Write-Verbose "Invoke-VSARestMethod. TotalRecords: $TotalRecords" $resultCollection = [System.Collections.ArrayList]@($result) # Workaround for SaaS session limitation: renew the token every $MaxRecordsPerSession. [int]$RenewTimes = 1 [int]$RenewThreshold = $MaxRecordsPerSession # The first page (skip 0) is already fetched above; continue while more remain. [int]$skipValue = $RecordsPerPage while ($skipValue -lt $TotalRecords) { $ApiSearchParams['$skip'] = $skipValue $WebRequestParams.Uri = '{0}{1}{2}' -f $URI, $UriSeparator, (ConvertTo-VSAQueryString -Parameters $ApiSearchParams) if ($skipValue -ge $RenewThreshold) { Write-Verbose "Fetching in progress... So far fetched $RenewThreshold records. Renewing session token." if ($null -eq $VSAConnection) { Update-VSAConnection -Force $UsersToken = "Bearer $( Get-VSAPersistentToken )" } else { Update-VSAConnection -VSAConnection $VSAConnection -Force $UsersToken = "Bearer $($VSAConnection.Token)" } $WebRequestParams.AuthString = $UsersToken $RenewTimes += 1 $RenewThreshold = $MaxRecordsPerSession * $RenewTimes } [array]$temp = Get-RequestData @WebRequestParams | Select-Object -ExpandProperty Result if ($temp.Count -gt 0) { $resultCollection.AddRange($temp) | Out-Null } $skipValue += $RecordsPerPage } $result = $resultCollection.ToArray() } Write-Debug "Invoke-VSARestMethod. Result`n $($result | Out-String)" if ($ExtendedOutput) { if ($paginated) { # Return the envelope but with the fully-merged Result (F-57). $merged = $response.PSObject.Copy() $merged.Result = $result return $merged } return $response } return $result } |