modules/Azure/Infrastructure/Public/Invoke-AzureApi.ps1

function Invoke-AzureApi {
    <#
    .SYNOPSIS
        Invokes Azure REST API (ARM, Graph, or KeyVault) 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.

        Supports three calling patterns:
        - ByUri: Pass a full URL via -Uri
        - ByPath: Pass a relative path via -Path with a mandatory -Api selector
        - Batch: Pass Graph sub-requests via -Requests to call Microsoft Graph $batch

    .PARAMETER Uri
        Full API URL (ByUri set). Mutually exclusive with -Path and -Requests.

    .PARAMETER Path
        Relative path appended to the base URL resolved from azure_provider_apis.

    .PARAMETER Requests
        Graph batch sub-requests. Each entry must have Id, Method, and Path.

    .PARAMETER Api
        Target API selector (ARM, Graph, GraphBeta, KeyVault). Mandatory for
        all parameter sets — the old URI host auto-detection path has been
        removed.

    .PARAMETER ResourceName
        Friendly resource name used in error messages.

    .PARAMETER Method
        HTTP method for ByUri/ByPath calls. Defaults to GET.

    .PARAMETER Body
        Hashtable/PSCustomObject request body for POST/PUT/PATCH.

    .PARAMETER Raw
        Return the raw response envelope with StatusCode/Body/Headers instead of
        unwrapping paginated `value`/`data` collections.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByUri')]
    [OutputType([PSObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ByUri')]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

        [Parameter(Mandatory, ParameterSetName = 'ByPath')]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory, ParameterSetName = 'Batch')]
        [AllowEmptyCollection()]
        [hashtable[]]$Requests,

        [Parameter(Mandatory, ParameterSetName = 'ByUri')]
        [Parameter(Mandatory, ParameterSetName = 'ByPath')]
        [Parameter(Mandatory, ParameterSetName = 'Batch')]
        [ValidateSet('ARM', 'Graph', 'GraphBeta', 'KeyVault')]
        [string]$Api,

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

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [object]$Body,

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [switch]$Raw
    )

    $ErrorActionPreference = 'Stop'
    $ProgressPreference = 'SilentlyContinue'

    if ($PSCmdlet.ParameterSetName -eq 'ByPath') {
        $apiRecord = GetCIEMAzureProviderApi -Name $Api
        if (-not $apiRecord) {
            throw "No API endpoint record found for '$Api' in azure_provider_apis table."
        }

        $baseUrl = $apiRecord.BaseUrl.TrimEnd('/')
        $Uri = "$baseUrl/$($Path.TrimStart('/'))"
    }

    if (-not $script:AzureAuthContext -or -not $script:AzureAuthContext.IsConnected) {
        throw 'Not connected to Azure. Run Connect-CIEM first.'
    }

    $jsonBody = $null
    if ($Body) {
        $jsonBody = $Body | ConvertTo-Json -Depth 20 -Compress
    }

    $tokenPropertyMap = @{
        'Graph'     = 'GraphToken'
        'GraphBeta' = 'GraphToken'
        'ARM'       = 'ARMToken'
        'KeyVault'  = 'KeyVaultToken'
    }
    $token = $script:AzureAuthContext.($tokenPropertyMap[$Api])

    if (-not $token) {
        throw "$Api API call requested but no $Api token available. Run Connect-CIEM first."
    }

    $headers = @{ Authorization = "Bearer $token" }

    if ($PSCmdlet.ParameterSetName -eq 'Batch') {
        return InvokeAzureBatchRequests -BatchRequests $Requests -BatchApi $Api -BatchHeaders $headers -BatchResourceName $ResourceName
    }

    Write-Verbose "Loading $ResourceName..."

    $currentUri = $Uri
    $currentMethod = $Method
    $currentJsonBody = $jsonBody
    $currentBodyObject = $Body
    $pageCount = 0
    $lastNextLink = $null
    $hasWrittenPaginationProgress = $false

    try {
        while ($currentUri) {
            $currentResponse = InvokeAzureRequestWithRetry -RequestUri $currentUri -RequestHeaders $headers -RequestMethod $currentMethod -JsonBody $currentJsonBody -FriendlyName $ResourceName
            AssertSuccessfulAzureResponse -Response $currentResponse -FriendlyName $ResourceName

            if ($Raw) {
                return $currentResponse
            }

            $statusCode = [int]$currentResponse.StatusCode

            if ($statusCode -eq 200) {
                $content = $currentResponse.Body
                if (-not $content) {
                    $currentUri = $null
                    continue
                }

                $pageCount++
                $isValueCollection = $content.PSObject.Properties.Name -contains 'value'
                $isDataCollection = $content.PSObject.Properties.Name -contains 'data'

                if (-not ($isValueCollection -or $isDataCollection)) {
                    # Single-object response — emit and exit the loop
                    $content
                    $currentUri = $null
                    continue
                }

                $items = if ($isValueCollection) { @($content.value) } else { @($content.data) }
                $nextLink = if ($content.PSObject.Properties.Name -contains '@odata.nextLink') {
                    $content.'@odata.nextLink'
                }
                elseif ($content.PSObject.Properties.Name -contains 'nextLink') {
                    $content.nextLink
                }
                else {
                    $null
                }

                if ($items.Count -eq 0 -and $nextLink) {
                    $currentUri = $null
                    continue
                }

                if ($nextLink) {
                    if ($lastNextLink -and $nextLink -eq $lastNextLink) {
                        throw "Failed to load $ResourceName - pagination cycle detected at '$nextLink'."
                    }

                    $lastNextLink = $nextLink
                    $hasWrittenPaginationProgress = $true
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount" -CurrentOperation $nextLink
                }
                elseif ($hasWrittenPaginationProgress) {
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount"
                }

                $items

                if ($nextLink) {
                    $currentUri = $nextLink
                    $currentMethod = 'GET'
                    $currentJsonBody = $null
                    continue
                }

                if ($content.PSObject.Properties.Name -contains '$skipToken' -and $content.'$skipToken' -and $currentMethod -eq 'POST') {
                    $currentBodyObject = ConvertToSkipTokenBody -BodyObject $currentBodyObject -SkipToken $content.'$skipToken'
                    $currentJsonBody = $currentBodyObject | ConvertTo-Json -Depth 20 -Compress
                    $hasWrittenPaginationProgress = $true
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount"
                    continue
                }

                $currentUri = $null
                continue
            }

            $currentUri = $null
        }
    }
    finally {
        if ($hasWrittenPaginationProgress) {
            Write-Progress -Activity "Loading $ResourceName" -Completed
        }
    }
}