Private/Graph/Invoke-AzureRMApi.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Invoke-AzureRMApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

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

        [ValidateSet('Get', 'Post', 'Patch', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [hashtable]$Body,
        [hashtable]$QueryParameters,

        [string]$ApiVersion = '2022-12-01',

        [int]$MaxRetries = 3,

        [switch]$Paginate,

        [switch]$Quiet
    )

    $headers = @{
        Authorization = "Bearer $AccessToken"
    }

    $baseUrl = 'https://management.azure.com'

    # Build full URI
    $fullUri = if ($Uri -match '^https?://') {
        $Uri
    } else {
        $cleanUri = $Uri.TrimStart('/')
        "$baseUrl/$cleanUri"
    }

    # Ensure api-version is included
    $params = @{}
    if ($QueryParameters) {
        foreach ($key in $QueryParameters.Keys) {
            $params[$key] = $QueryParameters[$key]
        }
    }
    if (-not $params.ContainsKey('api-version') -and -not ($fullUri -match 'api-version=')) {
        $params['api-version'] = $ApiVersion
    }

    if ($params.Count -gt 0) {
        $queryString = ($params.GetEnumerator() | ForEach-Object {
            "$($_.Key)=$([System.Uri]::EscapeDataString($_.Value.ToString()))"
        }) -join '&'
        $separator = if ($fullUri.Contains('?')) { '&' } else { '?' }
        $fullUri = "$fullUri$separator$queryString"
    }

    $allItems = [System.Collections.Generic.List[PSCustomObject]]::new()
    $nextLink = $null
    $pageCount = 0

    do {
        $requestUri = if ($nextLink) { $nextLink } else { $fullUri }
        $response = $null

        for ($attempt = 0; $attempt -lt $MaxRetries; $attempt++) {
            try {
                $invokeParams = @{
                    Uri         = $requestUri
                    Headers     = $headers
                    Method      = $Method
                    ErrorAction = 'Stop'
                }
                if ($Body -and $Method -in @('Post', 'Patch', 'Put')) {
                    $invokeParams['Body'] = ($Body | ConvertTo-Json -Depth 20)
                    $invokeParams['ContentType'] = 'application/json'
                }

                $response = Invoke-RestMethod @invokeParams
                break
            } catch {
                $statusCode = $_.Exception.Response.StatusCode.value__

                if ($statusCode -eq 429 -and $attempt -lt ($MaxRetries - 1)) {
                    $retryAfter = $_.Exception.Response.Headers |
                        Where-Object { $_.Key -eq 'Retry-After' } |
                        Select-Object -ExpandProperty Value -First 1
                    $wait = if ($retryAfter) { [int]$retryAfter[0] } else { [Math]::Pow(2, $attempt + 1) }
                    Write-Verbose "ARM API throttled (429), waiting ${wait}s"
                    Start-Sleep -Seconds $wait
                } elseif ($statusCode -in @(503, 504) -and $attempt -lt ($MaxRetries - 1)) {
                    $wait = [Math]::Pow(2, $attempt + 1)
                    Write-Verbose "ARM API unavailable ($statusCode), waiting ${wait}s"
                    Start-Sleep -Seconds $wait
                } elseif ($statusCode -eq 400) {
                    Write-Warning "ARM API 400 for $($Uri): $(Get-CleanApiError $_)"
                    return $null
                } elseif ($statusCode -in @(401, 403)) {
                    $cleanMsg = Get-CleanApiError $_
                    throw "ARM API $statusCode for $($Uri): $cleanMsg — Verify Azure RBAC permissions."
                } elseif ($statusCode -eq 404) {
                    Write-Verbose "ARM API resource not found (404) for $requestUri"
                    return $null
                } else {
                    if ($attempt -eq ($MaxRetries - 1)) {
                        Write-Warning "ARM API failed after $MaxRetries retries for $($Uri): $(Get-CleanApiError $_)"
                        return $null
                    }
                    $wait = [Math]::Pow(2, $attempt + 1)
                    Start-Sleep -Seconds $wait
                }
            }
        }

        if (-not $response) { break }

        if ($Paginate) {
            $items = $response.value
            if ($items) {
                foreach ($item in @($items)) {
                    $allItems.Add($item)
                }
            }
            $nextLink = $response.nextLink
            $pageCount++

            if (-not $Quiet -and $pageCount % 5 -eq 0) {
                Write-Verbose "ARM: Fetched $pageCount pages, $($allItems.Count) items"
            }
        } else {
            return $response
        }
    } while ($Paginate -and $nextLink)

    if ($Paginate) {
        return @($allItems)
    }

    return $response
}