Public/Google/Invoke-GoogleBatchRequest.ps1
|
<# .SYNOPSIS Sends multiple Google Directory API calls in a single multipart/mixed batch request. .DESCRIPTION The Invoke-GoogleBatchRequest function takes a list of request descriptors (method, path, body, content ID) and sends them to the Google Admin SDK Directory API batch endpoint in chunks. Each chunk is one HTTP round trip containing up to the chunk size of individual API calls, which substantially reduces wall-clock time on large runs. Note batching does NOT reduce quota usage - every inner call still counts against the Directory API rate limit - and Google does not guarantee execution order of the calls inside one batch, so only order-independent calls (e.g. all of one change type) should share a batch. The multipart response is parsed manually (Invoke-RestMethod cannot parse multipart/mixed): each part's inner HTTP status and JSON body are extracted and returned keyed by the Content-ID supplied on the matching request. Per-item failures are logged as errors and included in the results; they do not stop the remaining items or chunks. .PARAMETER Requests An array of request descriptors. Each descriptor is a hashtable with: Method - the HTTP method of the inner call (e.g. "PUT", "POST") Path - the API path relative to the host (e.g. "/admin/directory/v1/users/123") Body - (optional) the JSON request body as a string ContentId - an identifier echoed back on the matching response part New-IDBridgeGoogleUser and Update-IDBridgeGoogleUser produce these with -AsBatchRequest. .PARAMETER BatchUri The batch endpoint to post to. Defaults to the Admin SDK Directory API batch endpoint. .OUTPUTS [PSCustomObject[]] one per inner call: @{ ContentId; StatusCode; Body } where Body is the parsed JSON response (or raw text if not JSON). StatusCode >= 400 indicates that item failed. .EXAMPLE $requests = $GoogleUsersToUpdate | ForEach-Object { $s = $_.Splat; Update-IDBridgeGoogleUser @s -AsBatchRequest } $results = Invoke-GoogleBatchRequest -Requests $requests Sends all pending user updates in batches and returns the per-user results. .NOTES Version: 1.0 Author: Sam Cattanach Date: 2026-07-05 Purpose: To reduce run time by batching Google Directory API write calls. .LINK https://developers.google.com/admin-sdk/directory/v1/guides/batch #> function Invoke-GoogleBatchRequest { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object[]]$Requests, [Parameter(Mandatory = $false)] [string]$BatchUri = "https://admin.googleapis.com/batch/admin/directory_v1" ) #Import Google API Headers (with access token) try { $headers = Get-GoogleHeaders } catch { Throw $_ } # Google allows up to 1000 calls per batch, but large write batches are known to trigger # rate limiting (503s); 50 per batch is the size Google recommends. $batchSize = 50 $results = [System.Collections.Generic.List[PSCustomObject]]::new() for ($offset = 0; $offset -lt $Requests.Count; $offset += $batchSize) { $chunk = $Requests[$offset..([Math]::Min($offset + $batchSize, $Requests.Count) - 1)] $boundary = "batch_" + [guid]::NewGuid().ToString("N") # Build the multipart/mixed request body: one application/http part per inner call $bodyBuilder = [System.Text.StringBuilder]::new() foreach ($request in $chunk) { [void]$bodyBuilder.Append("--$boundary`r`n") [void]$bodyBuilder.Append("Content-Type: application/http`r`n") [void]$bodyBuilder.Append("Content-ID: <$($request.ContentId)>`r`n`r`n") [void]$bodyBuilder.Append("$($request.Method) $($request.Path)`r`n") if ($request.Body) { [void]$bodyBuilder.Append("Content-Type: application/json`r`n`r`n") [void]$bodyBuilder.Append("$($request.Body)`r`n") } else { [void]$bodyBuilder.Append("`r`n") } } [void]$bodyBuilder.Append("--$boundary--`r`n") Write-Log -Message "Google: Sending batch request with $($chunk.Count) calls" -Level Trace try { # Body is passed as UTF-8 bytes so names with non-ASCII characters survive intact $response = Invoke-WebRequest -Uri $BatchUri -Method Post -Headers $headers -ContentType "multipart/mixed; boundary=$boundary" -Body ([System.Text.Encoding]::UTF8.GetBytes($bodyBuilder.ToString())) } catch { Write-Log -Message "Google: Batch request failed: $($_.Exception.Message)" -Level Error continue } # The response uses its own boundary - read it from the response Content-Type header $responseContentType = [string]$response.Headers.'Content-Type' if ($responseContentType -notmatch 'boundary=(\S+)') { Write-Log -Message "Google: Batch response was not multipart (Content-Type: $responseContentType)" -Level Error continue } $responseBoundary = $Matches[1] # multipart/mixed is not a text type Invoke-WebRequest decodes, so Content arrives as bytes $responseContent = if ($response.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($response.Content) } else { [string]$response.Content } foreach ($part in ($responseContent -split "--$([regex]::Escape($responseBoundary))")) { if ([string]::IsNullOrWhiteSpace($part) -or $part.Trim() -eq '--') { continue } # Each part is: outer headers (incl. Content-ID) / inner HTTP status line + headers / body $contentId = if ($part -match 'Content-ID:\s*<?response-([^>\r\n]+)>?') { $Matches[1] } else { $null } $statusCode = if ($part -match 'HTTP/\d\.\d\s+(\d{3})') { [int]$Matches[1] } else { 0 } $sections = $part -split "`r`n`r`n", 3 $partBody = $null if ($sections.Count -ge 3 -and -not [string]::IsNullOrWhiteSpace($sections[2])) { try { $partBody = $sections[2] | ConvertFrom-Json } catch { $partBody = $sections[2].Trim() } } if ($statusCode -ge 400) { Write-Log -Message "Google: Batch item $contentId failed ($statusCode): $($partBody.error.message)" -Level Error } else { Write-Log -Message "Google: Batch Response ($statusCode) for $($contentId): $($partBody | ConvertTo-Json -Depth 5 -Compress)" } $results.Add([PSCustomObject]@{ ContentId = $contentId StatusCode = $statusCode Body = $partBody }) } } return $results } |