Private/Invoke-IACGraphRequest.ps1
|
function Invoke-IACGraphRequest { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [Alias('Path')] [string]$Uri, [Parameter()] [ValidateSet('GET', 'POST')] [string]$Method = 'GET', [Parameter()] [AllowNull()] [object]$Body, [Parameter()] [switch]$AllPages, [Parameter()] [ValidateRange(0, 10)] [int]$MaxRetryCount = 3, [Parameter()] [ValidateRange(1, 10000)] [int]$MaxPageCount = 1000 ) if ([string]::IsNullOrWhiteSpace($script:GraphEndpoint)) { throw 'Microsoft Graph is not connected. Run Connect-IntuneAssignmentChecker first.' } $graphBase = $script:GraphEndpoint.TrimEnd('/') $requestUri = $Uri.Trim() # OData filters commonly contain literal spaces. IsWellFormedUriString rejects those, # so detect the scheme first and validate a safely escaped copy of the authority. $isAbsolute = $requestUri -match '^[a-z][a-z0-9+.-]*://' if ($isAbsolute) { $parsedRequestUri = $null $parseCandidate = $requestUri -replace ' ', '%20' if (-not [uri]::TryCreate($parseCandidate, [System.UriKind]::Absolute, [ref]$parsedRequestUri)) { throw 'Graph request URI is not a valid absolute URI.' } $parsedGraphBase = [uri]$graphBase if ($parsedRequestUri.Scheme -ne $parsedGraphBase.Scheme -or $parsedRequestUri.Host -ne $parsedGraphBase.Host -or $parsedRequestUri.Port -ne $parsedGraphBase.Port -or -not $requestUri.StartsWith("$graphBase/", [System.StringComparison]::OrdinalIgnoreCase)) { throw "Graph request URI '$requestUri' does not match the active cloud endpoint '$graphBase'." } $relativePath = $requestUri.Substring($graphBase.Length) } else { $relativePath = if ($requestUri.StartsWith('/')) { $requestUri } else { "/$requestUri" } } # The module intentionally targets Microsoft Graph beta. Normalize callers and nextLink # values through the same path so a stale version cannot silently bypass this policy. $relativePath = $relativePath -replace '^/(?:v1\.0|beta)(?=/|$)', '' $currentUri = "$graphBase/beta$relativePath" $items = [System.Collections.Generic.List[object]]::new() $visitedPageUris = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $pageCount = 0 $includeBody = $PSBoundParameters.ContainsKey('Body') $firstResponse = $null do { $pageCount++ if ($pageCount -gt $MaxPageCount) { throw "Microsoft Graph paging exceeded the configured maximum of $MaxPageCount pages." } if (-not $visitedPageUris.Add($currentUri)) { throw 'Microsoft Graph returned a repeated nextLink; paging was stopped to prevent an infinite loop.' } $attempt = 0 while ($true) { try { $requestParameters = @{ Uri = $currentUri Method = $Method ErrorAction = 'Stop' } if ($includeBody) { $requestParameters['Body'] = $Body } $response = Invoke-MgGraphRequest @requestParameters break } catch { $attempt++ $statusCode = $null foreach ($candidate in @( $_.Exception.Response.StatusCode, $_.Exception.StatusCode, $_.Exception.ResponseStatusCode )) { if ($null -eq $candidate) { continue } try { $statusCode = [int]$candidate break } catch { } } if ($null -eq $statusCode) { foreach ($pattern in @( '(?i)\bHTTP(?:\s+status)?\s+(?<Status>4\d\d|5\d\d)\b', '(?i)response status code does not indicate success:\s*(?<Status>4\d\d|5\d\d)\b' )) { if ($_.Exception.Message -match $pattern) { $statusCode = [int]$Matches.Status break } } } $retryAfterSeconds = $null try { $retryAfterHeader = $_.Exception.Response.Headers.'Retry-After' if ($retryAfterHeader -is [System.Collections.IEnumerable] -and $retryAfterHeader -isnot [string]) { $retryAfterHeader = @($retryAfterHeader)[0] } if ($retryAfterHeader) { $retryAfterSeconds = [int]$retryAfterHeader } } catch { } if ($null -eq $retryAfterSeconds) { try { $delta = $_.Exception.Response.Headers.RetryAfter.Delta if ($delta) { $retryAfterSeconds = [int][math]::Ceiling($delta.TotalSeconds) } } catch { } } $baseException = $_.Exception.GetBaseException() $isConnectionTransient = $null -eq $statusCode -and ( $baseException -is [System.TimeoutException] -or $baseException -is [System.Net.Http.HttpRequestException] -or $baseException -is [System.Net.WebException] -or $baseException -is [System.Threading.Tasks.TaskCanceledException] ) $isTransient = $statusCode -eq 429 -or ($null -ne $statusCode -and $statusCode -ge 500 -and $statusCode -le 599) -or $isConnectionTransient if ($isTransient -and $attempt -le $MaxRetryCount) { $delaySeconds = if ($null -ne $retryAfterSeconds -and $retryAfterSeconds -ge 0) { $retryAfterSeconds } else { [math]::Min([math]::Pow(2, $attempt - 1), 30) } Write-Verbose "Microsoft Graph returned HTTP $statusCode for '$currentUri'. Retrying in $delaySeconds second(s) (attempt $attempt of $MaxRetryCount)." if ($delaySeconds -gt 0) { Start-Sleep -Seconds $delaySeconds } continue } $graphErrorCode = $null $graphErrorMessage = $_.Exception.Message $requestId = $null $clientRequestId = $null $errorPayload = $_.ErrorDetails.Message if (-not [string]::IsNullOrWhiteSpace($errorPayload)) { try { $parsedError = $errorPayload | ConvertFrom-Json -Depth 20 -ErrorAction Stop $graphError = if ($parsedError.error) { $parsedError.error } else { $parsedError } if ($graphError.code) { $graphErrorCode = [string]$graphError.code } if ($graphError.message) { $graphErrorMessage = [string]$graphError.message } $innerError = $graphError.innerError if ($innerError) { $requestId = $innerError.'request-id' $clientRequestId = $innerError.'client-request-id' } } catch { } } $statusText = if ($null -ne $statusCode) { "HTTP $statusCode" } else { 'HTTP status unavailable' } $codeText = if ($graphErrorCode) { " ($graphErrorCode)" } else { '' } $message = "Microsoft Graph request failed: $statusText$codeText - $graphErrorMessage" $exception = [System.InvalidOperationException]::new($message, $_.Exception) $exception.Data['StatusCode'] = $statusCode $exception.Data['GraphErrorCode'] = $graphErrorCode $exception.Data['RequestId'] = $requestId $exception.Data['ClientRequestId'] = $clientRequestId $exception.Data['RequestUri'] = $currentUri $exception.Data['Method'] = $Method $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'IntuneAssignmentChecker.GraphRequestFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $currentUri ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } if ($null -eq $firstResponse) { $firstResponse = $response } $nextLink = if ($response) { $response.'@odata.nextLink' } else { $null } if (-not $AllPages -and $pageCount -eq 1 -and [string]::IsNullOrWhiteSpace($nextLink)) { return $response } if ($response -and $null -ne $response.value) { foreach ($item in @($response.value)) { $items.Add($item) } } elseif ($AllPages -and $null -ne $response) { $items.Add($response) } if (-not [string]::IsNullOrWhiteSpace($nextLink)) { if (-not $nextLink.StartsWith("$graphBase/beta/", [System.StringComparison]::OrdinalIgnoreCase)) { $exception = [System.Security.SecurityException]::new('Microsoft Graph returned a nextLink outside the active beta endpoint.') $exception.Data['StatusCode'] = $null $exception.Data['RequestUri'] = $nextLink $exception.Data['Method'] = 'GET' $errorRecord = [System.Management.Automation.ErrorRecord]::new( $exception, 'IntuneAssignmentChecker.InvalidGraphNextLink', [System.Management.Automation.ErrorCategory]::SecurityError, $nextLink ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $currentUri = $nextLink $Method = 'GET' $includeBody = $false } } while (-not [string]::IsNullOrWhiteSpace($currentUri) -and -not [string]::IsNullOrWhiteSpace($nextLink)) if ($AllPages) { return , $items.ToArray() } # Preserve the normal response contract while replacing the first-page value with the # complete collection. This makes paging automatic for existing response.value callers. $combinedResponse = [ordered]@{} if ($firstResponse -is [System.Collections.IDictionary]) { foreach ($key in $firstResponse.Keys) { if ($key -notin @('value', '@odata.nextLink')) { $combinedResponse[$key] = $firstResponse[$key] } } } else { foreach ($property in $firstResponse.PSObject.Properties) { if ($property.Name -notin @('value', '@odata.nextLink')) { $combinedResponse[$property.Name] = $property.Value } } } $combinedResponse['value'] = $items.ToArray() return [PSCustomObject]$combinedResponse } |