public/Invoke-ZtAzureRequest.ps1

<#
 .SYNOPSIS
    Proxy function for Invoke-AzRestMethod that adds caching, automatic pagination,
    and OData convenience parameters.
 
 .DESCRIPTION
    This is a proxy function wrapping Invoke-AzRestMethod. It inherits all of
    Invoke-AzRestMethod's parameter sets (ByPath, ByURI, ByParameters) so it works
    identically across all Azure environments (Global, USGov, China) without
    hardcoding any hostnames.
 
    Additional features over Invoke-AzRestMethod:
    * OData query parameters: -Select, -Filter
    * Additional query parameters via -QueryParameters hashtable
    * Session-scoped caching of GET results
    * Automatic pagination enabled by default for GET requests
    * Throws on non-2xx errors (consistent with Invoke-ZtGraphRequest)
    * Return full PSHttpResponse with -FullResponse for StatusCode inspection
    * Automatic unwrapping of .value array from responses
 
 .PARAMETER Path
    Path of target resource URL. Hostname of Resource Manager should not be added.
 
 .PARAMETER Uri
    Uniform Resource Identifier of the Azure resources. The target resource needs to support Azure AD authentication and the access token is derived according to resource id. If resource id is not set, its value is derived according to built-in service suffixes in current Azure Environment.
 
 .PARAMETER ResourceId
    Identifier URI specified by the REST API you are calling. It shouldn't be the resource id of Azure Resource Manager.
 
 .PARAMETER SubscriptionId
    Target Subscription Id
 
 .PARAMETER ResourceGroupName
    Target Resource Group Name
 
 .PARAMETER ResourceProviderName
    Target Resource Provider Name
 
 .PARAMETER ResourceType
    List of Target Resource Type
 
 .PARAMETER Name
    List of Target Resource Name
 
 .PARAMETER ApiVersion
    Api Version
 
 .PARAMETER Method
    Http Method
 
 .PARAMETER Payload
    JSON format payload
 
 .PARAMETER AsJob
    Run cmdlet in the background
 
 .PARAMETER DefaultProfile
    The credentials, account, tenant, and subscription used for communication with Azure.
 
 .PARAMETER WaitForCompletion
    Waits for the long-running operation to complete before returning the result.
 
 .PARAMETER PollFrom
    Specifies the polling header (to fetch from) for long-running operation status.
 
 .PARAMETER FinalResultFrom
    Specifies the header for final GET result after the long-running operation completes.
 
 .PARAMETER NextLinkName
    Specifies the name of the next link JSON property to follow for pagination.
 
 .PARAMETER PageableItemName
    Specifies the name of the JSON property that contains the items in a paginated response.
 
 .PARAMETER MaxPageSize
    Specifies the maximum number of pages to retrieve when following next links in a paginated response.
 
 .PARAMETER Select
    Filters properties (columns). Adds $select query parameter.
 
 .PARAMETER Filter
    Filters results (rows). Adds $filter query parameter.
 
 .PARAMETER QueryParameters
    Additional query parameters to append to the request URL.
 
 .PARAMETER DisablePaging
    Only return first page of results. By default, -Paginate is enabled.
 
 .PARAMETER DisableCache
    Specify if this request should skip cache and go directly to Azure.
 
 .PARAMETER FullResponse
    Return the full PSHttpResponse object instead of just the parsed content.
    When specified, does not throw on non-2xx status codes.
 
 .EXAMPLE
    Invoke-ZtAzureRequest -Path "/subscriptions?api-version=2022-01-01"
 
    Get all subscriptions (api-version in the path, like Invoke-AzRestMethod).
 
 .EXAMPLE
    Invoke-ZtAzureRequest -Path "/subscriptions/$subscriptionId/providers/Microsoft.Compute/virtualMachines?api-version=2024-03-01" -Select "name,location"
 
    Get all virtual machines with selected properties.
 
 .EXAMPLE
    $response = Invoke-ZtAzureRequest -Path "/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" -Filter "atScope()" -FullResponse
    if ($response.StatusCode -eq 403) {
        # Handle forbidden access
    }
 
    Get role assignments at root scope with full response to check status code.
 
 .EXAMPLE
    $body = @{ query = "Resources | where type =~ 'microsoft.compute/virtualmachines'" } | ConvertTo-Json
    Invoke-ZtAzureRequest -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01" -Method POST -Payload $body
 
    Query Azure Resource Graph using POST with pagination (follows $skipToken automatically).
 
 .EXAMPLE
    Invoke-ZtAzureRequest -Uri "https://management.usgovcloudapi.net/subscriptions?api-version=2022-01-01"
 
    Use -Uri for full URL (sovereign cloud). All Invoke-AzRestMethod parameters are supported.
 
