Private/Graph/Invoke-IntuneDiffRequest.ps1

# Copyright (c) 2026 Sandy Zeng. All rights reserved.
# Source-available. All rights reserved. See LICENSE file.

<#
    Invoke-IntuneDiffRequest.ps1 — REST wrapper for Microsoft Graph with pagination, 429
    retry, and silent token refresh on 401. Uses Invoke-RestMethod directly so the module
    has no dependency on Microsoft.Graph.Authentication.
 
    Author: Sandy Zeng
    Project: IntuneDiff
 
    Version History:
    1.0.0 Initial release.
    2.0.0 Rewritten to use Invoke-RestMethod with a Bearer token — no Microsoft.Graph SDK.
#>


function Invoke-IntuneDiffRequest {
    <#
    .SYNOPSIS
        Sends a Microsoft Graph REST request with pagination and 429 retry.
 
    .PARAMETER Method
        HTTP method. Defaults to GET.
 
    .PARAMETER Uri
        Absolute (https://graph.microsoft.com/...) or relative (/beta/...) Graph URI.
 
    .PARAMETER Body
        Optional request body (hashtable) for POST/PATCH.
 
    .PARAMETER All
        For GET requests, follow @odata.nextLink until exhausted and return a hashtable
        whose `value` property contains every page concatenated.
 
    .PARAMETER TimeoutSec
        Per-request timeout. Defaults to 30 seconds.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter(Mandatory)]
        [string]$Uri,

        [object]$Body,

        [switch]$All,

        [int]$TimeoutSec = 30
    )

    Write-IDLog "Graph $Method $Uri"
    $maxRetries     = 5
    $tokenRefreshed = $false

    # Normalize relative URIs to absolute
    if ($Uri -notmatch '^https?://') {
        if ($Uri.StartsWith('/')) {
            $Uri = "https://graph.microsoft.com$Uri"
        } else {
            $Uri = "https://graph.microsoft.com/$Uri"
        }
    }

    $invokeOnce = {
        param([string]$method, [string]$uri, [object]$bodyJson, [int]$timeoutSec)
        $headers = @{
            Authorization = "Bearer $(Get-IDAccessToken)"
            Accept        = 'application/json'
        }
        $params = @{
            Method      = $method
            Uri         = $uri
            Headers     = $headers
            TimeoutSec  = $timeoutSec
            ErrorAction = 'Stop'
        }
        if ($bodyJson) {
            $params['Body']        = $bodyJson
            $params['ContentType'] = 'application/json'
        }
        return Invoke-RestMethod @params
    }

    $jsonBody = $null
    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        $jsonBody = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 -Compress }
    }

    if ($Method -eq 'GET' -and $All) {
        $aggregated    = @()
        $firstResponse = $null
        $currentUri    = $Uri

        while ($currentUri) {
            $attempt  = 0
            $response = $null
            while ($true) {
                try {
                    $response = & $invokeOnce 'GET' $currentUri $null $TimeoutSec
                    break
                } catch {
                    $statusCode = $null
                    if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode }

                    if ($statusCode -eq 429 -and $attempt -lt $maxRetries) {
                        $retryAfter = 2
                        try {
                            $headerValue = $_.Exception.Response.Headers['Retry-After']
                            if ($headerValue) { $retryAfter = [int]$headerValue }
                        } catch { }
                        Start-Sleep -Seconds $retryAfter
                        $attempt++
                        continue
                    }

                    if ($statusCode -eq 401 -and -not $tokenRefreshed) {
                        Write-IDLog '401 Unauthorized — forcing token refresh and retrying'
                        try { Get-IDAccessToken -ForceRefresh | Out-Null } catch { throw }
                        $tokenRefreshed = $true
                        continue
                    }

                    throw
                }
            }

            if (-not $firstResponse) { $firstResponse = $response }

            if ($response.value) { $aggregated += $response.value }

            $nextLink = $response.'@odata.nextLink'
            if ($nextLink -and $nextLink -notmatch '^https://graph\.microsoft\.com/') {
                Write-Warning "IntuneDiff: Ignoring untrusted @odata.nextLink: $nextLink"
                $nextLink = $null
            }
            $currentUri = $nextLink
        }

        if ($firstResponse -is [hashtable]) {
            $firstResponse['value'] = $aggregated
            return $firstResponse
        }
        return @{ value = $aggregated }
    }

    # Single-shot (non-paginated) request
    $attempt = 0
    while ($true) {
        try {
            return (& $invokeOnce $Method $Uri $jsonBody $TimeoutSec)
        } catch {
            $statusCode = $null
            if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode }

            if ($statusCode -eq 429 -and $attempt -lt $maxRetries) {
                $retryAfter = 2
                try {
                    $headerValue = $_.Exception.Response.Headers['Retry-After']
                    if ($headerValue) { $retryAfter = [int]$headerValue }
                } catch { }
                Start-Sleep -Seconds $retryAfter
                $attempt++
                continue
            }

            if ($statusCode -eq 401 -and -not $tokenRefreshed) {
                Write-IDLog '401 Unauthorized — forcing token refresh and retrying'
                try { Get-IDAccessToken -ForceRefresh | Out-Null } catch { throw }
                $tokenRefreshed = $true
                continue
            }

            throw
        }
    }
}