Private/Invoke-AzureApi.ps1

function Invoke-AzureApi {
    <#
    .SYNOPSIS
        Invokes Azure REST API (Graph or ARM) with standardized error handling.

    .DESCRIPTION
        Single point of entry for all Azure API calls. Handles authentication
        internally - callers should not deal with tokens or auth logic.

        By default, non-success responses result in warnings (silent failure).
        Use -ErrorAction Stop to throw terminating errors on non-success responses.

    .PARAMETER Uri
        The full API URI to call.

    .PARAMETER Api
        The API to target: ARM (Azure Resource Manager) or Graph (Microsoft Graph).
        If not specified, auto-detects from URI.

    .PARAMETER ResourceName
        A friendly name for the resource being loaded, used in verbose/warning messages.

    .PARAMETER Raw
        Return the raw response object (StatusCode, Content) instead of parsed content.
        Used internally for pagination support.

    .OUTPUTS
        [PSObject] The API response content. For collection endpoints, returns the
        'value' array. For single-resource endpoints, returns the full response object.
        Returns nothing on error (unless -ErrorAction Stop is specified).
        With -Raw, returns the response object with StatusCode and Content properties.

    .EXAMPLE
        Invoke-AzureApi -Uri 'https://graph.microsoft.com/v1.0/users' -ResourceName 'Users'

    .EXAMPLE
        Invoke-AzureApi -Uri $armUri -Api ARM -ResourceName 'KeyVaults'

    .EXAMPLE
        # Throw on error instead of warning
        Invoke-AzureApi -Uri $uri -ResourceName 'Critical Resource' -ErrorAction Stop
    #>

    [CmdletBinding()]
    [OutputType([PSObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

        [Parameter()]
        [ValidateSet('ARM', 'Graph')]
        [string]$Api,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ResourceName,

        [Parameter()]
        [switch]$Raw
    )

    # Capture caller's ErrorAction before we override
    $shouldThrow = $ErrorActionPreference -eq 'Stop'

    $ErrorActionPreference = 'Stop'

    Write-Verbose "Loading $ResourceName..."

    # Auto-detect API from URI if not specified
    if (-not $Api) {
        $Api = if ($Uri -match 'graph\.microsoft\.com') { 'Graph' } else { 'ARM' }
    }

    # Get tokens via centralized helper
    $tokens = Get-CIEMToken

    # Make the API call based on target API
    switch ($Api) {
        'Graph' {
            if (-not $tokens.GraphToken) {
                $msg = "Graph API call requested but no Graph token available. Run Connect-CIEM first."
                if ($shouldThrow) { throw $msg }
                Write-Warning $msg
                return
            }
            $headers = @{ Authorization = "Bearer $($tokens.GraphToken)" }
            $restResponse = Invoke-RestMethod -Uri $Uri -Method GET -Headers $headers -ErrorAction SilentlyContinue -ErrorVariable restError -StatusCodeVariable statusCode

            $response = [PSCustomObject]@{
                StatusCode = if ($statusCode) { $statusCode } elseif ($restError) { 400 } else { 200 }
                Content    = if ($restResponse) { $restResponse | ConvertTo-Json -Depth 20 } else { $null }
            }
        }
        'ARM' {
            if (-not $tokens.ARMToken) {
                # Fall back to Invoke-AzRestMethod which uses Az context
                $response = Invoke-AzRestMethod -Uri $Uri -Method GET
            }
            else {
                $headers = @{ Authorization = "Bearer $($tokens.ARMToken)" }
                $restResponse = Invoke-RestMethod -Uri $Uri -Method GET -Headers $headers -ErrorAction SilentlyContinue -ErrorVariable restError -StatusCodeVariable statusCode

                $response = [PSCustomObject]@{
                    StatusCode = if ($statusCode) { $statusCode } elseif ($restError) { 400 } else { 200 }
                    Content    = if ($restResponse) { $restResponse | ConvertTo-Json -Depth 20 } else { $null }
                }
            }
        }
    }

    # Handle no response
    if (-not $response) {
        $msg = "Failed to load $ResourceName - No response"
        if ($shouldThrow) { throw $msg }
        Write-Warning $msg
        return
    }

    # Raw mode returns response object directly (for pagination)
    if ($Raw) {
        return $response
    }

    # Parse and return content
    switch ($response.StatusCode) {
        200 {
            $content = $response.Content | ConvertFrom-Json
            if ($content.PSObject.Properties.Name -contains 'value') {
                $content.value
            }
            else {
                $content
            }
        }
        403 {
            $msg = "Access denied loading $ResourceName - missing permissions"
            if ($shouldThrow) { throw $msg }
            Write-Warning $msg
        }
        404 {
            $msg = "Resource not found: $ResourceName"
            if ($shouldThrow) { throw $msg }
            Write-Verbose $msg
        }
        default {
            $msg = "Failed to load $ResourceName - Status: $($response.StatusCode)"
            if ($shouldThrow) { throw $msg }
            Write-Warning $msg
        }
    }
}