public/Invoke-ZtGraphRequest.ps1
|
<#
.SYNOPSIS Helper module to run graph request that supports paging, batching and caching. .Description The version of Invoke-Graph request supports * Filter, Select and Unique IDs as parameters * Automatic paging if Graph returns a nextLink * Batching of requests to Graph if multiple requests are piped through * Caching of results for the duration of the session * Ability to skip cache and go directly to Graph * Specify consistency level as a parameter * Additional custom headers via the Headers parameter POST is intended for read/query Graph endpoints. POST requests require a JSON object body, are not cached or batched, and can target only one endpoint per invocation. :::info Note: Batch requests don't support caching. ::: Batched GETs use Invoke-ZtGraphBatchRequest for scheduling and member throttling retries. Results are processed as the helper returns them; continuation pages use ordinary GET requests. Initial batches use the helper's SDK routing, fixed batch size, and output representation, rather than GraphBaseUri, BatchSize, or OutputType. The helper currently sends batches to v1.0. .Example Invoke-ZtGraphRequest -RelativeUri "users" -Filter "displayName eq 'John Doe'" -Select "displayName" -Top 10 Get all users with a display name of "John Doe" and return the first 10 results. .Example $body = @{ Query = 'DeviceProcessEvents | limit 2' } | ConvertTo-Json -Compress Invoke-ZtGraphRequest -RelativeUri 'security/runHuntingQuery' -Method POST -Body $body Run a Microsoft Defender advanced hunting query. POST is intended only for Graph query endpoints. #> function Invoke-ZtGraphRequest { [CmdletBinding()] param( # Graph endpoint such as "users". [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] [string[]] $RelativeUri, # Specifies unique Id(s) for the URI endpoint. For example, users endpoint accepts Id or UPN. [Parameter(Mandatory = $false)] [string[]] $UniqueId = '', # Filters properties (columns). [Parameter(Mandatory = $false)] [string[]] $Select, # Filters results (rows). https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter [Parameter(Mandatory = $false)] [string] $Filter, # The number of items to be included in the result. [Parameter(Mandatory = $false)] [string] $Top, # Parameters [Parameter(Mandatory = $false)] [hashtable] $QueryParameters, # API Version. [Parameter(Mandatory = $false)] [ValidateSet('v1.0', 'beta')] [string] $ApiVersion = 'v1.0', # HTTP method. POST is intended for read/query endpoints. [Parameter(Mandatory = $false)] [ValidateSet('GET', 'POST')] [string] $Method = 'GET', # JSON object request body for POST requests. [Parameter(Mandatory = $false)] [string] $Body, # Specifies consistency level. [Parameter(Mandatory = $false)] [string] $ConsistencyLevel = 'eventual', # Only return first page of results. [Parameter(Mandatory = $false)] [switch] $DisablePaging, # Force individual requests to MS Graph. [Parameter(Mandatory = $false)] [switch] $DisableBatching, # Specify Batch size. [Parameter(Mandatory = $false)] [System.Obsolete('BatchSize is deprecated. Batch sizing is managed by Invoke-ZtGraphBatchRequest.')] [int] $BatchSize = 20, # Base URL for Microsoft Graph API. [Parameter(Mandatory = $false)] [System.Obsolete('GraphBaseUri is deprecated. Use the connected Microsoft Graph environment instead.')] [uri] $GraphBaseUri, # Specify if this request should skip cache and go directly to Graph. [Parameter(Mandatory = $false)] [switch] $DisableCache, # Specify the output type [Parameter(Mandatory = $false)] [ValidateSet('PSObject', 'PSCustomObject', 'Hashtable')] [string] $OutputType = 'PSObject', # If specified, writes the raw results to disk [Parameter(Mandatory = $false)] [string] $OutputFilePath, # Additional headers to include in the request [Parameter(Mandatory = $false)] [hashtable] $Headers ) begin { $batchRequests = New-Object 'System.Collections.Generic.List[psobject]' $postRelativeUris = New-Object 'System.Collections.Generic.List[string]' if ($Method -eq 'GET') { if ($PSBoundParameters.ContainsKey('Body')) { throw [System.ArgumentException]::new('-Body is only supported when -Method POST is specified.', 'Body') } } else { if ([string]::IsNullOrWhiteSpace($Body)) { throw [System.ArgumentException]::new('-Body is required when -Method POST is specified and must be a JSON object.', 'Body') } if (-not (Test-Json -Json $Body -ErrorAction SilentlyContinue)) { throw [System.ArgumentException]::new('-Body must be valid JSON.', 'Body') } try { $bodyObject = $Body | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { throw [System.ArgumentException]::new('-Body must be a JSON object.', 'Body', $_.Exception) } if ($bodyObject -isnot [System.Collections.IDictionary]) { throw [System.ArgumentException]::new('-Body must be a JSON object.', 'Body') } if ($DisableBatching) { throw [System.ArgumentException]::new('-DisableBatching cannot be used with -Method POST because POST requests are never batched.', 'DisableBatching') } foreach ($parameterName in 'Select', 'Filter', 'Top') { if ($PSBoundParameters.ContainsKey($parameterName)) { throw [System.ArgumentException]::new("-$parameterName cannot be used with -Method POST.", $parameterName) } } if ($UniqueId.Count -ne 1) { throw [System.ArgumentException]::new('-Method POST supports exactly one resolved endpoint.', 'UniqueId') } } $requestHeaders = if ($Headers) { $Headers.Clone() } else { @{} } $requestHeaders['ConsistencyLevel'] = $ConsistencyLevel if ($Method -eq 'POST' -and -not $requestHeaders.ContainsKey('Content-Type')) { $requestHeaders['Content-Type'] = 'application/json' } $requestParam = @{ Headers = $requestHeaders OutputType = $OutputType DisableCache = $DisableCache OutputFilePath = $OutputFilePath Method = $Method } if ($Method -eq 'POST') { $requestParam['Body'] = $Body } #region Utility Functions function Format-Result { [CmdletBinding()] param ( $Results, $RawOutput ) # Do nothing on null if (-not $Results) { return } if ($RawOutput) { return $Results } $hasValueProperty = $Results.PSObject.Properties.Name -contains "value" -or $Results.Keys -contains 'value' if (-not $hasValueProperty) { return $Results } $dataContextName = '@odata.context' foreach ($result in $Results.value) { if ($result.$dataContextName) { $result continue } if ($result -is [hashtable]) { $result[$dataContextName] = '{0}/$entity' -f $Results.'@odata.context' } else { [PSFramework.Object.ObjectHost]::AddNoteProperty($result, $dataContextName, ('{0}/$entity' -f $Results.'@odata.context'), $true) } $result } } function Complete-Result { [CmdletBinding()] param ( $Results, $DisablePaging, $RequestParam ) if ($DisablePaging -or -not $Results) { return } $pagingRequestParam = $RequestParam.Clone() $pagingRequestParam.Remove('Method') $pagingRequestParam.Remove('Body') $pageIndex = 1 while ($Results.'@odata.nextLink') { $Results = Invoke-ZtGraphRequestCache -Method GET -Uri $results.'@odata.nextLink' @pagingRequestParam -PageIndex $pageIndex $pageIndex++ Format-Result -Results $Results -RawOutput $DisablePaging } } function Resolve-GraphBaseUri { if ($GraphBaseUri) { return $GraphBaseUri } if (-not $script:__ZtSession.GraphBaseUri) { Write-PSFMessage -Message 'Setting GraphBaseUri to default value from MgContext.' $mgContext = Get-MgContext if (-not $mgContext) { throw 'No Microsoft Graph context found. Please connect to Microsoft Graph using Connect-ZtAssessment.' } $script:__ZtSession.GraphBaseUri = (Get-MgEnvironment -Name $mgContext.Environment).GraphEndpoint } return [uri] $script:__ZtSession.GraphBaseUri } function Invoke-ResolvedGraphRequest { param( [string[]] $Uris ) $resolvedGraphBaseUri = Resolve-GraphBaseUri if ($DisableBatching -and ($Uris.Count -gt 1 -or $UniqueId.Count -gt 1)) { Write-Warning ('This command is invoking {0} individual Graph requests. For better performance, remove the -DisableBatching parameter.' -f ($Uris.Count * $UniqueId.Count)) } $doBatch = ($Method -eq 'GET') -and -not $DisableBatching -and ($Uris.Count -gt 1 -or $UniqueId.Count -gt 1) foreach ($uri in $Uris) { $uriQueryEndpoint = [System.UriBuilder]::new([IO.Path]::Combine($resolvedGraphBaseUri.AbsoluteUri, $ApiVersion, $uri)) #region Process Uri & Query if ($uriQueryEndpoint.Query) { $finalQueryParameters = ConvertFrom-QueryString -InputStrings $uriQueryEndpoint.Query -AsHashtable if ($QueryParameters) { foreach ($ParameterName in $QueryParameters.Keys) { $finalQueryParameters[$ParameterName] = $QueryParameters[$ParameterName] } } } elseif ($QueryParameters) { $finalQueryParameters = $QueryParameters } else { $finalQueryParameters = @{ } } if ($Select) { $finalQueryParameters['$select'] = $Select -join ',' } if ($Filter) { $finalQueryParameters['$filter'] = $Filter } if ($Top) { $finalQueryParameters['$top'] = $Top } $uriQueryEndpoint.Query = ConvertTo-QueryString $finalQueryParameters #endregion Process Uri & Query foreach ($id in $UniqueId) { $uriQueryEndpointFinal = New-Object System.UriBuilder -ArgumentList $uriQueryEndpoint.Uri $uriQueryEndpointFinal.Path = ([IO.Path]::Combine($uriQueryEndpointFinal.Path, $id)) if ($doBatch) { $batchHeaders = if ($Headers) { $Headers.Clone() } else { @{} } $batchHeaders['ConsistencyLevel'] = $ConsistencyLevel $request = [PSCustomObject]@{ id = $batchRequests.Count + 1 method = 'GET' url = $uriQueryEndpointFinal.Uri.AbsoluteUri -replace ('{0}{1}/' -f $resolvedGraphBaseUri.AbsoluteUri, $ApiVersion) headers = $batchHeaders } $batchRequests.Add($request) } else { $results = Invoke-ZtGraphRequestCache -Uri $uriQueryEndpointFinal.Uri.AbsoluteUri @requestParam Format-Result -Results $results -RawOutput $DisablePaging Complete-Result -Results $results -DisablePaging $DisablePaging -RequestParam $requestParam } } } } #endregion Utility Functions } process { if ($Method -eq 'POST') { foreach ($uri in $RelativeUri) { $postRelativeUris.Add($uri) } return } Invoke-ResolvedGraphRequest -Uris $RelativeUri } end { if ($Method -eq 'POST') { if ($postRelativeUris.Count -ne 1) { throw [System.ArgumentException]::new('-Method POST supports exactly one resolved endpoint.', 'RelativeUri') } Invoke-ResolvedGraphRequest -Uris $postRelativeUris.ToArray() } if ($batchRequests.Count -lt 1) { return } Invoke-ZtGraphBatchRequest -Request $batchRequests.ToArray() -Raw -NoPaging -ApiVersion $ApiVersion | ForEach-Object { $results = $_.body Format-Result -Results $results -RawOutput $DisablePaging Complete-Result -Results $results -DisablePaging $DisablePaging -RequestParam $requestParam } } } |