Private/Invoke-GkGraphRequest.ps1
|
function Invoke-GkGraphRequest { <# .SYNOPSIS Single internal chokepoint for all Microsoft Graph traffic in PSGraphKit. .DESCRIPTION Centralizes, on top of the Invoke-GkRawGraphCall seam: * Base-URL/version prefixing (relative URIs) and passthrough of absolute nextLinks. * Automatic pagination: follows @odata.nextLink to completion, re-injecting custom headers (e.g. ConsistencyLevel) on every page since Graph does not carry them over. * 429/503 throttling: honors Retry-After, else bounded exponential backoff with jitter. * Curated error translation for permission (403), auth (401), and query (400) failures, enriched with the caller's active roles, while preserving the raw Graph error/request-id. * Optional one-shot beta fallback (opt-in, off by default; unused in Phase 1). Not exported. Public functions call this, never Invoke-MgGraphRequest directly. .OUTPUTS By default, the flattened array of collection items (all pages). With -Raw, the raw first-page body (Hashtable, or a scalar for /$count). #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Uri, [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')] [string] $Method = 'GET', [hashtable] $Body, [hashtable] $Headers, [ValidateSet('v1.0', 'beta')] [string] $ApiVersion = 'v1.0', [switch] $BetaFallback, # Return the raw first-page body instead of accumulating/unwrapping 'value'. # Used for single-entity reads and /$count endpoints. [switch] $Raw, [ValidateRange(0, 10)] [int] $MaxRetry = 5, # Name of the public function on whose behalf this runs; drives role hints in 403 messages. [string] $CallerFunction, # Set by Get-GkCurrentUserRole to prevent recursive role enrichment on its own 403. [switch] $SuppressRoleEnrichment ) # --- Build the initial URI ------------------------------------------------------------- if ($Uri -match '^https?://') { $current = $Uri } else { $current = '{0}/{1}/{2}' -f $script:GkGraphBaseUri, $ApiVersion, $Uri.TrimStart('/') } $doPaging = (-not $Raw) -and ($Method -eq 'GET') # only GET collections paginate $triedBeta = $false $items = [System.Collections.Generic.List[object]]::new() $attempt = 0 while ($true) { $requestParams = @{ Method = $Method; Uri = $current } if ($Headers) { $requestParams['Headers'] = $Headers } # re-passed every page on purpose if ($Body) { $requestParams['Body'] = $Body } try { $callResult = Invoke-GkRawGraphCall -RequestParams $requestParams } catch { # Transport-level failure (DNS, TLS, no connectivity) — no HTTP status available. $ex = [System.Exception]::new( "PSGraphKit could not reach Microsoft Graph at '$current': $($_.Exception.Message)", $_.Exception) $er = [System.Management.Automation.ErrorRecord]::new( $ex, 'GkGraph_TransportFailure', [System.Management.Automation.ErrorCategory]::ConnectionError, $current) $PSCmdlet.ThrowTerminatingError($er) } $sc = $callResult.StatusCode $rh = $callResult.Headers $response = $callResult.Body # --- Success ---------------------------------------------------------------------- if ($sc -ge 200 -and $sc -lt 300) { $attempt = 0 if ($Raw) { return $response } if ($response -is [System.Collections.IDictionary] -and $response.Contains('value')) { foreach ($v in @($response['value'])) { $items.Add($v) } $next = if ($response.Contains('@odata.nextLink')) { $response['@odata.nextLink'] } else { $null } if ($doPaging -and $next) { $current = [string]$next continue } return , $items.ToArray() } # Single entity or scalar response — nothing to page. return $response } # --- Throttling / transient (429, 503) -------------------------------------------- if ($sc -eq 429 -or $sc -eq 503) { $attempt++ if ($attempt -gt $MaxRetry) { $ex = [System.Exception]::new( "Microsoft Graph is throttling PSGraphKit (HTTP $sc) and did not recover after $MaxRetry retries calling '$current'. Try again later or narrow the query.") $er = [System.Management.Automation.ErrorRecord]::new( $ex, "GkGraph_Throttled_$sc", [System.Management.Automation.ErrorCategory]::LimitsExceeded, $current) $PSCmdlet.ThrowTerminatingError($er) } $retryAfter = Get-GkResponseHeader $rh 'Retry-After' [int] $ra = 0 if ($retryAfter -and [int]::TryParse($retryAfter, [ref] $ra) -and $ra -gt 0) { $delay = [double] $ra } else { $delay = [Math]::Min([Math]::Pow(2, $attempt), 60) + (Get-Random -Minimum 0 -Maximum 1000) / 1000.0 } Write-Verbose ("PSGraphKit: HTTP {0} on attempt {1}/{2}; backing off {3:N1}s." -f $sc, $attempt, $MaxRetry, $delay) Start-Sleep -Seconds $delay continue } # --- One-shot beta fallback (opt-in, off by default; unused in Phase 1) ----------- if ($BetaFallback -and -not $triedBeta -and ($sc -eq 404 -or $sc -eq 400) -and $current -match '/v1\.0/') { $triedBeta = $true $current = $current -replace '/v1\.0/', '/beta/' Write-Verbose "PSGraphKit: v1.0 returned $sc; retrying once against beta ($current)." continue } # --- Curated error translation (401 / 403 / 400 / 404 / other) -------------------- $code = $null; $gmsg = $null; $reqId = $null if ($response -is [System.Collections.IDictionary] -and $response.Contains('error')) { $errObj = $response['error'] if ($errObj -is [System.Collections.IDictionary]) { if ($errObj.Contains('code')) { $code = [string]$errObj['code'] } if ($errObj.Contains('message')) { $gmsg = [string]$errObj['message'] } if ($errObj.Contains('innerError') -and $errObj['innerError'] -is [System.Collections.IDictionary]) { $inner = $errObj['innerError'] foreach ($k in 'request-id', 'client-request-id') { if ($inner.Contains($k)) { $reqId = [string]$inner[$k]; break } } } } } $ctx = Get-MgContext switch ($sc) { 401 { $message = "Microsoft Graph returned 401 Unauthorized calling '$current': your session is expired or invalid. Run Connect-MgGraph again." $category = [System.Management.Automation.ErrorCategory]::AuthenticationError } 403 { $roleLine = '' if (-not $SuppressRoleEnrichment) { if ($ctx -and $ctx.AuthType -eq 'Delegated') { $roles = Get-GkCurrentUserRole if ($roles) { $roleLine = "You currently hold active directory role(s): $($roles -join ', '). " } else { $roleLine = 'Could not read your active roles (a required role may be PIM-eligible but not activated). ' } } elseif ($ctx -and $ctx.AuthType -eq 'AppOnly') { $roleLine = 'This is an app-only session; effective access is the set of application permissions granted to the service principal. ' } if ($CallerFunction -and $script:GkScopeMap.ContainsKey($CallerFunction)) { $entry = $script:GkScopeMap[$CallerFunction] if ($entry.DelegatedOnly -and $ctx -and $ctx.AuthType -eq 'AppOnly') { $roleLine += "$CallerFunction relies on a delegated-only Graph API; reconnect with an interactive/delegated session. " } if ($entry.RoleHints -and $entry.RoleHints.Count -gt 0) { $roleLine += "This operation typically needs one of these roles: $($entry.RoleHints -join ', ') (or a custom role granting the equivalent action). " } } } $message = "Microsoft Graph denied access (403) calling '$current'. Your token has the scope, but access was refused. ${roleLine}Underlying Graph error: $code - $gmsg." $category = [System.Management.Automation.ErrorCategory]::PermissionDenied } 404 { $message = "Microsoft Graph returned 404 Not Found calling '$current': $code - $gmsg." $category = [System.Management.Automation.ErrorCategory]::ObjectNotFound } 400 { $message = "Microsoft Graph rejected the request (400) calling '$current': $code - $gmsg. If this is an advanced query, it should include ConsistencyLevel:eventual and `$count=true." $category = [System.Management.Automation.ErrorCategory]::InvalidArgument } default { $message = "Microsoft Graph request failed (HTTP $sc) calling '$current': $code - $gmsg." $category = [System.Management.Automation.ErrorCategory]::InvalidOperation } } if ($reqId) { $message += " (request-id: $reqId)" } $ex = [System.Exception]::new($message) $er = [System.Management.Automation.ErrorRecord]::new( $ex, "GkGraph_$($sc)_$($code)", $category, $current) $er.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($message) $PSCmdlet.ThrowTerminatingError($er) } } |