Public/Invoke-MgGraphCommunityRequest.ps1

function Invoke-MgGraphCommunityRequest {
    <#
    .SYNOPSIS
        Calls a Microsoft Graph endpoint using the current MgGraphCommunity session.

    .DESCRIPTION
        A pure-PowerShell drop-in for Invoke-MgGraphRequest. Sends a request to
        Microsoft Graph using the access token from the most recent
        Connect-MgGraphCommunity call.

        Features:
          - Auto-prepends the active environment's Graph host for relative URIs.
            Relative URIs default to the /beta endpoint ('/me' becomes
            'https://graph.microsoft.com/beta/me'), because beta exposes more of
            the Graph surface. Use -V1 for the stable /v1.0 endpoint.
          - Proactive refresh: if the access token expires within 5 minutes, the
            cached refresh token is used to silently re-acquire BEFORE the call.
          - Reactive refresh: on HTTP 401, attempts one silent refresh and retries.
          - Transient errors (HTTP 429 / 503 / 504) are retried up to -MaxRetry times
            with backoff; Retry-After is honored when present.
          - -FollowPagination walks @odata.nextLink and returns combined values.
          - Binary I/O: -InputFilePath uploads a file's bytes; -OutputFilePath streams
            the raw response to disk; -ContentType controls the request body media type.
          - Default headers added via Add-MgGraphCommunityDefaultHeader are merged
            automatically. Per-call -Headers override defaults.
          - A client-request-id is sent on every call; on errors the Graph
            request-id / client-request-id are surfaced for support correlation.
          - Surfaces Microsoft Graph error.code / error.message as PowerShell errors.

        Requires Connect-MgGraphCommunity to have established a session first.

    .PARAMETER Method
        HTTP method. Defaults to GET.

    .PARAMETER Uri
        Full URL (https://graph.microsoft.com/...) or a relative path
        ('/me', 'users?$top=5').

    .PARAMETER Body
        Request body. Objects are serialized as JSON; strings are sent as-is.

    .PARAMETER Headers
        Per-call HTTP headers. Merged on top of any default headers; per-call wins.

    .PARAMETER OutputType
        'PSObject' (default) parses JSON into PowerShell objects.
        'Hashtable' returns ConvertFrom-Json -AsHashtable.
        'HttpResponse' returns the raw response object.

    .PARAMETER FollowPagination
        Auto-follow @odata.nextLink and return the merged .value items from all
        pages as a single array (also for single-page collection responses).

    .PARAMETER Beta
        Use the /beta endpoint when expanding a relative URI. Beta is already the
        default; this switch is retained for backward compatibility and is a no-op
        unless combined with nothing else.

    .PARAMETER V1
        Use the stable /v1.0 endpoint instead of the default /beta when expanding a
        relative URI. Ignored for absolute URLs (which carry their own version).
        Takes precedence over -Beta if both are supplied.

    .PARAMETER ContentType
        Media type for the request body. Defaults to 'application/json'. When set to
        anything other than JSON, the body is sent as-is (string or byte[]) without
        being serialized to JSON.

    .PARAMETER InputFilePath
        Path to a file whose raw bytes are sent as the request body (uploads, e.g.
        PUT .../photo/$value or an upload-session chunk). Defaults ContentType to
        'application/octet-stream' when no -ContentType is given.

    .PARAMETER OutputFilePath
        Write the raw response body to this file instead of parsing it (downloads,
        e.g. GET .../photo/$value). The response bytes are streamed binary-safe.

    .PARAMETER MaxRetry
        Maximum number of retries for transient HTTP errors (429 / 503 / 504).
        Defaults to 3. Set to 0 to disable transient-error retries.

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Uri '/me'

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Uri "/me/photo/`$value" -OutputFilePath ./me.jpg

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Method PUT -Uri "/me/photo/`$value" -InputFilePath ./me.jpg -ContentType 'image/jpeg'

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Method GET -Uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices'

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Uri '/users?$top=5' # /beta (default)

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Uri '/users?$top=5' -V1 # stable /v1.0

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Uri '/users' -FollowPagination

    .EXAMPLE
        Invoke-MgGraphCommunityRequest -Method POST -Uri '/groups' -Body @{
            displayName = 'Marketing'
            mailEnabled = $false
            mailNickname = 'marketing'
            securityEnabled = $true
        }
    #>

    [CmdletBinding()]
    [Alias('Invoke-MgcRequest')]
    param(
        [ValidateSet('GET','POST','PUT','PATCH','DELETE')]
        [string]$Method = 'GET',

        [Parameter(Mandatory, Position = 0)]
        [string]$Uri,

        [object]$Body,

        [hashtable]$Headers,

        [ValidateSet('PSObject','Hashtable','HttpResponse')]
        [string]$OutputType = 'PSObject',

        [switch]$FollowPagination,

        [switch]$Beta,

        [switch]$V1,

        [string]$ContentType,

        [string]$InputFilePath,

        [string]$OutputFilePath,

        [ValidateRange(0, 10)]
        [int]$MaxRetry = 3
    )

    if (-not $script:MgcActiveSession) {
        throw "Not connected. Run Connect-MgGraphCommunity first."
    }

    # ---- Proactive token refresh ----
    # If the access token expires within 5 minutes, refresh before the call.
    if ($script:MgcActiveSession.ExpiresOn -and $script:MgcActiveSession.Tokens.refresh_token) {
        $remainingMin = ($script:MgcActiveSession.ExpiresOn - (Get-Date).ToUniversalTime()).TotalMinutes
        if ($remainingMin -le 5) {
            Write-Verbose ("Access token expires in {0:N1} min - refreshing proactively." -f $remainingMin)
            try {
                $newTokens = Invoke-MgcRefreshTokenAuth `
                    -LoginEndpoint $script:MgcActiveSession.Authority.Login `
                    -TenantSegment $script:MgcActiveSession.TenantSegment `
                    -ClientId      $script:MgcActiveSession.ClientId `
                    -RefreshToken  $script:MgcActiveSession.Tokens.refresh_token `
                    -Scopes        $script:MgcActiveSession.Scopes
                $script:MgcActiveSession.Tokens    = $newTokens
                $script:MgcActiveSession.ExpiresOn = Get-MgcTokenExpiry -Tokens $newTokens
                Save-MgcTokenCache -Key $script:MgcActiveSession.CacheKey -Tokens $newTokens -Persist:$script:MgcActiveSession.Persist
            } catch {
                Write-Verbose "Proactive refresh failed (will rely on reactive 401 retry): $_"
            }
        }
    }

    # Resolve relative URIs against the active environment
    $resolvedUri = if ($Uri -match '^https?://') {
        $Uri
    } else {
        # Default to /beta (more surface area); -V1 opts down to the stable endpoint.
        $apiVer  = if ($V1) { 'v1.0' } else { 'beta' }
        $cleaned = $Uri.TrimStart('/')
        "{0}/{1}/{2}" -f $script:MgcActiveSession.Authority.GraphResource, $apiVer, $cleaned
    }

    # ---- Resolve the request body and its media type ----
    $effectiveContentType = if ($ContentType)         { $ContentType }
                            elseif ($InputFilePath)    { 'application/octet-stream' }
                            else                       { 'application/json' }
    $isJsonBody = $effectiveContentType -match 'json'

    $requestBody = $null
    $hasBody     = $false
    if ($InputFilePath) {
        $resolvedInput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InputFilePath)
        if (-not (Test-Path -LiteralPath $resolvedInput)) { throw "InputFilePath not found: $InputFilePath" }
        $requestBody = [System.IO.File]::ReadAllBytes($resolvedInput)
        $hasBody     = $true
    } elseif ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        # JSON bodies that aren't already a string get serialized; everything else
        # (raw string, byte[], non-JSON content type) is sent as-is.
        $requestBody = if ($isJsonBody -and -not ($Body -is [string])) {
            $Body | ConvertTo-Json -Depth 20 -Compress
        } else {
            $Body
        }
        $hasBody = $true
    }

    # One client-request-id per call (reused across retries) for support correlation.
    $clientRequestId = [guid]::NewGuid().ToString()

    # Build the request closure (so we can call it multiple times for retries)
    $sendRequest = {
        param([string]$accessToken)

        $mergedHeaders = @{
            'Content-Type'      = $effectiveContentType
            'client-request-id' = $clientRequestId
        }
        # Session-wide default headers (set via Add-MgGraphCommunityDefaultHeader)
        if ($script:MgcDefaultHeaders) {
            foreach ($k in $script:MgcDefaultHeaders.Keys) { $mergedHeaders[$k] = $script:MgcDefaultHeaders[$k] }
        }
        # Per-call -Headers override defaults
        if ($Headers) {
            foreach ($k in $Headers.Keys) { $mergedHeaders[$k] = $Headers[$k] }
        }
        # Always last: neither default nor per-call headers may replace the
        # session's bearer token (hashtable keys are case-insensitive).
        $mergedHeaders['Authorization'] = "Bearer $accessToken"

        $params = @{
            Uri     = $resolvedUri
            Method  = $Method
            Headers = $mergedHeaders
        }

        if ($hasBody) { $params['Body'] = $requestBody }

        # Invoke-MgcHttpRequest abstracts -SkipHttpErrorCheck differences across
        # PS 5.1 vs 7.x and returns a uniform { StatusCode; Headers; Content } object.
        # -ReturnBytes captures binary-safe bytes for file downloads.
        if ($OutputFilePath) {
            Invoke-MgcHttpRequest -Parameters $params -ReturnBytes
        } else {
            Invoke-MgcHttpRequest -Parameters $params
        }
    }

    # ---- First attempt ----
    $attempt = & $sendRequest -accessToken $script:MgcActiveSession.Tokens.access_token

    # ---- Retry on 401: reactive refresh once if possible ----
    if ($attempt.StatusCode -eq 401 -and $script:MgcActiveSession.Tokens.refresh_token) {
        Write-Verbose "401 from Graph - attempting silent token refresh."
        try {
            $newTokens = Invoke-MgcRefreshTokenAuth `
                -LoginEndpoint $script:MgcActiveSession.Authority.Login `
                -TenantSegment $script:MgcActiveSession.TenantSegment `
                -ClientId      $script:MgcActiveSession.ClientId `
                -RefreshToken  $script:MgcActiveSession.Tokens.refresh_token `
                -Scopes        $script:MgcActiveSession.Scopes
            $script:MgcActiveSession.Tokens    = $newTokens
            $script:MgcActiveSession.ExpiresOn = Get-MgcTokenExpiry -Tokens $newTokens
            Save-MgcTokenCache -Key $script:MgcActiveSession.CacheKey -Tokens $newTokens -Persist:$script:MgcActiveSession.Persist
            $attempt = & $sendRequest -accessToken $newTokens.access_token
        } catch {
            Write-Verbose "Reactive refresh failed: $_"
        }
    }

    # ---- Retry transient errors (429 / 503 / 504) with backoff ----
    # Honors Retry-After when present (429 / 503); otherwise exponential backoff
    # capped at 60s (504 rarely carries Retry-After).
    $retry = 0
    while (($attempt.StatusCode -in 429, 503, 504) -and ($retry -lt $MaxRetry)) {
        $retry++
        $wait = $null
        if ($attempt.Headers -and $attempt.Headers['Retry-After']) {
            try { $wait = [int]([array]$attempt.Headers['Retry-After'])[0] } catch { $wait = $null }
        }
        if ($null -eq $wait) { $wait = [int][Math]::Min(60, [Math]::Pow(2, $retry) * 2) }
        $wait = [Math]::Max(0, $wait)
        Write-Verbose ("HTTP {0} from Graph - retry {1}/{2} after {3}s." -f $attempt.StatusCode, $retry, $MaxRetry, $wait)
        Start-Sleep -Seconds $wait
        $attempt = & $sendRequest -accessToken $script:MgcActiveSession.Tokens.access_token
    }

    # ---- Error handling ----
    if ($attempt.StatusCode -ge 400) {
        $msg = "HTTP $($attempt.StatusCode) from $resolvedUri"
        try {
            if ($attempt.Content) {
                $errBody = $attempt.Content | ConvertFrom-Json -ErrorAction Stop
                if ($errBody.error) {
                    $msg = "Graph error $($attempt.StatusCode) [$($errBody.error.code)]: $($errBody.error.message)"
                }
            }
        } catch { }
        # Surface correlation IDs for Graph support tickets.
        $corr = @()
        if ($attempt.Headers) {
            if ($attempt.Headers['request-id'])        { $corr += "request-id: $(($attempt.Headers['request-id'] -join ' '))" }
            if ($attempt.Headers['client-request-id']) { $corr += "client-request-id: $(($attempt.Headers['client-request-id'] -join ' '))" }
        }
        if ($corr.Count -eq 0) { $corr += "client-request-id: $clientRequestId" }
        $msg += " (" + ($corr -join '; ') + ")"
        throw $msg
    }

    # ---- Binary download: stream raw bytes to disk ----
    if ($OutputFilePath) {
        $fullOut = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputFilePath)
        $bytes   = if ($null -ne $attempt.ContentBytes) { $attempt.ContentBytes } else { [byte[]]@() }
        [System.IO.File]::WriteAllBytes($fullOut, $bytes)
        return (Get-Item -LiteralPath $fullOut)
    }

    if ($OutputType -eq 'HttpResponse') { return $attempt }

    # Parse JSON response
    $contentType = $null
    if ($attempt.Headers) { $contentType = $attempt.Headers['Content-Type'] }
    $isJson = $contentType -and ($contentType -join ' ') -match 'json'
    $raw    = $attempt.Content

    if (-not $raw) { return $null }
    if (-not $isJson) { return $raw }

    # ConvertFrom-Json -AsHashtable is PS 6+; on PS 5.1 we convert PSObject -> Hashtable manually.
    $parsed = if ($OutputType -eq 'Hashtable') {
        if ($PSVersionTable.PSVersion.Major -ge 6) {
            $raw | ConvertFrom-Json -AsHashtable
        } else {
            $raw | ConvertFrom-Json | ConvertTo-MgcHashtable
        }
    } else {
        $raw | ConvertFrom-Json
    }

    # ---- Pagination ----
    # Works whether $parsed is a Hashtable (PS 5.1 fallback) or a PSCustomObject.
    # Keyed on the PRESENCE of .value (an empty first page is still a collection),
    # so -FollowPagination always returns the merged items, even for one page.
    if ($FollowPagination) {
        $hasValue = if ($parsed -is [System.Collections.IDictionary]) {
            $parsed.Contains('value')
        } else {
            $null -ne $parsed.PSObject.Properties['value']
        }
        if ($hasValue) {
            $all  = @($parsed.value)
            $next = $parsed.'@odata.nextLink'
            while ($next) {
                $page = Invoke-MgGraphCommunityRequest -Method GET -Uri $next -OutputType $OutputType
                if ($page.value) { $all += $page.value }
                $next = $page.'@odata.nextLink'
            }
            return $all
        }
    }

    return $parsed
}