Private/Graph/Invoke-GraphApi.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 Get-CleanApiError { <# .SYNOPSIS Extracts a clean error message from Graph/ARM API error responses. .DESCRIPTION API error responses come as JSON blobs. This extracts just the code and message for clean warning output instead of dumping raw JSON. #> [CmdletBinding()] param([System.Management.Automation.ErrorRecord]$ErrorRecord) $raw = $ErrorRecord.ErrorDetails.Message if (-not $raw) { return $ErrorRecord.Exception.Message } try { $parsed = $raw | ConvertFrom-Json -ErrorAction Stop if ($parsed.error) { $code = $parsed.error.code $msg = $parsed.error.message if ($code -and $msg) { return "$code — $msg" } if ($msg) { return $msg } } if ($parsed.error_description) { return $parsed.error_description } } catch { # Not JSON — return raw but truncate if huge } if ($raw.Length -gt 200) { return $raw.Substring(0, 200) + '...' } return $raw } function Invoke-GraphApi { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AccessToken, [Parameter(Mandatory)] [string]$Uri, [ValidateSet('Get', 'Post', 'Patch', 'Delete')] [string]$Method = 'Get', [hashtable]$Body, [hashtable]$QueryParameters, [int]$MaxRetries = 3, [switch]$Paginate, [string]$ApiVersion = 'v1.0', [switch]$Beta, [switch]$Quiet, [int]$ThrottleDelayMs = 0, [int]$MaxPages = 0, [string]$ConsistencyLevel ) $headers = @{ Authorization = "Bearer $AccessToken" } if ($ConsistencyLevel) { $headers['ConsistencyLevel'] = $ConsistencyLevel } # Determine base URL $baseUrl = 'https://graph.microsoft.com' $version = if ($Beta) { 'beta' } else { $ApiVersion } # If URI is a relative path, prepend base URL + version $fullUri = if ($Uri -match '^https?://') { $Uri } else { $cleanUri = $Uri.TrimStart('/') "$baseUrl/$version/$cleanUri" } # Append query parameters if ($QueryParameters -and $QueryParameters.Count -gt 0) { $queryString = ($QueryParameters.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')) { $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)) { # Throttled — respect Retry-After header $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 "Graph API throttled (429), waiting ${wait}s (attempt $($attempt + 1)/$MaxRetries)" Start-Sleep -Seconds $wait } elseif ($statusCode -in @(503, 504) -and $attempt -lt ($MaxRetries - 1)) { $wait = [Math]::Pow(2, $attempt + 1) Write-Verbose "Graph API unavailable ($statusCode), waiting ${wait}s" Start-Sleep -Seconds $wait } elseif ($statusCode -eq 400) { Write-Warning "Graph API 400 for $($Uri): $(Get-CleanApiError $_)" return $null } elseif ($statusCode -in @(401, 403)) { $cleanMsg = Get-CleanApiError $_ throw "Graph API $statusCode for $($Uri): $cleanMsg — Verify app permissions (Application, not Delegated) and admin consent." } elseif ($statusCode -eq 404) { Write-Verbose "Graph API resource not found (404) for $requestUri" return $null } else { if ($attempt -eq ($MaxRetries - 1)) { Write-Warning "Graph 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) { # Collect items from value array $items = $response.value if ($items) { foreach ($item in @($items)) { $allItems.Add($item) } } $nextLink = $response.'@odata.nextLink' $pageCount++ if (-not $Quiet -and $pageCount % 5 -eq 0) { Write-Verbose "Fetched $pageCount pages, $($allItems.Count) items so far" } if ($ThrottleDelayMs -gt 0 -and $nextLink) { Start-Sleep -Milliseconds $ThrottleDelayMs } if ($MaxPages -gt 0 -and $pageCount -ge $MaxPages) { Write-Verbose "Reached max pages limit ($MaxPages)" break } } else { # Non-paginated: return raw response return $response } } while ($Paginate -and $nextLink) if ($Paginate) { return @($allItems) } return $response } # ── Batch Request Helper ────────────────────────────────────────────────── function Invoke-GraphBatchRequest { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AccessToken, [Parameter(Mandatory)] [hashtable[]]$Requests, [switch]$Beta ) $version = if ($Beta) { 'beta' } else { 'v1.0' } $batchUri = "https://graph.microsoft.com/$version/`$batch" # Process in chunks of 20 (Graph batch limit) $results = [System.Collections.Generic.List[PSCustomObject]]::new() $chunks = for ($i = 0; $i -lt $Requests.Count; $i += 20) { , @($Requests[$i..[Math]::Min($i + 19, $Requests.Count - 1)]) } foreach ($chunk in $chunks) { $batchBody = @{ requests = @($chunk | ForEach-Object { $_ }) } $headers = @{ Authorization = "Bearer $AccessToken" 'Content-Type' = 'application/json' } try { $response = Invoke-RestMethod -Uri $batchUri -Method Post ` -Headers $headers -Body ($batchBody | ConvertTo-Json -Depth 20) -ErrorAction Stop foreach ($resp in $response.responses) { $results.Add([PSCustomObject]@{ Id = $resp.id Status = $resp.status Body = $resp.body Headers = $resp.headers }) } } catch { Write-Warning "Graph batch request failed: $(Get-CleanApiError $_)" } } return @($results) } |