Private/Invoke-PckDataverseRequest.ps1
|
function Invoke-PckDataverseRequest { <# .SYNOPSIS The single Dataverse Web API funnel (canon 5). Every Web API call in the module routes through here; no public cmdlet calls Invoke-RestMethod directly. .DESCRIPTION Responsibilities, in order: 1. Refuse when not connected (Connect-PckPowerPlatform sets the context). 2. Run request-construction guardrails, which refuse routes that are known to fail before any network call is made (design 6.2). 3. Enforce solution-awareness (canon 7): Post, Patch, and Put require -SolutionName so the result lands inside a solution, unless the caller passes -NoSolution for operations that are deliberately not solution content, such as flipping isexternalsearchindexenabled on the organization. 4. Send, honoring Retry-After on 429 and 503 up to -MaxRetries. 5. Translate failures into PckError with the Dataverse error code in the message. Response-translation guardrails for known error signatures land here as they are authored. .OUTPUTS Parsed response body for 200-range responses with content. For 204 responses, an object with an EntityId property when OData-EntityId is present, otherwise null. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('Get', 'Post', 'Patch', 'Put', 'Delete')] [string] $Method, # Relative to /api/data/v9.2/ ('bots?$select=...'), or an absolute URL. [Parameter(Mandatory)] [string] $Path, [object] $Body, [string] $SolutionName, [switch] $NoSolution, [hashtable] $Headers = @{}, [int] $MaxRetries = 3 ) if (-not $script:PckContext) { throw [PckPreflightError]::new( 'Not connected. Call Connect-PckPowerPlatform first.', $script:PckExitCode.NotConnected) } # Guardrails inspect a normalized view of the request: absolute URLs reduced to # their data-relative path, leading slashes stripped, and string bodies parsed # as JSON when they are JSON. A guardrail that can be bypassed by respelling the # same request is not a guardrail. $guardPath = $Path if ($guardPath -match '(?i)^https?://[^/]+/api/data/v\d+\.\d+/(.+)$') { $guardPath = $Matches[1] } $guardPath = $guardPath.TrimStart('/') $guardBody = $Body if ($Body -is [string]) { try { $guardBody = $Body | ConvertFrom-Json } catch { } } foreach ($check in $script:PckRequestConstructionChecks) { & $check -Method $Method -Path $guardPath -Body $guardBody } $Headers = $Headers.Clone() if ($Method -in @('Post', 'Patch', 'Put')) { if ($NoSolution -and -not [string]::IsNullOrWhiteSpace($SolutionName)) { throw [PckError]::new( '-SolutionName and -NoSolution are contradictory. Pass one or the other.', $script:PckExitCode.General) } if (-not $NoSolution) { if ([string]::IsNullOrWhiteSpace($SolutionName)) { throw [PckError]::new( "A $Method call requires -SolutionName so the result lands inside a solution (canon 7). Pass -NoSolution only for operations that are deliberately not solution content.", $script:PckExitCode.General) } $Headers['MSCRM.SolutionUniqueName'] = $SolutionName } } $merged = Get-PckAuthHeaders foreach ($key in $Headers.Keys) { $merged[$key] = $Headers[$key] } $request = @{ Method = $Method Uri = if ($Path -match '^https?://') { $Path } else { "$($script:PckContext.EnvironmentUrl)/api/data/$($script:PckApiVersion)/$Path" } Headers = $merged } if ($null -ne $Body) { $request.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 } $request.ContentType = 'application/json' } $attempt = 0 while ($true) { $attempt++ try { $response = Invoke-WebRequest @request -ErrorAction Stop break } catch [Microsoft.PowerShell.Commands.HttpResponseException] { $status = [int]$_.Exception.Response.StatusCode if ($status -in @(429, 503) -and $attempt -le $MaxRetries) { $delay = 5 try { $retryAfter = $_.Exception.Response.Headers.RetryAfter if ($retryAfter -and $retryAfter.Delta.HasValue) { $delay = [int]$retryAfter.Delta.Value.TotalSeconds } } catch { } $delay = [Math]::Min([Math]::Max($delay, 1), 60) Write-Verbose "HTTP $status from Dataverse. Retrying in $delay seconds (attempt $attempt of $MaxRetries)." Start-Sleep -Seconds $delay continue } $detail = $null if ($_.ErrorDetails -and $_.ErrorDetails.Message) { try { $detail = ($_.ErrorDetails.Message | ConvertFrom-Json).error } catch { } } $code = if ($detail -and $detail.PSObject.Properties['code']) { $detail.code } else { "HTTP $status" } $message = if ($detail -and $detail.PSObject.Properties['message']) { $detail.message } else { $_.Exception.Message } # Error translators (design 6.2): known failure signatures become # war-story guidance instead of an opaque code. foreach ($translator in $script:PckErrorTranslators) { $translated = & $translator -Method $Method -Path $guardPath -Body $guardBody -Code $code -Message $message if ($translated) { throw [PckError]::new($translated, $script:PckExitCode.KnownBrokenRoute) } } throw [PckError]::new( "Dataverse request failed: $Method $Path. Code: $code. $message", $script:PckExitCode.General) } } if ([int]$response.StatusCode -eq 204 -or [string]::IsNullOrWhiteSpace([string]$response.Content)) { if ($response.Headers.ContainsKey('OData-EntityId')) { $raw = [string]@($response.Headers['OData-EntityId'])[0] if ($raw -match '\(([0-9a-fA-F-]{36})\)') { return [pscustomobject]@{ EntityId = $Matches[1] } } } return $null } return $response.Content | ConvertFrom-Json } |