Public/Invoke-MgGraphCommunityBatch.ps1
|
function Invoke-MgGraphCommunityBatch { <# .SYNOPSIS Sends multiple Microsoft Graph requests in a single $batch call. .DESCRIPTION Combines up to 20 requests per call into Microsoft Graph's JSON $batch endpoint, reducing round-trips. More than 20 requests are split into consecutive batches automatically. Uses the active MgGraphCommunity session (auth, proactive/reactive refresh, and transient-error retry are inherited from Invoke-MgGraphCommunityRequest). Throttled sub-responses (HTTP 429 inside the batch) are retried automatically, honoring each sub-response's Retry-After, up to -MaxRetry rounds. Returns one response object per submitted request, in submitted order: id - the request id (auto-assigned 1..N if you did not set one) status - the sub-request HTTP status code headers - the sub-response headers body - the parsed sub-response body .PARAMETER Requests The requests to batch. Each item is a hashtable or object with: Method (required) GET / POST / PUT / PATCH / DELETE Url (required) relative Graph URL, e.g. '/me' or '/users/{id}' Id (optional) correlation id; auto-assigned if omitted Body (optional) request body (objects are serialized to JSON) Headers (optional) per-request headers (Content-Type added for bodies) DependsOn (optional) array of ids this request must run after .PARAMETER Beta Target the /beta $batch endpoint. Beta is already the default; retained for backward compatibility. .PARAMETER V1 Target the stable /v1.0 $batch endpoint instead of the default /beta. Takes precedence over -Beta if both are supplied. Note this only sets the $batch endpoint version; each sub-request's own relative Url is resolved by Graph against that same version. .PARAMETER MaxRetry Maximum retry rounds for throttled (429) sub-responses. Default 3. .EXAMPLE $responses = Invoke-MgGraphCommunityBatch -Requests @( @{ Method = 'GET'; Url = '/me' }, @{ Method = 'GET'; Url = '/me/memberOf' } ) $responses[0].body.displayName .EXAMPLE Invoke-MgcBatch -Requests @( @{ Id = 'g'; Method = 'POST'; Url = '/groups'; Body = @{ displayName = 'X'; mailEnabled = $false; mailNickname = 'x'; securityEnabled = $true } } ) #> [CmdletBinding()] [Alias('Invoke-MgcBatch')] param( [Parameter(Mandatory, Position = 0)] [object[]]$Requests, [switch]$Beta, [switch]$V1, [ValidateRange(0, 10)] [int]$MaxRetry = 3 ) if (-not $script:MgcActiveSession) { throw "Not connected. Run Connect-MgGraphCommunity first." } if ($Requests.Count -eq 0) { return @() } # Normalize each input into a Graph batch sub-request, assigning a stable id. $getProp = { param($item, [string]$name) if ($item -is [System.Collections.IDictionary]) { foreach ($k in $item.Keys) { if ("$k" -eq $name) { return $item[$k] } } return $null } $p = $item.PSObject.Properties[$name] if ($p) { return $p.Value } return $null } $normalized = New-Object System.Collections.Generic.List[object] $i = 0 foreach ($req in $Requests) { $i++ $method = & $getProp $req 'Method' $url = & $getProp $req 'Url' if (-not $url) { $url = & $getProp $req 'Uri' } # tolerate Uri as an alias if (-not $method) { throw "Batch request #$i is missing a Method." } if (-not $url) { throw "Batch request #$i is missing a Url." } $id = & $getProp $req 'Id' if (-not $id) { $id = "$i" } $id = "$id" # Graph requires relative URLs in the batch body ('/me', not the absolute host). $relUrl = "$url" $relUrl = $relUrl -replace '^https?://[^/]+/(v1\.0|beta)', '' if ($relUrl -notmatch '^/') { $relUrl = "/$relUrl" } $entry = [ordered]@{ id = $id method = "$method".ToUpperInvariant() url = $relUrl } $headers = & $getProp $req 'Headers' $body = & $getProp $req 'Body' if ($null -ne $body) { $entry['body'] = $body # Graph requires a Content-Type header whenever a body is present. $h = @{} if ($headers) { foreach ($k in $headers.Keys) { $h[$k] = $headers[$k] } } if (-not ($h.Keys | Where-Object { "$_" -ieq 'Content-Type' })) { $h['Content-Type'] = 'application/json' } $entry['headers'] = $h } elseif ($headers) { $entry['headers'] = $headers } $dependsOn = & $getProp $req 'DependsOn' if ($dependsOn) { $entry['dependsOn'] = @($dependsOn | ForEach-Object { "$_" }) } $normalized.Add([pscustomobject]@{ Id = $id; Order = $i; Entry = $entry }) } # Build an ABSOLUTE $batch URL. A relative '/beta/$batch' would be re-prefixed # with the version segment by Invoke-MgGraphCommunityRequest (-> /beta/beta/$batch). # Default to /beta (more surface area); -V1 opts down to the stable endpoint. $apiVer = if ($V1) { 'v1.0' } else { 'beta' } $batchUri = "$($script:MgcActiveSession.Authority.GraphResource)/$apiVer/`$batch" $resultsById = @{} # Send in chunks of 20 (Graph hard limit), retrying throttled sub-requests. for ($start = 0; $start -lt $normalized.Count; $start += 20) { $end = [Math]::Min($start + 19, $normalized.Count - 1) $chunk = $normalized[$start..$end] # Map of id -> entry for this chunk; pending starts as every entry. $entriesById = @{} foreach ($n in $chunk) { $entriesById[$n.Id] = $n.Entry } $pendingIds = [System.Collections.Generic.List[string]]@($chunk | ForEach-Object { $_.Id }) $round = 0 while ($pendingIds.Count -gt 0) { $payload = @{ requests = @($pendingIds | ForEach-Object { $entriesById[$_] }) } $resp = Invoke-MgGraphCommunityRequest -Method POST -Uri $batchUri -Body $payload if ($null -eq $resp -or $null -eq $resp.responses) { break } $throttled = [System.Collections.Generic.List[string]]@() $maxRetryAfter = 0 $sawRetryAfter = $false foreach ($r in @($resp.responses)) { $rid = "$($r.id)" if ($r.status -eq 429 -and $round -lt $MaxRetry) { $throttled.Add($rid) if ($r.headers) { $raVal = $r.headers.'Retry-After' if ($null -ne $raVal -and "$raVal" -ne '') { try { $ra = [int]([array]$raVal)[0]; $sawRetryAfter = $true; if ($ra -gt $maxRetryAfter) { $maxRetryAfter = $ra } } catch { } } } } else { $resultsById[$rid] = [pscustomobject]@{ id = $rid status = $r.status headers = $r.headers body = $r.body } } } if ($throttled.Count -eq 0) { break } $round++ $sleep = if ($sawRetryAfter) { $maxRetryAfter } else { [int][Math]::Min(60, [Math]::Pow(2, $round) * 2) } Write-Verbose ("Batch: {0} sub-request(s) throttled - retry round {1}/{2} after {3}s." -f $throttled.Count, $round, $MaxRetry, $sleep) Start-Sleep -Seconds ([Math]::Max(0, $sleep)) $pendingIds = $throttled } } # Return in submitted order. Emit a null-status placeholder for any id Graph # never returned, so the output stays index-aligned with the input. return $normalized | ForEach-Object { if ($resultsById.ContainsKey($_.Id)) { $resultsById[$_.Id] } else { [pscustomobject]@{ id = $_.Id; status = $null; headers = $null; body = $null } } } } |