M365cde.Invoke-GraphRequestPaged.psm1

function Invoke-GraphRequestPaged {
<#
.SYNOPSIS
Retrieves all pages of a Microsoft Graph GET request.

.DESCRIPTION
Calls Invoke-MgGraphRequest (Microsoft.Graph.Authentication) with the given URI and follows '@odata.nextLink' until all pages are retrieved.
Throttled or temporarily unavailable responses (HTTP 429, 503, 504) are retried, honoring the 'Retry-After' header and falling back to exponential backoff.
Each item of the 'value' collection is written to the pipeline; responses without a 'value' property (single objects) are written as they are.
An active connection to Microsoft Graph (Connect-MgGraph) with sufficient permissions is required. Works in scripts and Azure Automation runbooks.

.PARAMETER Uri
The Microsoft Graph URI to query, relative (e.g. '/v1.0/users') or absolute (e.g. 'https://graph.microsoft.com/v1.0/users').

.PARAMETER Headers
Optional additional request headers, e.g. @{ ConsistencyLevel = 'eventual' } for advanced queries.

.PARAMETER MaxRetries
Maximum number of retries per page for HTTP 429, 503 and 504. Default is 5.

.PARAMETER AsHashtable
Returns the raw hashtables of Invoke-MgGraphRequest instead of PSCustomObjects. By default results are converted (recursively) to PSCustomObjects so that Format-Table, Select-Object, Export-Csv etc. work as expected.

.EXAMPLE
Invoke-GraphRequestPaged -Uri 'https://graph.microsoft.com/v1.0/users?$select=displayName,userPrincipalName'

Returns all users.

.EXAMPLE
Invoke-GraphRequestPaged -Uri '/v1.0/users?$count=true&$filter=startsWith(displayName,''A'')' -Headers @{ ConsistencyLevel = 'eventual' }

Returns all matching users using an advanced query.
#>

    [CmdletBinding()]
    [OutputType([object])]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

        [System.Collections.IDictionary]$Headers,

        [ValidateRange(0, 20)]
        [int]$MaxRetries = 5,

        [switch]$AsHashtable
    )

    if (-not (Get-MgContext)) {
        throw 'Not connected to Microsoft Graph. Run Connect-MgGraph first.'
    }

    $nextUri = $Uri
    while ($nextUri) {
        $params = @{ Method = 'GET'; Uri = $nextUri; ErrorAction = 'Stop' }
        if ($Headers) { $params.Headers = $Headers }

        $attempt = 0
        while ($true) {
            try {
                $response = Invoke-MgGraphRequest @params
                break
            }
            catch {
                $status = Get-GraphErrorStatusCode -ErrorRecord $_
                if ($status -in 429, 503, 504 -and $attempt -lt $MaxRetries) {
                    $attempt++
                    $wait = Get-GraphRetryAfterSeconds -ErrorRecord $_
                    if (-not $wait) { $wait = [math]::Pow(2, $attempt) }
                    Write-Verbose "HTTP $status - retry $attempt/$MaxRetries in $wait s."
                    Start-Sleep -Seconds $wait
                }
                else { throw }
            }
        }

        if ($response -is [System.Collections.IDictionary] -and $response.Contains('value')) {
            # Enumerate explicitly: an empty page must not emit $null.
            foreach ($item in $response['value']) {
                if ($AsHashtable) { $item } else { ConvertTo-GraphObject -InputObject $item }
            }
        }
        elseif ($null -ne $response) {
            # single object
            if ($AsHashtable) { $response } else { ConvertTo-GraphObject -InputObject $response }
        }

        $nextUri = if ($response -is [System.Collections.IDictionary]) { $response['@odata.nextLink'] }
    }
}

function ConvertTo-GraphObject {
    [CmdletBinding()]
    param ($InputObject)

    if ($InputObject -is [System.Collections.IDictionary]) {
        $ordered = [ordered]@{}
        foreach ($key in $InputObject.Keys) {
            $ordered[[string]$key] = ConvertTo-GraphObject -InputObject $InputObject[$key]
        }
        [pscustomobject]$ordered
    }
    elseif ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
        # Keep arrays as arrays (the comma prevents unrolling of single/empty arrays).
        , @(foreach ($element in $InputObject) { ConvertTo-GraphObject -InputObject $element })
    }
    else {
        $InputObject
    }
}

function Get-GraphErrorStatusCode {
    [CmdletBinding()]
    [OutputType([int])]
    param ([System.Management.Automation.ErrorRecord]$ErrorRecord)

    $webResponse = $ErrorRecord.Exception.Response
    if ($webResponse -and $null -ne $webResponse.StatusCode) { return [int]$webResponse.StatusCode }
    return 0
}

function Get-GraphRetryAfterSeconds {
    [CmdletBinding()]
    [OutputType([int])]
    param ([System.Management.Automation.ErrorRecord]$ErrorRecord)

    $webResponse = $ErrorRecord.Exception.Response
    if (-not $webResponse -or -not $webResponse.Headers) { return 0 }

    $value = $null
    $headers = $webResponse.Headers
    if ($headers -is [System.Net.Http.Headers.HttpHeaders]) {
        # PowerShell 7 (HttpResponseMessage)
        $values = $null
        if ($headers.TryGetValues('Retry-After', [ref]$values)) { $value = $values | Select-Object -First 1 }
    }
    else {
        # Windows PowerShell 5.1 (WebHeaderCollection)
        $value = $headers['Retry-After']
    }

    $seconds = 0
    if ([int]::TryParse([string]$value, [ref]$seconds) -and $seconds -gt 0) {
        return [math]::Min($seconds, 300)
    }
    return 0
}

Export-ModuleMember -Function Invoke-GraphRequestPaged