Private/Get-GraphPagedResults.ps1
|
function Get-GraphPagedResults { <# .SYNOPSIS Fetches all pages of a Graph API paginated response .DESCRIPTION Handles the @odata.nextLink pagination pattern used by Microsoft Graph API. Can either accumulate all results and return them, or invoke a scriptblock per page for streaming/processing scenarios. .PARAMETER Uri The initial Graph API URI (relative, e.g., "beta/deviceManagement/configurationPolicies") .PARAMETER Headers Optional headers to include in the request .PARAMETER ProcessItems Optional scriptblock invoked with each page's .value array. When provided, items are NOT accumulated — the caller handles them in the scriptblock. .EXAMPLE # Accumulate all results $allPolicies = Get-GraphPagedResults -Uri "beta/deviceManagement/configurationPolicies" .EXAMPLE # Process each page (streaming) Get-GraphPagedResults -Uri "beta/groups" -ProcessItems { param($items) $items | ForEach-Object { ... } } #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string]$Uri, [Parameter()] [hashtable]$Headers, [Parameter()] [scriptblock]$ProcessItems ) $results = [System.Collections.Generic.List[object]]::new() $listUri = $Uri $maxRetries = 3 $baseRetryDelay = 2 do { $params = @{ Method = 'GET' Uri = $listUri ErrorAction = 'Stop' } if ($Headers) { $params['Headers'] = $Headers } $response = $null $retryCount = 0 while ($true) { try { $response = Invoke-MgGraphRequest @params break } catch { # Invoke-MgGraphRequest deserializes JSON into a Dictionary which throws # on duplicate keys. Fall back to raw HTTP response + ConvertFrom-Json # (returns PSCustomObject where last-key-wins, no error). if ($_.Exception.Message -like '*Item has already been added*' -or ($_.Exception.InnerException -and $_.Exception.InnerException.Message -like '*Item has already been added*')) { Write-Verbose "Dictionary deserialization failed for '$listUri', retrying with raw HTTP response" $httpResponse = Invoke-MgGraphRequest @params -OutputType HttpResponseMessage $jsonContent = $httpResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() $response = $jsonContent | ConvertFrom-Json break } # Handle transient errors (429 throttling, 5xx server errors) with retry $httpStatus = $null if ($_.Exception.Response.StatusCode) { $httpStatus = [int]$_.Exception.Response.StatusCode } $isRetryable = $httpStatus -in @(429, 503) -or ($httpStatus -ge 500 -and $httpStatus -lt 600) if ($isRetryable -and $retryCount -lt $maxRetries) { $retryAfter = 0 if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) { [int]::TryParse([string]$_.Exception.Response.Headers['Retry-After'], [ref]$retryAfter) | Out-Null } $delay = if ($retryAfter -gt 0) { $retryAfter } else { $baseRetryDelay * [Math]::Pow(2, $retryCount) } Write-Verbose "Transient error (HTTP $httpStatus) fetching '$listUri' - retrying after ${delay}s (attempt $($retryCount + 1) of $maxRetries)" Start-Sleep -Seconds $delay $retryCount++ continue } throw } } $responseValue = if ($null -ne $response.value) { $response.value } else { @() } if ($ProcessItems) { & $ProcessItems $responseValue } else { if ($responseValue) { $results.AddRange(@($responseValue)) } } $listUri = $response.'@odata.nextLink' } while ($listUri) if (-not $ProcessItems) { return , $results.ToArray() } } |