#>

function Invoke-ZtAzureRequest {
    [CmdletBinding(DefaultParameterSetName = 'ByPath')]
    param(
        #region Invoke-AzRestMethod: ByPath parameter set
        [Parameter(ParameterSetName = 'ByPath', Mandatory, HelpMessage = 'Path of target resource URL. Hostname of Resource Manager should not be added.')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${Path},
        #endregion

        #region Invoke-AzRestMethod: ByURI parameter set
        [Parameter(ParameterSetName = 'ByURI', Mandatory, Position = 1, HelpMessage = 'Uniform Resource Identifier of the Azure resources.')]
        [ValidateNotNullOrEmpty()]
        [uri]
        ${Uri},

        [Parameter(ParameterSetName = 'ByURI', HelpMessage = 'Identifier URI specified by the REST API you are calling.')]
        [ValidateNotNullOrEmpty()]
        [uri]
        ${ResourceId},
        #endregion

        #region Invoke-AzRestMethod: ByParameters parameter set
        [Parameter(ParameterSetName = 'ByParameters', HelpMessage = 'Target Subscription Id')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${SubscriptionId},

        [Parameter(ParameterSetName = 'ByParameters', HelpMessage = 'Target Resource Group Name')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${ResourceGroupName},

        [Parameter(ParameterSetName = 'ByParameters', HelpMessage = 'Target Resource Provider Name')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${ResourceProviderName},

        [Parameter(ParameterSetName = 'ByParameters', HelpMessage = 'List of Target Resource Type')]
        [ValidateNotNullOrEmpty()]
        [string[]]
        ${ResourceType},

        [Parameter(ParameterSetName = 'ByParameters', HelpMessage = 'list of Target Resource Name')]
        [ValidateNotNullOrEmpty()]
        [string[]]
        ${Name},

        [Parameter(ParameterSetName = 'ByParameters', Mandatory, HelpMessage = 'Api Version')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${ApiVersion},
        #endregion

        #region Invoke-AzRestMethod: Common parameters
        [Parameter(HelpMessage = 'Specifies the method used for the web request. Defaults to GET.')]
        [ValidateSet('GET', 'POST')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${Method},

        [Parameter(HelpMessage = 'JSON format payload')]
        [ValidateNotNullOrEmpty()]
        [string]
        ${Payload},

        [Parameter(HelpMessage = 'Run cmdlet in the background')]
        [switch]
        ${AsJob},

        [Parameter(HelpMessage = 'The credentials, account, tenant, and subscription used for communication with Azure.')]
        [Alias('AzContext', 'AzureRmContext', 'AzureCredential')]
        [Microsoft.Azure.Commands.Common.Authentication.Abstractions.Core.IAzureContextContainer]
        ${DefaultProfile},

        [Parameter(HelpMessage = 'Waits for the long-running operation to complete before returning the result.')]
        [switch]
        ${WaitForCompletion},

        [Parameter(HelpMessage = 'Specifies the polling header (to fetch from) for long-running operation status.')]
        [ValidateSet('AzureAsyncLocation', 'Location', 'OriginalUri', 'Operation-Location')]
        [string]
        ${PollFrom},

        [Parameter(HelpMessage = 'Specifies the header for final GET result after the long-running operation completes.')]
        [ValidateSet('FinalStateVia', 'Location', 'OriginalUri', 'Operation-Location')]
        [string]
        ${FinalResultFrom},

        [Parameter(HelpMessage = 'Specifies the name of the next link JSON property to follow for pagination.')]
        [string]
        ${NextLinkName},

        [Parameter(HelpMessage = 'Specifies the name of the JSON property that contains the items in a paginated response.')]
        [string]
        ${PageableItemName},

        [Parameter(HelpMessage = 'Specifies the maximum number of pages to retrieve when following next links in a paginated response.')]
        [int]
        ${MaxPageSize},
        #endregion

        #region ZtAzureRequest: OData convenience parameters
        # Filters properties (columns). Adds $select query parameter.
        [Parameter()]
        [string[]]
        $Select,

        # Filters results (rows). Adds $filter query parameter.
        [Parameter()]
        [string]
        $Filter,

        # Additional query parameters to append to the request URL.
        [Parameter()]
        [hashtable]
        $QueryParameters,
        #endregion

        #region ZtAzureRequest: Pagination and caching control
        # Only return first page of results. By default, -Paginate is enabled.
        [Parameter()]
        [switch]
        $DisablePaging,

        # Specify if this request should skip cache and go directly to Azure.
        [Parameter()]
        [switch]
        $DisableCache,

        # Return the full PSHttpResponse object instead of just the parsed content.
        # When specified, does not throw on non-2xx status codes.
        [Parameter()]
        [switch]
        $FullResponse
        #endregion
    )

    process {
        # Determine effective method (default to GET)
        $effectiveMethod = if ($Method) { $Method } else { 'GET' }

        #region Build Invoke-AzRestMethod parameters
        # Start with all bound parameters, then remove our custom ones
        $azParams = @{} + $PSBoundParameters

        # Remove ZtAzureRequest-specific parameters (not recognized by Invoke-AzRestMethod)
        foreach ($customParam in @('Select', 'Filter', 'QueryParameters', 'DisablePaging', 'DisableCache', 'FullResponse')) {
            $azParams.Remove($customParam) | Out-Null
        }

        # Inject OData and custom query parameters into -Path or -Uri
        $extraQueryParams = [ordered]@{}
        if ($Select) { $extraQueryParams['$select'] = $Select -join ',' }
        if ($Filter) { $extraQueryParams['$filter'] = $Filter }
        if ($QueryParameters) {
            foreach ($key in $QueryParameters.Keys) {
                $extraQueryParams[$key] = $QueryParameters[$key]
            }
        }

        if ($extraQueryParams.Count -gt 0) {
            $extraQs = ConvertTo-QueryString $extraQueryParams

            if ($azParams.ContainsKey('Path')) {
                # -Path is a relative path (e.g., "/subscriptions?api-version=...")
                $pathStr = $azParams['Path']
                $fragment = ''
                if ($pathStr -match '(#.+)$') {
                    $fragment = $matches[1]
                    $pathStr = $pathStr.Substring(0, $pathStr.Length - $fragment.Length)
                }

                if ($pathStr -match '\?') {
                    $azParams['Path'] = $pathStr + '&' + $extraQs + $fragment
                }
                else {
                    $azParams['Path'] = $pathStr + '?' + $extraQs + $fragment
                }
            }
            elseif ($azParams.ContainsKey('Uri')) {
                # -Uri is a full URL
                # Use UriBuilder to safely handle query parameters and fragments
                $uriBuilder = [System.UriBuilder]::new($azParams['Uri'])
                if ([string]::IsNullOrWhiteSpace($uriBuilder.Query)) {
                    $uriBuilder.Query = $extraQs
                }
                else {
                    # UriBuilder.Query includes the leading '?'
                    $uriBuilder.Query = $uriBuilder.Query.TrimStart('?') + '&' + $extraQs
                }
                $azParams['Uri'] = $uriBuilder.Uri
            }
            else {
                # ByParameters set: Invoke-AzRestMethod builds the URL internally,
                # so we cannot append OData/custom query parameters.
                Write-PSFMessage -Level Warning -Message "OData/query parameters (Select, Filter, QueryParameters) are not supported with the ByParameters parameter set and will be ignored. Use -Path or -Uri instead."
            }
        }

        # Enable automatic pagination for GET requests only.
        # POST-based APIs (e.g. Resource Graph) handle paging via request body (skipToken in options).
        $isGet = $effectiveMethod -eq 'GET'
        if ($isGet -and -not $DisablePaging -and -not $AsJob) {
            $azParams['Paginate'] = $true
        }
        #endregion Build Invoke-AzRestMethod parameters

        #region Determine cache key
        if ($azParams.ContainsKey('Path')) {
            $cacheKey = $azParams['Path']
        }
        elseif ($azParams.ContainsKey('Uri')) {
            $cacheKey = $azParams['Uri'].ToString()
        }
        else {
            # ByParameters set: build a key from the components
            $cacheKey = '{0}/{1}/{2}/{3}/{4}?api-version={5}' -f $SubscriptionId, $ResourceGroupName, $ResourceProviderName, ($ResourceType -join '/'), ($Name -join '/'), $ApiVersion
        }

        # Prefix with HTTP method for non-GET requests to prevent cache-key collisions
        # (e.g. a POST to the same URL as a previous GET should not share a cache entry).
        if ($effectiveMethod -ne 'GET') {
            $cacheKey = "$effectiveMethod`:$cacheKey"
        }
        #endregion Determine cache key

        #region Execute with caching
        $results = Invoke-ZtAzureRequestCache -CacheKey $cacheKey -AzParams $azParams `
            -DisableCache:$DisableCache -FullResponse:$FullResponse
        #endregion Execute with caching

        #region Format Results
        if ($FullResponse) {
            return $results
        }

        # Unwrap .value array if present (standard ARM list response envelope)
        if ($results -and ($results.PSObject.Properties.Name -contains 'value')) {
            return $results.value
        }

        $results
        #endregion Format Results
    }
}