Private/Invoke-DataverseHttp.ps1
function Invoke-DataverseHttp { <# .SYNOPSIS Core HTTP executor for Dataverse requests. .DESCRIPTION Centralizes URL normalization, header building, request execution, and structured output. Does not throw for normal HTTP errors; returns a structured result contract. .PARAMETER Method HTTP method: GET, POST, PATCH, DELETE. .PARAMETER AccessToken OAuth access token string. .PARAMETER Url Base environment URL. If not supplied, will try to derive from token 'aud' via Get-UrlFromAccessToken. .PARAMETER Query Path/query beginning with '/'. Will be normalized. .PARAMETER Body Optional object. Serialized to JSON when provided; sets Content-Type to application/json if not set. .PARAMETER Headers Extra headers to merge. .PARAMETER TimeoutSec Request timeout in seconds. .OUTPUTS PSCustomObject with fields: StatusCode, Headers, Content, RawContent, Success, Error, RequestId, CorrelationId #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string] $Method, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string] $AccessToken, [string] $Url, [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string] $Query, [object] $Body, [hashtable] $Headers, [int] $TimeoutSec ) # Derive URL if needed # If Url isn't provided, we pull 'aud' from the access token payload via Get-UrlFromAccessToken. # This preserves the existing public behavior where Url was optional when the token audience matched the env URL. if ([string]::IsNullOrEmpty($Url)) { $extractedUrl = Get-UrlFromAccessToken -AccessToken $AccessToken if ($extractedUrl) { $Url = $extractedUrl } else { throw "Could not extract URL from the access token." } } if ([string]::IsNullOrEmpty($Url)) { throw "URL is required. Either provide it as a parameter or use an access token that contains an 'aud' claim." } # Normalize URL and Query # - Trim trailing slash from Url # - Ensure Query starts with '/' # - Build requestUrl for Invoke-WebRequest if ($Url.EndsWith('/')) { $Url = $Url.TrimEnd('/') } if (-not $Query.StartsWith('/')) { $Query = "/$Query" } $requestUrl = "$Url$Query" # Auto content-type if sending a body and not provided by headers # Default to application/json for body scenarios unless explicitly overridden by caller headers. $contentType = $null if ($null -ne $Body) { $contentType = 'application/json' } if ($Headers -and $Headers.ContainsKey('Content-Type')) { $contentType = $Headers['Content-Type'] } # Build headers with sane defaults and pass-through # New-DataverseHeaders composes Prefer parts and merges ExtraHeaders to maintain intent. $builtHeaders = New-DataverseHeaders -AccessToken $AccessToken -ContentType $contentType -ExtraHeaders $Headers # Serialize body when present $jsonBody = $null if ($null -ne $Body) { $jsonBody = $Body | ConvertTo-Json -Depth 20 -Compress } # Prepare Invoke-WebRequest splat # TimeoutSec is passed through if specified; otherwise platform default is used. $splat = @{ Uri = $requestUrl; Method = $Method; Headers = $builtHeaders; ErrorAction = 'Stop' } if ($jsonBody) { $splat['Body'] = $jsonBody } if ($TimeoutSec -gt 0) { $splat['TimeoutSec'] = $TimeoutSec } try { $response = Invoke-WebRequest @splat # Try parse JSON content $parsed = $null $raw = $response.Content if ($raw) { try { $parsed = $raw | ConvertFrom-Json } catch { $parsed = $null } } # capture ids # Capture common request/trace identifiers when provided by the service. $reqId = $null $corr = $null try { $reqId = $response.Headers['x-ms-service-request-id'] } catch { Write-Verbose 'Invoke-DataverseHttp: x-ms-service-request-id header not present.' } try { if (-not $corr) { $corr = $response.Headers['x-ms-client-request-id'] } } catch { Write-Verbose 'Invoke-DataverseHttp: x-ms-client-request-id header not present.' } return [PSCustomObject]@{ StatusCode = $response.StatusCode Headers = $response.Headers Content = $parsed RawContent = $raw Success = $true RequestId = $reqId CorrelationId = $corr } } catch { $statusCode = $null try { if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } } catch { Write-Verbose 'Invoke-DataverseHttp: Unable to read StatusCode from exception response.' } $responseContent = '' if ($_.ErrorDetails) { $responseContent = $_.ErrorDetails.Message } # Initialize parsed content; try JSON first, else keep the raw string. $contentObj = $null if ($responseContent) { try { $contentObj = $responseContent | ConvertFrom-Json } catch { $contentObj = $responseContent } } $reqId = $null; $corr = $null try { if ($_.Exception.Response -and $_.Exception.Response.Headers) { $reqId = $_.Exception.Response.Headers['x-ms-service-request-id']; $corr = $_.Exception.Response.Headers['x-ms-client-request-id'] } } catch { Write-Verbose 'Invoke-DataverseHttp: Unable to read identifiers from exception response headers.' } if ($statusCode) { return [PSCustomObject]@{ StatusCode = $statusCode Headers = $null Content = $contentObj RawContent = $responseContent Error = $_.Exception.Message Success = $false RequestId = $reqId CorrelationId = $corr } } else { return [PSCustomObject]@{ Error = $_.Exception.Message Success = $false RequestId = $reqId CorrelationId = $corr } } } } |