Private/Invoke-AzTableRestMethod.ps1

function Invoke-AzTableRestMethod {
    <#
    .SYNOPSIS
        Sends an authenticated HTTP request to the Azure Table Storage REST API.

    .DESCRIPTION
        Builds the full request URL (appending the SAS token when applicable),
        obtains the correct authorization headers, and dispatches the request.
        Returns a PSCustomObject with Content (parsed JSON) and Headers
        (response headers) so that callers can inspect continuation tokens.

        On HTTP errors the native exception from Invoke-WebRequest propagates
        unchanged, so callers can inspect $_.Exception.StatusCode directly.
        When the response body contains an OData error, ErrorDetails.Message
        is rewritten as "<code>: <message>" for readability; otherwise the
        raw error body is kept in $_.ErrorDetails.Message.

    .OUTPUTS
        [PSCustomObject] with properties:
          - Content : Parsed JSON body, or $null for empty responses
          - Headers : Response header collection
          - StatusCode : HTTP status code (int)
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject]$Context,

        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH')]
        [string]$Method,

        # Path after the base endpoint, e.g. "Tables", "mytable()", "Tables('mytable')"
        [Parameter(Mandatory)]
        [string]$Resource,

        # Optional OData query string (without leading '?'), e.g. "$filter=PartitionKey eq 'pk'"
        [string]$QueryString = '',

        # Optional request body (will be serialized to JSON)
        [object]$Body = $null,

        # Optional ETag for conditional operations (If-Match header)
        [string]$ETag = $null
    )

    $contentType = ''
    $bodyBytes   = $null

    if ($null -ne $Body) {
        $contentType = 'application/json'
        $bodyJson    = $Body | ConvertTo-Json -Depth 10 -Compress
        $bodyBytes   = [System.Text.Encoding]::UTF8.GetBytes($bodyJson)
    }

    # Build authorization headers
    $authHeaders = Get-AzTableAuthorizationHeader `
        -Context     $Context `
        -Method      $Method `
        -Resource    $Resource `
        -ContentType $contentType

    $requestHeaders = @{
        'DataServiceVersion'    = '3.0;NetFx'
        'MaxDataServiceVersion' = '3.0;NetFx'
        'Accept'                = 'application/json;odata=nometadata'
    }

    foreach ($key in $authHeaders.Keys) {
        $requestHeaders[$key] = $authHeaders[$key]
    }

    if ($ETag) {
        $requestHeaders['If-Match'] = $ETag
    }

    # Build URL - SAS token always goes first so OData params are appended afterward
    $url = "$($Context.Endpoint)/$Resource"

    if ($Context.AuthType -eq 'SasToken') {
        $url += $Context.SasToken      # already starts with '?'
        if ($QueryString) {
            $url += "&$QueryString"
        }
    } elseif ($QueryString) {
        $url += "?$QueryString"
    }

    $requestParams = @{
        Uri             = $url
        Method          = $Method
        Headers         = $requestHeaders
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }

    if ($null -ne $bodyBytes) {
        $requestParams['Body']        = $bodyBytes
        $requestParams['ContentType'] = 'application/json'
    }

    try {
        $response = Invoke-WebRequest @requestParams
    } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
        $errorRecord = $_
        $rawBody     = $errorRecord.ErrorDetails.Message

        if (-not [string]::IsNullOrWhiteSpace($rawBody)) {
            $parsedError = try { $rawBody | ConvertFrom-Json -ErrorAction Stop } catch { $null }
            $odataError  = $parsedError.'odata.error'

            if ($odataError.code) {
                # Azure puts RequestId/Time on extra lines; keep only the human-readable first line
                $messageValue = ([string]$odataError.message.value -split "`n")[0].Trim()
                $friendly = if ($messageValue) { "$($odataError.code): $messageValue" } else { [string]$odataError.code }
                $errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($friendly)
            }
        }

        throw $errorRecord
    }

    $parsedContent = $null
    $rawContent    = $response.Content

    if ($rawContent -is [byte[]]) {
        $rawContent = [System.Text.Encoding]::UTF8.GetString($rawContent)
    }

    if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
        $parsedContent = $rawContent | ConvertFrom-Json
    }

    return [PSCustomObject]@{
        Content    = $parsedContent
        Headers    = $response.Headers
        StatusCode = [int]$response.StatusCode
    }
}