pt.EntraGraphUtils.psm1
|
# .ExternalHelp pt.EntraGraphUtils-help.xml #region Private Functions #region ConvertFrom-QueryString <# .SYNOPSIS Convert Query String to object. .EXAMPLE PS C:\>ConvertFrom-QueryString '?name=path/file.json&index=10' Convert query string to object. .EXAMPLE PS C:\>'name=path/file.json&index=10' | ConvertFrom-QueryString -AsHashtable Convert query string to hashtable. .INPUTS System.String .LINK https://github.com/jasoth/Utility.PS #> function ConvertFrom-QueryString { [CmdletBinding()] [OutputType([psobject])] [OutputType([hashtable])] param ( # Value to convert [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] [string[]] $InputStrings, # URL decode parameter names [Parameter(Mandatory = $false)] [switch] $DecodeParameterNames, # Converts to hash table object [Parameter(Mandatory = $false)] [switch] $AsHashtable ) process { foreach ($InputString in $InputStrings) { if ($AsHashtable) { [hashtable] $OutputObject = @{ } } else { [psobject] $OutputObject = New-Object psobject } if ($InputString[0] -eq '?') { $InputString = $InputString.Substring(1) } [string[]] $QueryParameters = $InputString.Split('&') foreach ($QueryParameter in $QueryParameters) { [string[]] $QueryParameterPair = $QueryParameter.Split('=') if ($DecodeParameterNames) { $QueryParameterPair[0] = [System.Net.WebUtility]::UrlDecode($QueryParameterPair[0]) } if ($OutputObject -is [hashtable]) { $OutputObject.Add($QueryParameterPair[0], [System.Net.WebUtility]::UrlDecode($QueryParameterPair[1])) } else { $OutputObject | Add-Member $QueryParameterPair[0] -MemberType NoteProperty -Value ([System.Net.WebUtility]::UrlDecode($QueryParameterPair[1])) } } Write-Output $OutputObject } } } #endregion ConvertFrom-QueryString #region ConvertTo-QueryString <# .SYNOPSIS Convert Hashtable to Query String. .EXAMPLE PS C:\>ConvertTo-QueryString @{ name = 'path/file.json'; index = 10 } Convert hashtable to query string. .EXAMPLE PS C:\>[ordered]@{ title = 'convert&prosper'; id = [guid]'352182e6-9ab0-4115-807b-c36c88029fa4' } | ConvertTo-QueryString Convert ordered dictionary to query string. .INPUTS System.Collections.Hashtable .LINK https://github.com/jasoth/Utility.PS #> function ConvertTo-QueryString { [CmdletBinding()] [OutputType([string])] param ( # Value to convert [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] [object] $InputObjects, # URL encode parameter names [Parameter(Mandatory = $false)] [switch] $EncodeParameterNames ) process { foreach ($InputObject in $InputObjects) { $QueryString = New-Object System.Text.StringBuilder if ($InputObject -is [hashtable] -or $InputObject -is [System.Collections.Specialized.OrderedDictionary] -or $InputObject.GetType().FullName.StartsWith('System.Collections.Generic.Dictionary')) { foreach ($Item in $InputObject.GetEnumerator()) { if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') } [string] $ParameterName = $Item.Key if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) } [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($Item.Value)) } } elseif ($InputObject -is [object] -and $InputObject -isnot [ValueType]) { foreach ($Item in ($InputObject | Get-Member -MemberType Property, NoteProperty)) { if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') } [string] $ParameterName = $Item.Name if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) } [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($InputObject.($Item.Name))) } } else { ## Non-Terminating Error $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to query string.' -f $InputObject.GetType()) Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertQueryStringFailureTypeNotSupported' -TargetObject $InputObject continue } Write-Output $QueryString.ToString() } } } #endregion ConvertTo-QueryString #endregion Private Functions #region Public Functions #region Invoke-ptGraphBatchRequest <# .SYNOPSIS Executes multiple Microsoft Graph API requests in batches with automatic retry handling, rate limiting, and pagination support. .DESCRIPTION This function processes multiple Microsoft Graph API requests efficiently by grouping them into batches of up to 20 requests. It handles rate limiting (HTTP 429 responses) with automatic retry logic, supports multiple pagination modes, and provides flexible output options including raw responses and grouped results. The function is designed to optimize API usage while respecting Microsoft Graph rate limits and providing robust error handling. The function supports three parameter sets: - Standard: Normal processing with individual item output and configurable pagination - GroupById: Groups results by request ID with automatic pagination (ideal for collecting complete datasets) - RawOutput: Returns complete batch response objects with configurable pagination (ideal for custom post-processing) .PARAMETER BatchItems An array of PSCustomObject items representing individual Graph API requests. Each item must contain: - 'id' property: Unique identifier for tracking the request - 'url' property: The Graph API endpoint (relative to base URI and API version) - Optional: 'method', 'headers', 'body' properties for advanced requests .PARAMETER GraphBaseUri The base URI for Microsoft Graph API. Supports different Graph environments: - https://graph.microsoft.com (default - Commercial cloud) - https://graph.microsoft.us (US Government cloud) - https://dod-graph.microsoft.us (US Government DoD cloud) - https://graph.microsoft.de (German cloud) - https://microsoftgraph.chinacloudapi.cn (China cloud) .PARAMETER BatchSize The maximum number of requests to include in each batch. Must be between 1 and 20. Microsoft Graph supports up to 20 requests per batch operation. Defaults to 20. .PARAMETER GroupById Switch parameter that enables result grouping by request ID. When used: - Results are collected in a hashtable keyed by request ID - Automatically enables 'auto' pagination to ensure complete datasets - Returns a hashtable with request IDs as keys and arrays of results as values - Cannot be used with RawOutput parameter .PARAMETER ApiVersion The Microsoft Graph API version to use. Valid values are 'v1.0' (default) or 'beta'. Choose 'v1.0' for production stability or 'beta' for preview features. .PARAMETER RawOutput Switch parameter that returns complete batch response objects instead of processed results. When enabled: - Returns the raw Graph batch API responses - Pagination still works (if enabled) but responses aren't processed - Ideal for custom post-processing or debugging - Cannot be used with GroupById parameter - Great for using $count or other metadata from the response .PARAMETER pagination Controls how the function handles paginated responses with @odata.nextLink. Valid values: - 'auto': Automatically follow all pagination links to retrieve complete datasets - 'none': Return only the first page of results without following pagination - Not specified: Return first page with warnings about available additional pages Note: Not available with GroupById parameter set (always uses 'auto') .PARAMETER EnrichOutput Switch parameter that adds batch metadata to each output item. When enabled: - Adds a '@batchMetadata' property to each result object containing: * 'requestId': The batch request ID that returned this item * '@odata.context': The OData context URL from the response - Available in Standard and GroupById modes only (not available with RawOutput) - Helps track which batch request produced each result - Useful for debugging and audit trails .OUTPUTS The output depends on the parameter set used: Standard mode: Individual result objects from successful requests GroupById mode: Hashtable with request IDs as keys and result arrays as values RawOutput mode: Complete batch response objects from Graph API .EXAMPLE $requests = @( New-ptGraphRequestItem -id "1" -url "/users" New-ptGraphRequestItem -id "2" -url "/groups" ) Invoke-ptGraphBatchRequest -BatchItems $requests Processes two Graph API requests in standard mode. Returns individual user and group objects. Shows warnings if additional pages are available. .EXAMPLE $requests = @( New-ptGraphRequestItem -id "users" -url "/users" New-ptGraphRequestItem -id "groups" -url "/groups" ) $results = Invoke-ptGraphBatchRequest -BatchItems $requests -GroupById $results["users"] # All user objects $results["groups"] # All group objects Groups results by request ID with automatic pagination. Returns a hashtable where each key contains all results for that request type. .EXAMPLE Invoke-ptGraphBatchRequest -BatchItems $requests -RawOutput -pagination 'auto' Returns raw batch response objects with automatic pagination. Useful for custom processing or when you need access to response metadata like status codes. .EXAMPLE $largeRequests = @( New-ptGraphRequestItem -id "all-users" -url "/users" New-ptGraphRequestItem -id "all-groups" -url "/groups" ) Invoke-ptGraphBatchRequest -BatchItems $largeRequests -pagination 'auto' Automatically follows pagination to retrieve all users and groups. Each individual object is output as it's processed. .EXAMPLE Invoke-ptGraphBatchRequest -BatchItems $requests -GraphBaseUri 'https://graph.microsoft.us' -ApiVersion 'beta' Processes requests using the US Government cloud endpoint with the beta API version. .EXAMPLE Invoke-ptGraphBatchRequest -BatchItems $requests -pagination 'auto' -Verbose Processes batch items with automatic pagination and verbose output showing detailed progress information including batch counts and API call results. .EXAMPLE $requests = @( New-ptGraphRequestItem -id "users" -url "/users" New-ptGraphRequestItem -id "groups" -url "/groups" ) $results = Invoke-ptGraphBatchRequest -BatchItems $requests -EnrichOutput -pagination 'auto' $results | Select-Object displayName, '@batchMetadata' Enriches each output item with batch metadata. Each result will have a '@batchMetadata' property containing the batch request ID and OData context, helpful for tracking data sources. .NOTES - Requires the Microsoft.Graph PowerShell SDK for Invoke-MgGraphRequest - Automatically handles rate limiting with exponential backoff retry logic - Thread-safe queue implementation for reliable batch processing - Supports all Microsoft Graph sovereign cloud environments - Maximum 20 requests per batch (Microsoft Graph limitation) - Rate limit retry delays are extracted from Graph API error responses when available .LINK https://docs.microsoft.com/en-us/graph/json-batching .LINK https://docs.microsoft.com/en-us/graph/throttling .LINK https://docs.microsoft.com/en-us/graph/paging #> function Invoke-ptGraphBatchRequest { [CmdletBinding(DefaultParameterSetName = 'Standard', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/Invoke-ptGraphBatchRequest.md')] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Standard')] [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')] [PSCustomObject[]] $BatchItems, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet( 'https://graph.microsoft.us/', 'https://microsoftgraph.chinacloudapi.cn/', 'https://graph.microsoft.com/', 'https://dod-graph.microsoft.us/', 'https://graph.microsoft.de/' )] [uri] $GraphBaseUri = 'https://graph.microsoft.com', # Specify Batch size. [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateRange(1, 20)] [int] $BatchSize = 20, # Group results by batch id - only works with auto pagination [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')] [switch] $GroupById, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet('v1.0', 'beta')] [string] $ApiVersion = 'v1.0', # Raw output - returns complete batch responses [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')] [switch]$RawOutput, # Pagination only available for Standard and RawOutput (GroupById always uses auto) [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet('none', 'auto')] [string]$pagination, # Enrich output with batch metadata (only for Standard and GroupById modes) [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [switch]$EnrichOutput ) begin { # Validate that all batch items have required properties $invalidItems = $BatchItems | Where-Object { -not $_.id -or -not $_.url -or -not $_.method } if ($invalidItems) { throw "All batch items must have 'id', 'method' and 'url' properties. Found $($invalidItems.Count) invalid item(s)." } # Determine pagination strategy based on parameter set # GroupById parameter set always uses auto pagination to ensure complete datasets if ($PSCmdlet.ParameterSetName -eq 'GroupById') { $pagination = 'auto' } # Note: When pagination is not specified, it remains empty string which triggers # the 'default' case in the switch statement to show warnings # Initialize thread-safe queue for managing batch items during processing $queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue)) # Initialize results collection if GroupById mode is enabled if ($GroupById) { $Results = @{} } # Populate the queue with all batch items and initialize result collections for GroupById $BatchItems | ForEach-Object { if ($GroupById) { $id = $_.id # Create a generic list for each unique ID to efficiently collect results $Results[$id] = New-Object 'System.Collections.Generic.List[psobject]' } # Add each item to the processing queue $queue.Enqueue($_) } } process { Write-Information ('Processing {0} requests in batches of {1}.' -f $BatchItems.Count, $BatchSize) Write-Verbose "Using parameter set: $($PSCmdlet.ParameterSetName)" Write-Verbose "Pagination mode: $(if ($pagination) { $pagination } else { 'default (warn)' })" # Initialize batch collection for current processing batch $batch = New-Object 'System.Collections.Generic.List[psobject]' # Construct the Graph API batch endpoint URI $uriQueryEndpoint = New-Object System.UriBuilder -ArgumentList ([IO.Path]::Combine($GraphBaseUri.AbsoluteUri, $ApiVersion, '$batch')) Write-Verbose "Batch endpoint: $($uriQueryEndpoint.Uri.AbsoluteUri)" # Track statistics for verbose output $batchCount = 0 $totalProcessed = 0 $pagesFollowed = 0 # Main processing loop - continues until all items are processed do { $batchCount++ Write-Information ('Items in queue: {0}' -f $queue.Count) Write-Verbose "Processing batch #$batchCount" # Fill the current batch up to the specified batch size while ($batch.Count -lt $BatchSize -and $queue.Count -gt 0) { $batch.Add($queue.Dequeue()) } # Serialize batch requests to JSON format required by Graph batch API $jsonRequests = New-Object psobject -Property @{ requests = $batch } | ConvertTo-Json -Depth 5 Write-Debug -Message "Batch Request JSON: $jsonRequests" # Execute the batch request if there are items to process if ($batch.Count) { try { $batchResponse = Invoke-MgGraphRequest -Method POST -Uri $uriQueryEndpoint.Uri.AbsoluteUri -Body $jsonRequests -OutputType PSObject Write-Verbose "Batch #$batchCount completed successfully with $($batchResponse.responses.Count) response(s)" } catch { Write-Error "Failed to execute batch request #$batchCount : $_" -ErrorAction Stop throw } } else { # Create empty response if batch is empty (shouldn't normally happen) $batchResponse = [PSCustomObject]@{ responses = @() } } # Initialize retry timer for rate limiting (429 responses) $RetryTimer = 0 # Output raw batch response if RawOutput mode is enabled # This provides access to complete response metadata including status codes and headers if ($RawOutput) { Write-Output $batchResponse } # Process each individual response in the batch foreach ($response in $batchResponse.responses) { # Handle rate limiting (HTTP 429) - extract retry delay and continue if ($response.status -eq 429) { $retryAfter = 15 # Default retry delay in seconds $message = $response.body.error.message # Try to extract specific retry delay from error message if ($message -match 'Try again in (\d+) seconds') { $retryAfter = [int]$matches[1] } # Track the longest retry delay if multiple 429s occur if ($retryAfter -gt $RetryTimer) { $RetryTimer = $retryAfter } continue } # Handle all other non-success status codes (not 2xx) elseif ($response.status -notin 200..299) { $errorMessage = "Graph API request failed with status $($response.status) for request ID '$($response.id)'" # Include Graph API error message if available if ($response.body.error.message) { $errorMessage += ": $($response.body.error.message)" } Write-Debug -Message ($response | ConvertTo-Json -Depth 5) Write-Error -Message $errorMessage -Category InvalidOperation -TargetObject $response # Remove failed item from batch to prevent infinite loop $failedInstance = $batch.Find({ param($x) $x.id -eq $response.id }) if ($failedInstance) { $batch.Remove($failedInstance) | Out-Null } continue } # Extract the request ID for tracking and result correlation $instanceId = $response.id $totalProcessed++ # Process response body if not in raw output mode if (-not $RawOutput) { # Ensure response has a body before processing if (-not $response.body) { Write-Warning "Response for request ID '$instanceId' has no body. Skipping." # Remove item with no body from batch to prevent infinite loop $noBodyInstance = $batch.Find({ param($x) $x.id -eq $instanceId }) if ($noBodyInstance) { $batch.Remove($noBodyInstance) | Out-Null } continue } # GroupById mode: Collect all results in hashtable keyed by request ID if ($GroupById) { # Check if value property is an array (typical for list responses) if ($response.body.value -is [array]) { # Add each item individually to maintain flat structure $response.body.value | ForEach-Object { # Enrich item with batch metadata if requested if ($EnrichOutput) { $_ | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.body.'@odata.context' } -Force } $Results[$instanceId].Add($_) } } else { # Handle single object responses or responses without 'value' property if ($EnrichOutput) { $response.body | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.body.'@odata.context' } -Force } $Results[$instanceId].Add($response.body) } } # Standard mode: Output individual items as they're processed else { # Check if response has a 'value' property (collection response) if ($null -ne $response.body.PSObject.Properties['value']) { # Check if value property is an array if ($response.body.value -is [array]) { # Output each item individually for pipeline processing # Empty arrays won't output anything (correct behavior) $response.body.value | ForEach-Object { # Enrich item with batch metadata if requested if ($EnrichOutput) { $_ | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.body.'@odata.context' } -Force } Write-Output $_ } } else { # Handle single object in value property if ($EnrichOutput) { $response.body.value | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.body.'@odata.context' } -Force } Write-Output $response.body.value } } else { # No value property - output the entire body (e.g., single entity GET) if ($EnrichOutput) { $response.body | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.body.'@odata.context' } -Force } Write-Output $response.body } } } # Find the original batch item by response ID for pagination handling $instance = $batch.Find({ param($x) $x.id -eq $instanceId }) # Validate that the batch item was found (should always exist) if (-not $instance) { Write-Warning "Could not find batch item with ID '$instanceId' in current batch. Skipping pagination handling." continue } # Handle pagination based on user preference and @odata.nextLink presence if ($response.body.'@odata.nextLink') { switch ($pagination) { 'auto' { # Automatically follow pagination - update URL and keep in batch # Strip base URI and version to get relative URL for next page $nextLink = $response.body.'@odata.nextLink' # Extract the path after the API version (e.g., /v1.0/users?... -> /users?...) if ($nextLink -match "$ApiVersion(.+)$") { $instance.url = $matches[1] } else { # Fallback: just strip base URI and version $instance.url = $nextLink -replace ('{0}{1}' -f $GraphBaseUri.AbsoluteUri, $ApiVersion) } $pagesFollowed++ } 'none' { # No pagination - remove from batch to stop processing this request $batch.Remove($instance) | Out-Null } default { # Pagination not specified - inform user about available pages and stop Write-Warning "Request ID '$($response.id)' has additional pages available. Use -pagination 'auto' to retrieve all pages automatically, or 'none' to stop after first page." $batch.Remove($instance) | Out-Null } } } else { # No more pages available - remove completed request from batch $batch.Remove($instance) | Out-Null } } # Apply rate limit delay if any 429 responses were encountered if ($RetryTimer) { Write-Information -MessageData "Rate limit exceeded, waiting $RetryTimer seconds" Start-Sleep -Seconds $RetryTimer } } while ($batch.Count -gt 0 -or $queue.Count -gt 0 ) # Output final statistics Write-Verbose "Completed processing: $batchCount batch(es), $totalProcessed response(s), $pagesFollowed page(s) followed" # Output grouped results if GroupById mode was used if ($GroupById) { Write-Verbose "Returning grouped results for $($Results.Keys.Count) unique ID(s)" $Results.Clone() } } } #endregion Invoke-ptGraphBatchRequest #region Invoke-ptGraphRequest <# .SYNOPSIS Executes Microsoft Graph API requests individually with automatic retry handling, rate limiting, and pagination support. .DESCRIPTION This function processes Microsoft Graph API requests one at a time. It handles rate limiting (HTTP 429 responses) with automatic retry logic, supports multiple pagination modes, and provides flexible output options including raw responses and grouped results. The function is designed to optimize API usage while respecting Microsoft Graph rate limits and providing robust error handling. The function supports three parameter sets: - Standard: Normal processing with individual item output and configurable pagination - GroupById: Groups results by request ID with automatic pagination (ideal for collecting complete datasets) - RawOutput: Returns complete response objects with configurable pagination (ideal for custom post-processing) .PARAMETER RequestItems An array of PSCustomObject items representing individual Graph API requests. Each item must contain: - 'id' property: Unique identifier for tracking the request - 'url' property: The Graph API endpoint (relative to base URI and API version) - 'method' property: HTTP method (GET, POST, PUT, PATCH, DELETE) - Optional: 'headers', 'body' properties for advanced requests .PARAMETER GraphBaseUri The base URI for Microsoft Graph API. Supports different Graph environments: - https://graph.microsoft.com (default - Commercial cloud) - https://graph.microsoft.us (US Government cloud) - https://dod-graph.microsoft.us (US Government DoD cloud) - https://graph.microsoft.de (German cloud) - https://microsoftgraph.chinacloudapi.cn (China cloud) .PARAMETER GroupById Switch parameter that enables result grouping by request ID. When used: - Results are collected in a hashtable keyed by request ID - Automatically enables 'auto' pagination to ensure complete datasets - Returns a hashtable with request IDs as keys and arrays of results as values - Cannot be used with RawOutput parameter .PARAMETER ApiVersion The Microsoft Graph API version to use. Valid values are 'v1.0' (default) or 'beta'. Choose 'v1.0' for production stability or 'beta' for preview features. .PARAMETER RawOutput Switch parameter that returns complete response objects instead of processed results. When enabled: - Returns the raw Graph API responses - Pagination still works (if enabled) but responses aren't processed - Ideal for custom post-processing or debugging - Cannot be used with GroupById parameter .PARAMETER pagination Controls how the function handles paginated responses with @odata.nextLink. Valid values: - 'auto': Automatically follow all pagination links to retrieve complete datasets - 'none': Return only the first page of results without following pagination - Not specified: Return first page with warnings about available additional pages Note: Not available with GroupById parameter set (always uses 'auto') .PARAMETER EnrichOutput Switch parameter that adds request metadata to each output item. When enabled: - Adds a '@requestMetadata' property to each result object containing: * 'requestId': The request ID that returned this item * '@odata.context': The OData context URL from the response - Available in Standard and GroupById modes only (not available with RawOutput) - Helps track which request produced each result .OUTPUTS The output depends on the parameter set used: Standard mode: Individual result objects from successful requests GroupById mode: Hashtable with request IDs as keys and result arrays as values RawOutput mode: Complete response objects from Graph API .EXAMPLE $requests = @( New-ptGraphRequestItem -id "1" -url "/users" New-ptGraphRequestItem -id "2" -url "/groups" ) Invoke-ptGraphRequest -RequestItems $requests Processes two Graph API requests individually. Returns individual user and group objects. .EXAMPLE $requests = @( New-ptGraphRequestItem -id "users" -url "/users" New-ptGraphRequestItem -id "groups" -url "/groups" ) $results = Invoke-ptGraphRequest -RequestItems $requests -GroupById $results["users"] # All user objects $results["groups"] # All group objects Groups results by request ID with automatic pagination. .EXAMPLE Invoke-ptGraphRequest -RequestItems $requests -RawOutput -pagination 'auto' Returns raw response objects with automatic pagination. .EXAMPLE Invoke-ptGraphRequest -RequestItems $requests -pagination 'auto' -EnrichOutput Processes requests with automatic pagination and enriches each output item with metadata. .NOTES - Requires the Microsoft.Graph PowerShell SDK for Invoke-MgGraphRequest - Automatically handles rate limiting with exponential backoff retry logic - Supports all Microsoft Graph sovereign cloud environments - Rate limit retry delays are extracted from Graph API error responses when available .LINK https://docs.microsoft.com/en-us/graph/throttling .LINK https://docs.microsoft.com/en-us/graph/paging #> function Invoke-ptGraphRequest { [CmdletBinding(DefaultParameterSetName = 'Standard', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/Invoke-ptGraphRequest.md')] param ( [Parameter(Mandatory = $true, ParameterSetName = 'Standard')] [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')] [PSCustomObject[]] $RequestItems, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet( 'https://graph.microsoft.us/', 'https://microsoftgraph.chinacloudapi.cn/', 'https://graph.microsoft.com/', 'https://dod-graph.microsoft.us/', 'https://graph.microsoft.de/' )] [uri] $GraphBaseUri = 'https://graph.microsoft.com', [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')] [switch] $GroupById, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet('v1.0', 'beta')] [string] $ApiVersion = 'v1.0', [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')] [switch]$RawOutput, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')] [ValidateSet('none', 'auto')] [string]$pagination, [Parameter(Mandatory = $false, ParameterSetName = 'Standard')] [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')] [switch]$EnrichOutput ) begin { # Validate that all request items have required properties $invalidItems = $RequestItems | Where-Object { -not $_.id -or -not $_.url -or -not $_.method } if ($invalidItems) { throw "All request items must have 'id', 'method' and 'url' properties. Found $($invalidItems.Count) invalid item(s)." } # Determine pagination strategy based on parameter set if ($PSCmdlet.ParameterSetName -eq 'GroupById') { $pagination = 'auto' } # Initialize thread-safe queue for managing request items $queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue)) # Initialize results collection if GroupById mode is enabled if ($GroupById) { $Results = @{} } # Populate the queue with all request items and initialize result collections for GroupById $RequestItems | ForEach-Object { if ($GroupById) { $id = $_.id $Results[$id] = New-Object 'System.Collections.Generic.List[psobject]' } $queue.Enqueue($_) } } process { Write-Information ('Processing {0} request(s).' -f $RequestItems.Count) Write-Verbose "Using parameter set: $($PSCmdlet.ParameterSetName)" Write-Verbose "Pagination mode: $(if ($pagination) { $pagination } else { 'default (warn)' })" # Track statistics $totalProcessed = 0 $pagesFollowed = 0 # Main processing loop while ($queue.Count -gt 0) { $request = $queue.Dequeue() $instanceId = $request.id Write-Verbose "Processing request ID: $instanceId" # Construct full URI $uri = [IO.Path]::Combine($GraphBaseUri.AbsoluteUri, $ApiVersion, $request.url.TrimStart('/')) Write-Verbose "Request URI: $uri" # Prepare request parameters $requestParams = @{ Method = $request.method Uri = $uri } # Add headers if provided if ($request.headers) { $requestParams['Headers'] = $request.headers } # Add body if provided if ($request.body) { if ($request.body -is [hashtable]) { $requestParams['Body'] = ($request.body | ConvertTo-Json -Depth 10) } else { $requestParams['Body'] = $request.body } } # Execute request with retry logic $maxRetries = 3 $retryCount = 0 $success = $false while (-not $success -and $retryCount -lt $maxRetries) { try { $response = Invoke-MgGraphRequest @requestParams -OutputType PSObject $success = $true $totalProcessed++ Write-Verbose "Request ID '$instanceId' completed successfully" } catch { $statusCode = $_.Exception.Response.StatusCode.value__ # Handle rate limiting (429) if ($statusCode -eq 429) { $retryAfter = 15 if ($_.Exception.Response.Headers['Retry-After']) { $retryAfter = [int]$_.Exception.Response.Headers['Retry-After'] } Write-Warning "Rate limit exceeded for request ID '$instanceId'. Waiting $retryAfter seconds (attempt $($retryCount + 1)/$maxRetries)" Start-Sleep -Seconds $retryAfter $retryCount++ continue } # Handle other errors $errorMessage = "Graph API request failed for request ID '$instanceId': $($_.Exception.Message)" Write-Error -Message $errorMessage -Category InvalidOperation break } } # Skip further processing if request failed if (-not $success) { continue } # Output raw response if RawOutput mode is enabled if ($RawOutput) { Write-Output $response } # Process response if not in raw output mode if (-not $RawOutput) { # Ensure response exists if (-not $response) { Write-Warning "Response for request ID '$instanceId' is null. Skipping." continue } # GroupById mode: Collect all results in hashtable if ($GroupById) { if ($response.value -is [array]) { $response.value | ForEach-Object { if ($EnrichOutput) { $_ | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.'@odata.context' } -Force } $Results[$instanceId].Add($_) } } else { if ($EnrichOutput) { $response | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.'@odata.context' } -Force } $Results[$instanceId].Add($response) } } # Standard mode: Output individual items else { if ($null -ne $response.PSObject.Properties['value']) { if ($response.value -is [array]) { $response.value | ForEach-Object { if ($EnrichOutput) { $_ | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.'@odata.context' } -Force } Write-Output $_ } } else { if ($EnrichOutput) { $response.value | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.'@odata.context' } -Force } Write-Output $response.value } } else { if ($EnrichOutput) { $response | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{ requestId = $instanceId '@odata.context' = $response.'@odata.context' } -Force } Write-Output $response } } } # Handle pagination if ($response.'@odata.nextLink') { switch ($pagination) { 'auto' { # Update URL and re-queue $request.url = $response.'@odata.nextLink' -replace ('{0}{1}/' -f $GraphBaseUri.AbsoluteUri, $ApiVersion) $queue.Enqueue($request) $pagesFollowed++ } 'none' { # Don't follow pagination } default { Write-Warning "Request ID '$instanceId' has additional pages available. Use -pagination 'auto' to retrieve all pages automatically, or 'none' to stop after first page." } } } } # Output statistics Write-Verbose "Completed processing: $totalProcessed response(s), $pagesFollowed page(s) followed" # Output grouped results if GroupById mode was used if ($GroupById) { Write-Verbose "Returning grouped results for $($Results.Keys.Count) unique ID(s)" $Results.Clone() } } } #endregion Invoke-ptGraphRequest #region New-ptGraphRequestItem <# .SYNOPSIS Creates a new Microsoft Graph request item for use with batch requests or individual API calls. .DESCRIPTION This helper function creates properly formatted Graph request items that can be used with the Invoke-GraphBatchRequest2 function or for individual Graph API requests. It supports different parameter sets for requests with and without body content, and handles both string and hashtable body types. The function intelligently handles query parameters by: - Preserving existing query parameters from the input URL - Merging them with new OData parameters specified via function parameters - Allowing QueryParameters hashtable to override or add additional parameters - Properly URL-encoding the final query string .PARAMETER id Unique identifier for the request within a batch operation. Used to correlate batch responses with individual requests. Only used when this request item is part of a batch request. If not specified, a new GUID will be automatically generated. .PARAMETER url The Graph API endpoint URL (relative to the base URI, without the version prefix). Must start with a forward slash '/'. Can include existing query parameters which will be preserved and merged with any OData parameters specified via function parameters. Examples: "/users", "/groups/12345/members", "/me/messages", "/users?$top=5" .PARAMETER method HTTP method for the request. Valid values: GET, POST, PUT, PATCH, DELETE. Defaults to 'GET' if not specified. .PARAMETER headers Optional hashtable of HTTP headers to include with the request. When using body parameters, Content-Type will be automatically set to 'application/json' unless explicitly overridden via the ContentType parameter or headers hashtable. .PARAMETER body Request body content. Can be either: - A hashtable object (will be converted to JSON automatically) - A pre-formatted JSON string (when you need precise control over formatting) The function automatically detects the type and handles it appropriately. .PARAMETER bodyHashtable (Deprecated - use -body instead) Alias for -body parameter when passing a hashtable. .PARAMETER bodyString (Deprecated - use -body instead) Alias for -body parameter when passing a string. .PARAMETER dependsOn Optional identifier of another request within the same batch that this request depends on. Used to control execution order within batch operations. Only applicable when using batch requests - ignored for individual API calls. .PARAMETER pageSize Number of items to return per page (OData $top parameter). .PARAMETER Count Include count of total items in response (OData $count parameter). .PARAMETER ExpandProperty Expand related properties (OData $expand parameter). .PARAMETER Filter Filter results based on criteria (OData $filter parameter). .PARAMETER Format Response format (OData $format parameter). .PARAMETER Sort Sort order for results (OData $orderby parameter). .PARAMETER Search Search query (OData $search parameter). .PARAMETER Property Select specific properties (OData $select parameter). .PARAMETER Skip Number of items to skip (OData $skip parameter). .PARAMETER skipToken Token for pagination (OData $skiptoken parameter). .PARAMETER QueryParameters Optional hashtable of additional query parameters to include in the request URL. These parameters will be merged with any existing query parameters from the URL and any OData parameters specified via dedicated function parameters. QueryParameters take precedence over existing URL parameters with the same name. .PARAMETER ConsistencyLevel Sets the consistency level for the request. Currently only 'eventual' is supported. Automatically adds the ConsistencyLevel header to the request when specified. .PARAMETER ContentType Sets the Content-Type header for the request. If not specified, defaults to 'application/json' for requests with body content. Common values include: 'application/json' (default for most Graph operations), 'application/x-www-form-urlencoded' (for OAuth), 'multipart/form-data' (for file uploads), etc. .OUTPUTS PSCustomObject Returns a properly formatted Graph request item object with all query parameters (existing, OData, and custom) properly merged and URL-encoded in the final URL. .EXAMPLE # Simple GET request (ID and method auto-generated/defaulted) $item1 = New-ptGraphRequestItem -url "/users" .EXAMPLE # POST request with hashtable body and custom ID $item2 = New-ptGraphRequestItem -id "create-user" -url "/users" -method "POST" -headers @{"Content-Type"="application/json"} -body @{ displayName = "John Doe" userPrincipalName = "john@contoso.com" } .EXAMPLE # POST request with string body (auto-generated ID) $jsonBody = '{"displayName":"Jane Doe","userPrincipalName":"jane@contoso.com"}' $item3 = New-ptGraphRequestItem -url "/users" -method "POST" -headers @{"Content-Type"="application/json"} -body $jsonBody .EXAMPLE # Backward compatibility: Using legacy -bodyHashtable parameter (aliased to -body) $item3b = New-ptGraphRequestItem -url "/users" -method "POST" -bodyHashtable @{ displayName = "Legacy User" userPrincipalName = "legacy@contoso.com" } .EXAMPLE # GET request with OData query parameters $item4 = New-ptGraphRequestItem -url "/users" -pageSize 10 -Filter "startswith(displayName,'John')" -Property "id,displayName,mail" -Sort "displayName" # Results in URL: /users?$top=10&$filter=startswith(displayName,'John')&$select=id,displayName,mail&$orderby=displayName .EXAMPLE # URL with existing query parameters that get preserved and merged $item5 = New-ptGraphRequestItem -url "/users?$expand=manager" -pageSize 5 -Filter "department eq 'Sales'" # Results in URL: /users?$expand=manager&$top=5&$filter=department eq 'Sales' .EXAMPLE # Using QueryParameters to add custom parameters $customParams = @{ 'api-version' = '2.0'; 'custom' = 'value' } $item6 = New-ptGraphRequestItem -url "/users" -QueryParameters $customParams -pageSize 10 # Results in URL: /users?api-version=2.0&custom=value&$top=10 .NOTES - The function automatically handles the proper structure required by Graph API - Body content is only validated for type, not for Graph API schema compliance - Can be used for both batch operations and individual Graph API requests - The 'id' and 'dependsOn' parameters are only used for batch operations and are ignored for individual requests - Query parameters are intelligently merged: URL parameters + OData parameters + QueryParameters - Parameter precedence: QueryParameters > OData function parameters > existing URL parameters - All query parameters are properly URL-encoded in the final output - Uses internal module functions ConvertFrom-QueryString and ConvertTo-QueryString for query parameter processing #> function New-ptGraphRequestItem { [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='None', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/New-ptGraphRequestItem.md')] param ( [Parameter(Mandatory = $false)] [string]$id = [System.Guid]::NewGuid().ToString(), [Parameter(Mandatory = $true)] [ValidateScript({ if ($_ -notmatch '^/') { throw "URL must start with '/'. Use relative URLs only. Example: '/users' instead of 'users' or 'https://graph.microsoft.com/v1.0/users'. Current value: '$_'" } return $true })] [string]$url, [Parameter(Mandatory = $false)] [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$method = 'GET', [Parameter(Mandatory = $false)] [hashtable]$headers, [Parameter(Mandatory = $false)] [ValidateNotNull()] [Alias('bodyHashtable', 'bodyString')] [object]$body, [Parameter(Mandatory = $false)] [string]$dependsOn, # OData Query Parameters [Parameter(Mandatory = $false)] [Alias('$top')] [int]$pageSize, [Parameter(Mandatory = $false)] [Alias('$count')] [switch]$Count, [Parameter(Mandatory = $false)] [Alias('$expand')] [string]$ExpandProperty, [Parameter(Mandatory = $false)] [Alias('$filter')] [string]$Filter, [Parameter(Mandatory = $false)] [Alias('$format')] [string]$Format, [Parameter(Mandatory = $false)] [Alias('$orderby')] [string]$Sort, [Parameter(Mandatory = $false)] [Alias('$search')] [string]$Search, [Parameter(Mandatory = $false)] [Alias('$select')] [string[]]$Property, [Parameter(Mandatory = $false)] [Alias('$skip')] [int]$Skip, [Parameter(Mandatory = $false)] [Alias('$skiptoken')] [string]$skipToken, # Parameters such as "$top". [Parameter(Mandatory = $false)] [hashtable] $QueryParameters, [Parameter(Mandatory = $false)] [ValidateSet('eventual')] [string] $ConsistencyLevel, [Parameter(Mandatory = $false)] [ValidateSet('application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'application/octet-stream', 'text/plain')] [string] $ContentType ) # Initialize URI builder $uriQueryEndpoint = [System.UriBuilder]::new("https://server.com$url") if ($uriQueryEndpoint.Query) { [hashtable] $odataParams = ConvertFrom-QueryString $uriQueryEndpoint.Query -AsHashtable if ($QueryParameters) { foreach ($ParameterName in $QueryParameters.Keys) { $odataParams[$ParameterName] = $QueryParameters[$ParameterName] } } } elseif ($QueryParameters) { [hashtable] $odataParams = $QueryParameters } else { [hashtable] $odataParams = @{ } } # Define OData parameter mappings $odataParameterMap = @{ 'pageSize' = '$top' 'Count' = '$count' 'ExpandProperty' = '$expand' 'Filter' = '$filter' 'Format' = '$format' 'Sort' = '$orderby' 'Search' = '$search' 'Property' = '$select' 'Skip' = '$skip' 'skipToken' = '$skiptoken' } # Process each bound parameter that's an OData parameter $PSBoundParameters.Keys | Where-Object { $odataParameterMap.ContainsKey($_) } | ForEach-Object { $paramName = $_ $paramValue = $PSBoundParameters[$paramName] $odataParamName = $odataParameterMap[$paramName] # Warn if this OData parameter already exists in the URL or QueryParameters if ($odataParams.ContainsKey($odataParamName)) { $existingValue = $odataParams[$odataParamName] if ($existingValue -ne $paramValue -and ($paramName -ne 'Count' -or $existingValue -ne 'true')) { Write-Warning "OData parameter '$odataParamName' specified multiple times. Function parameter '-$paramName' value ('$paramValue') will override existing value ('$existingValue') from URL or QueryParameters." } } if ($paramName -eq 'Count' -and $paramValue) { # Count is a switch parameter $odataParams[$odataParamName] = 'true' } elseif ($paramName -eq 'Property' -and $paramValue.Count -gt 1) { # Property accepts string array - join with commas for OData $select $odataParams[$odataParamName] = $paramValue -join ',' } else { $odataParams[$odataParamName] = $paramValue } } # Append query parameters to URL if any OData parameters were specified if ($odataParams.Count -gt 0) { $queryString = ConvertTo-QueryString $odataParams $url = $uriQueryEndpoint.Path + '?' + [uri]::UnescapeDataString($queryString) } else { # No query parameters, just use the path $url = $uriQueryEndpoint.Path } # Create the base batch item structure $batchItem = @{ id = $id url = $url method = $method.ToUpper() } # consistencyLevel header if ($ConsistencyLevel) { if (-not $headers) { $headers = @{} } $headers['ConsistencyLevel'] = $ConsistencyLevel } # contentType header - set explicitly or default for body requests $finalContentType = $ContentType if (-not $finalContentType -and $PSBoundParameters.ContainsKey('body')) { # Default to application/json for body requests if not specified $finalContentType = 'application/json' } if ($finalContentType) { if (-not $headers) { $headers = @{} } # Warn if both ContentType parameter and headers["Content-Type"] are specified if ($ContentType -and $headers -and $headers.ContainsKey('Content-Type') -and $headers['Content-Type'] -ne $ContentType) { Write-Warning "Both -ContentType parameter ('$ContentType') and headers['Content-Type'] ('$($headers['Content-Type'])') are specified. The -ContentType parameter will override the header value." } $headers['Content-Type'] = $finalContentType } # Add headers if provided if ($headers) { $batchItem.headers = $headers } # Add body if provided if ($PSBoundParameters.ContainsKey('body')) { # Detect body type at runtime if ($body -is [hashtable]) { $batchItem.body = $body } elseif ($body -is [string]) { # Validate JSON if Content-Type suggests it should be JSON if ($finalContentType -eq 'application/json' -or (-not $ContentType -and -not ($headers -and $headers.ContainsKey('Content-Type')))) { try { # Test if the string is valid JSON $null = $body | ConvertFrom-Json -ErrorAction Stop } catch { Write-Warning "Body string does not appear to be valid JSON, but Content-Type is set to 'application/json' or not defined. Consider:" Write-Warning ' 1. Fix the JSON syntax in your body string' Write-Warning " 2. Explicitly set -ContentType to the correct type (e.g., 'text/plain', 'application/x-www-form-urlencoded')" Write-Warning ' 3. Use a hashtable for -body instead for automatic JSON conversion' Write-Warning "JSON validation error: $($_.Exception.Message)" } } $batchItem.body = $body } else { throw "Body must be either a hashtable or string. Received type: $($body.GetType().Name)" } } else { # Warn if using methods that typically require a body if ($method.ToUpper() -in @('POST', 'PUT', 'PATCH')) { Write-Warning "Using $($method.ToUpper()) method without a body. Consider:" Write-Warning ' 1. Add -body with a hashtable for structured data that will be converted to JSON' Write-Warning ' 2. Add -body with a string for pre-formatted content (JSON, form data, etc.)' Write-Warning ' 3. If no body is intentional, you can ignore this warning' } } # Add dependency if specified if ($dependsOn) { $batchItem.dependsOn = $dependsOn } return [PSCustomObject]$batchItem } #endregion New-ptGraphRequestItem #endregion Public Functions Export-ModuleMember -Function @('Invoke-ptGraphBatchRequest', 'Invoke-ptGraphRequest', 'New-ptGraphRequestItem') |