Public/Invoke-OpenApiRequest.ps1
|
<# .SYNOPSIS Sends a request for an OpenAPI operation and returns the response as PowerShell objects. .DESCRIPTION Invoke-OpenApiRequest is the request engine that every module generated by New-OpenApiModule uses. Wrapper functions call it with the operation's metadata and the parameter values the user passed; you can also call it directly, for example for an operation the generator skipped. It uses the connection stored with Set-OpenApiContext for the service (base URI, credentials, headers, proxy, timeout and retries) and: - builds the URL from the path template and the path and query parameters, serialising arrays and objects with the parameter's style and explode settings (form, spaceDelimited, pipeDelimited, deepObject, simple, label, matrix); booleans are sent as true/false and dates as ISO 8601; a path parameter flagged CatchAll or AllowReserved keeps the '/' of its value (each segment is escaped on its own); - sends header and cookie parameters (cookies as one Cookie header); - encodes the body by content type: JSON, application/x-www-form-urlencoded, multipart/form-data (FileInfo, byte[] and stream values become file parts), text, or binary (byte[], stream or FileInfo); - authenticates with the first security requirement of the operation that the context has credentials for (API key in a header, query or cookie, HTTP basic, bearer token, or OAuth2 client credentials with token caching and one refresh on 401); - retries throttled and failed requests with tcs.core Invoke-WithRetry, honouring Retry-After; - converts JSON responses to objects (adding the response PSTypeName), outputs the items of a page for a pageable operation and the UnwrapProperty of the response for other operations that name one, saves binary responses to -OutFile, and with -All follows next-page links or repeats the request with the page token of each response. HTTP errors are written as ErrorRecords with the FullyQualifiedErrorId 'OpenApi.<Service>.<StatusCode>' (connection failures 'OpenApi.<Service>.Connection'); when -Cmdlet is passed they are written through the calling command, so its -ErrorAction and -ErrorVariable apply. .PARAMETER Service The service name used with Set-OpenApiContext. Defaults to the Service of the operation metadata. .PARAMETER Operation The operation metadata (a hashtable or object with OperationId, Method, Path, Parameters, RequestContentTypes, ResponseContentTypes, BinaryResponse, Security, SecuritySchemes, Paging, ResponseTypeName and optionally UnwrapProperty), as written by the generator to OpenApi/operations.json. Paging is { Kind = 'nextLink'; ItemsProperty; NextLinkProperty }, { Kind = 'linkHeader'; ItemsProperty } or { Kind = 'token'; ItemsProperty; TokenParameter; TokenProperty }. Members that are missing (metadata written by an older generator) keep their earlier behaviour. .PARAMETER PathParameters Values of path parameters, keyed by their name in the specification. .PARAMETER QueryParameters Values of query parameters, keyed by their name in the specification. Only the keys present are sent. .PARAMETER HeaderParameters Values of header parameters, keyed by their name in the specification. .PARAMETER CookieParameters Values of cookie parameters, keyed by their name in the specification. .PARAMETER Body The request body: a hashtable or object (JSON, form and multipart), a string (sent as it is), a byte array, stream or FileInfo (binary). An explicit $null is sent as JSON null. .PARAMETER ContentType The media type of the body. Defaults to the first of the operation's RequestContentTypes, else application/json. .PARAMETER OutFile Saves the response body to this file and returns the FileInfo. .PARAMETER All Follows next-page links (a nextLink property or a Link header with rel="next") on the same host, or for token paging repeats the request with the token query parameter set from each response (the other query parameters, the method and the body are kept) until the token is empty, missing or repeats, and writes the items of every page. .PARAMETER Raw Returns an object with StatusCode, Headers and Content (text, or bytes for binary responses) instead of converted objects. .PARAMETER Cmdlet The $PSCmdlet of the calling function. Errors, warnings and verbose output are written through it. .INPUTS None This function does not accept pipeline input. .OUTPUTS System.Object The converted response (objects with the operation's response PSTypeName), a Tcs.OpenApi.RawResponse with -Raw, System.IO.FileInfo with -OutFile, a byte array for binary responses, or nothing for empty responses. .EXAMPLE $operation = @{ OperationId = 'getPetById'; Method = 'GET'; Path = '/pets/{petId}' Parameters = @(@{ Name = 'petId'; In = 'path' }) Security = @(@{ api_key = @() }) SecuritySchemes = @{ api_key = @{ Type = 'apiKey'; In = 'header'; ParameterName = 'X-API-Key' } } ResponseTypeName = 'PetStore.Pet' } Invoke-OpenApiRequest -Service 'PetStore' -Operation $operation -PathParameters @{ petId = 42 } Gets pet 42 from the service configured with Set-OpenApiContext -Service PetStore. .EXAMPLE Invoke-OpenApiRequest -Service 'PetStore' -Operation $listPets -QueryParameters @{ tags = @('dog', 'cat') } -All Lists pets with two tags, following next-page links. .NOTES Author: Nigel Tatschner Company: TheCodeSaiyan Verbose output shows each request line and status; Debug output also shows headers and bodies with Authorization, API keys, cookies and properties named like password, secret or token replaced by ********. A deprecated operation writes one warning per session. .LINK Set-OpenApiContext #> function Invoke-OpenApiRequest { [CmdletBinding()] [OutputType([System.Object])] param( [Parameter(HelpMessage = 'The service name used with Set-OpenApiContext.')] [string]$Service, [Parameter(Mandatory, HelpMessage = 'The operation metadata.')] [ValidateNotNull()] [object]$Operation, [Parameter(HelpMessage = 'Path parameter values keyed by spec name.')] [AllowNull()] [System.Collections.IDictionary]$PathParameters, [Parameter(HelpMessage = 'Query parameter values keyed by spec name.')] [AllowNull()] [System.Collections.IDictionary]$QueryParameters, [Parameter(HelpMessage = 'Header parameter values keyed by spec name.')] [AllowNull()] [System.Collections.IDictionary]$HeaderParameters, [Parameter(HelpMessage = 'Cookie parameter values keyed by spec name.')] [AllowNull()] [System.Collections.IDictionary]$CookieParameters, [Parameter(HelpMessage = 'The request body.')] [AllowNull()] [object]$Body, [Parameter(HelpMessage = 'The media type of the body.')] [string]$ContentType, [Parameter(HelpMessage = 'File to save the response body to.')] [string]$OutFile, [Parameter(HelpMessage = 'Follow next-page links.')] [switch]$All, [Parameter(HelpMessage = 'Return status, headers and content instead of objects.')] [switch]$Raw, [Parameter(HelpMessage = 'The $PSCmdlet of the calling function.')] [AllowNull()] [System.Management.Automation.PSCmdlet]$Cmdlet ) $bodyGiven = $PSBoundParameters.ContainsKey('Body') Import-OpenApiHttpAssembly # Preferences of the calling command do not cross module boundaries; take them from -Cmdlet. # Set-Variable honours -WhatIf, so it is called with -WhatIf:$false: a read has no -WhatIf of its own and # must still run, with the caller's preferences, under a session-wide $WhatIfPreference if ($null -ne $Cmdlet) { $callerPreference = Get-OpenApiCallerPreference -Cmdlet $Cmdlet foreach ($preference in @($callerPreference.Keys)) { $commonName = $preference.Replace('Preference', '') if (-not $PSBoundParameters.ContainsKey($commonName) -and -not ($preference -eq 'WarningPreference' -and $PSBoundParameters.ContainsKey('WarningAction'))) { $value = $callerPreference[$preference] if ([string]$value -eq 'Ignore') { # See below: Ignore is not a valid preference variable value on Windows PowerShell 5.1 $value = [System.Management.Automation.ActionPreference]::SilentlyContinue } Set-Variable -Name $preference -Value $value -Confirm:$false -WhatIf:$false } } } if ($DebugPreference -eq 'Inquire') { # Windows PowerShell 5.1 sets Inquire for -Debug; do not prompt for every message $DebugPreference = 'Continue' } # Windows PowerShell 5.1 throws when Write-Warning (or Write-Verbose ...) reads a preference variable set to # Ignore (as -WarningAction Ignore does); the engine writes its own messages with SilentlyContinue instead foreach ($preference in @('VerbosePreference', 'DebugPreference', 'WarningPreference', 'InformationPreference')) { if ([string](Get-Variable -Name $preference -ValueOnly) -eq 'Ignore') { Set-Variable -Name $preference -Value ([System.Management.Automation.ActionPreference]::SilentlyContinue) -Confirm:$false -WhatIf:$false } } $writeError = { param([System.Management.Automation.ErrorRecord]$Record) if ($null -ne $Cmdlet) { $Cmdlet.WriteError($Record) } else { Write-Error -ErrorRecord $Record } } $operationId = [string](Get-OpenApiMember -InputObject $Operation -Name 'OperationId') $method = ([string](Get-OpenApiMember -InputObject $Operation -Name 'Method')).ToUpperInvariant() if ([string]::IsNullOrEmpty($method)) { $method = 'GET' } $path = [string](Get-OpenApiMember -InputObject $Operation -Name 'Path') if ([string]::IsNullOrEmpty($Service)) { $Service = [string](Get-OpenApiMember -InputObject $Operation -Name 'Service') } if ([string]::IsNullOrEmpty($Service)) { $exception = New-Object System.ArgumentException -ArgumentList 'No service name was given: pass -Service or an operation with a Service property.' & $writeError (New-Object System.Management.Automation.ErrorRecord -ArgumentList $exception, 'OpenApi.MissingService', ([System.Management.Automation.ErrorCategory]::InvalidArgument), $operationId) return } $loadProblem = '' try { $context = Resolve-OpenApiContext -Service $Service } catch { $context = $null $loadProblem = " The saved context could not be loaded: $($_.Exception.Message)" } if ($null -eq $context) { $exception = New-Object System.InvalidOperationException -ArgumentList "There is no connection for the '$Service' service. Run Set-OpenApiContext -Service '$Service' -BaseUri <url> first.$loadProblem" & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $method -Uri $path -Exception $exception -Kind 'NoContext' -Category ([System.Management.Automation.ErrorCategory]::ConnectionError)) return } if ([bool](Get-OpenApiMember -InputObject $Operation -Name 'Deprecated') -and -not [string]::IsNullOrEmpty($operationId)) { Write-OpenApiDeprecationWarning -Service $Service -OperationId $operationId -Cmdlet $Cmdlet } # Get-OpenApiMember returns a collection as one object; piping the variable enumerates it $parameterSpecs = Get-OpenApiMember -InputObject $Operation -Name 'Parameters' $parameterSpecs = @($parameterSpecs | Where-Object -FilterScript { $null -ne $_ }) try { $uri = New-OpenApiRequestUri -BaseUri $context.BaseUri -Path $path -Parameter $parameterSpecs -PathParameters $PathParameters -QueryParameters $QueryParameters } catch { & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $method -Uri $path -Exception $_.Exception -Kind 'InvalidArgument' -Category ([System.Management.Automation.ErrorCategory]::InvalidArgument)) return } # Headers: context defaults, then Accept, then header parameters $headers = [ordered]@{} $headers['User-Agent'] = 'tcs.openapi/{0} PowerShell/{1}' -f $MyInvocation.MyCommand.Module.Version, $PSVersionTable.PSVersion $responseTypes = Get-OpenApiMember -InputObject $Operation -Name 'ResponseContentTypes' $responseTypes = @($responseTypes | Where-Object -FilterScript { -not [string]::IsNullOrEmpty($_) }) if ($responseTypes.Count -gt 0) { $headers['Accept'] = $responseTypes -join ', ' } else { $headers['Accept'] = 'application/json, */*;q=0.8' } foreach ($key in $context.Header.Keys) { $headers[$key] = $context.Header[$key] } if ($null -ne $HeaderParameters) { foreach ($key in $HeaderParameters.Keys) { $style = Get-OpenApiParameterStyle -Parameter $parameterSpecs -Name ([string]$key) -In 'header' $headers[[string]$key] = ConvertTo-OpenApiHeaderParameter -Value $HeaderParameters[$key] -Explode:$style.Explode } } $cookies = New-Object System.Collections.Generic.List[string] if ($null -ne $CookieParameters) { foreach ($key in $CookieParameters.Keys) { $style = Get-OpenApiParameterStyle -Parameter $parameterSpecs -Name ([string]$key) -In 'cookie' foreach ($pair in (ConvertTo-OpenApiCookieParameter -Name ([string]$key) -Value $CookieParameters[$key] -Explode:$style.Explode)) { $cookies.Add($pair) } } } # Body $contentFactory = $null if ($bodyGiven) { $bodyContentType = $ContentType if ([string]::IsNullOrEmpty($bodyContentType)) { $requestTypes = Get-OpenApiMember -InputObject $Operation -Name 'RequestContentTypes' $bodyContentType = @($requestTypes | Where-Object -FilterScript { -not [string]::IsNullOrEmpty($_) }) | Select-Object -First 1 } if ([string]::IsNullOrEmpty($bodyContentType)) { $bodyContentType = 'application/json' } if ($Body -is [System.IO.Stream]) { # Read the stream once: every attempt then sends the same bytes, and the caller's stream is never # handed to HttpClient (on .NET Framework HttpClient disposes the request content, and so the stream) $startPosition = $null if ($Body.CanSeek) { $startPosition = $Body.Position $Body.Position = 0 } $buffer = New-Object System.IO.MemoryStream $Body.CopyTo($buffer) if ($null -ne $startPosition) { $Body.Position = $startPosition } $Body = $buffer.ToArray() $buffer.Dispose() } # A new HttpContent is built for every attempt (a sent request cannot be sent again) $contentFactory = { New-OpenApiHttpContent -Body $Body -ContentType $bodyContentType } } $paging = Get-OpenApiMember -InputObject $Operation -Name 'Paging' $pagingKind = [string](Get-OpenApiMember -InputObject $paging -Name 'Kind') $itemsProperty = [string](Get-OpenApiMember -InputObject $paging -Name 'ItemsProperty') $nextLinkProperty = [string](Get-OpenApiMember -InputObject $paging -Name 'NextLinkProperty') $tokenParameter = [string](Get-OpenApiMember -InputObject $paging -Name 'TokenParameter') $tokenProperty = [string](Get-OpenApiMember -InputObject $paging -Name 'TokenProperty') $tokenPaging = ($pagingKind -eq 'token' -and -not [string]::IsNullOrEmpty($tokenParameter)) if ($tokenPaging) { $nextLinkProperty = '' if ([string]::IsNullOrEmpty($tokenProperty)) { $tokenProperty = $tokenParameter } } elseif ($null -ne $paging -and $pagingKind -ne 'linkHeader') { if ([string]::IsNullOrEmpty($itemsProperty)) { $itemsProperty = 'value' } if ([string]::IsNullOrEmpty($nextLinkProperty)) { $nextLinkProperty = 'nextLink' } } # A pageable operation outputs the items of its pages; UnwrapProperty applies to the other operations $unwrapProperty = '' if ($null -eq $paging) { $unwrapProperty = [string](Get-OpenApiMember -InputObject $Operation -Name 'UnwrapProperty') } $typeName = [string](Get-OpenApiMember -InputObject $Operation -Name 'ResponseTypeName') $binaryResponse = [bool](Get-OpenApiMember -InputObject $Operation -Name 'BinaryResponse') $sensitive = @($context.Header.Keys | Where-Object -FilterScript { Test-OpenApiSensitiveName -Name $_ }) if (-not [string]::IsNullOrEmpty($OutFile)) { $OutFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutFile) } try { $client = Get-OpenApiHttpClient -Context $context } catch { & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $method -Uri $uri -Exception $_.Exception -Kind 'Connection' -Category ([System.Management.Automation.ErrorCategory]::ConnectionError)) return } $originUri = $uri $visited = @{} $visitedTokens = @{} if ($tokenPaging -and $null -ne $QueryParameters) { foreach ($key in $QueryParameters.Keys) { if ([string]$key -eq $tokenParameter -and -not [string]::IsNullOrEmpty([string]$QueryParameters[$key])) { $visitedTokens[[string]$QueryParameters[$key]] = $true } } } $pageMethod = $method $pageFactory = $contentFactory while ($null -ne $uri) { $visited[$uri] = $true $parsed = $null $parsedOk = $false $content = $null try { $exchange = @{ Context = $context Operation = $Operation Client = $client Method = $pageMethod Uri = $uri Header = $headers Cookie = $cookies.ToArray() ContentFactory = $pageFactory SensitiveName = $sensitive } $response = Invoke-OpenApiHttpExchange @exchange } catch { $failure = $_.Exception $kind = 'Connection' $category = [System.Management.Automation.ErrorCategory]::ConnectionError if ($failure -is [System.Security.Authentication.AuthenticationException]) { $kind = 'Authentication' $category = [System.Management.Automation.ErrorCategory]::AuthenticationError } elseif ($failure -is [System.ArgumentException] -or $failure -is [System.IO.FileNotFoundException] -or $failure -is [System.FormatException]) { $kind = 'InvalidArgument' $category = [System.Management.Automation.ErrorCategory]::InvalidArgument } elseif ($failure -is [System.TimeoutException]) { $category = [System.Management.Automation.ErrorCategory]::OperationTimeout } & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $pageMethod -Uri $uri -Exception $failure -Kind $kind -Category $category) return } if ($response.StatusCode -lt 200 -or $response.StatusCode -gt 299) { & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $pageMethod -Uri $uri -Response $response -SensitiveName $sensitive) return } $mediaKind = Get-OpenApiMediaTypeKind -ContentType $response.ContentType if (-not [string]::IsNullOrEmpty($OutFile)) { try { Save-OpenApiResponseFile -Response $response -Path $OutFile } catch { & $writeError (New-OpenApiErrorRecord -Service $Service -OperationId $operationId -Method $pageMethod -Uri $uri -Exception $_.Exception -Kind 'OutFile' -Category ([System.Management.Automation.ErrorCategory]::WriteError)) } return } $bytes = Read-OpenApiResponseBody -Response $response $isText = ($mediaKind -eq 'Json' -or $mediaKind -eq 'Text' -or $mediaKind -eq 'Form') if ($Raw) { $content = $bytes if ($isText -or [string]::IsNullOrEmpty($response.ContentType)) { $content = ConvertTo-OpenApiResponseText -Bytes $bytes -ContentType $response.ContentType } [pscustomobject]@{ PSTypeName = 'Tcs.OpenApi.RawResponse' StatusCode = $response.StatusCode Headers = $response.Headers Content = $content } } elseif ($response.StatusCode -eq 204 -or $bytes.Length -eq 0) { Write-Verbose -Message 'The response has no content.' } elseif ($binaryResponse -and $mediaKind -ne 'Json') { , $bytes } elseif ($mediaKind -eq 'Json' -or ([string]::IsNullOrEmpty($response.ContentType) -and -not $binaryResponse)) { $text = ConvertTo-OpenApiResponseText -Bytes $bytes -ContentType $response.ContentType if ($DebugPreference -ne 'SilentlyContinue') { Write-Debug -Message ('Response body: ' + (Get-OpenApiRedactedText -Text $text)) } $parsed = $null $parsedOk = $true try { $parsed = ConvertFrom-OpenApiResponseJson -Text $text } catch { $parsedOk = $false Write-Verbose -Message "The response is not valid JSON and is returned as text: $($_.Exception.Message)" $text } if ($parsedOk) { Write-OpenApiResponseOutput -InputObject $parsed -ItemsProperty $itemsProperty -TypeName $typeName -UnwrapProperty $unwrapProperty } } elseif ($mediaKind -eq 'Text' -or $mediaKind -eq 'Form') { ConvertTo-OpenApiResponseText -Bytes $bytes -ContentType $response.ContentType } else { , $bytes } # Next page $next = $null $link = $null if ($tokenPaging) { $token = $null $page = $null if ($parsedOk -and $null -ne $parsed -and -not $Raw) { $page = $parsed } elseif ($Raw -and $isText) { try { $page = ConvertFrom-OpenApiResponseJson -Text $content } catch { $page = $null } } if ($null -ne $page -and $page -isnot [array]) { $token = Get-OpenApiMember -InputObject $page -Name $tokenProperty } if ($null -ne $token -and -not [string]::IsNullOrEmpty([string]$token)) { $token = [string]$token if (-not $All) { Write-Verbose -Message 'More results are available; use -All to get every page.' } elseif ($visitedTokens.ContainsKey($token)) { Write-Warning -Message "Paging stopped: the page token '$token' was already used." } else { $visitedTokens[$token] = $true # The caller's query parameters with the token set; same method and body as the first request $pageQuery = [ordered]@{} if ($null -ne $QueryParameters) { foreach ($key in $QueryParameters.Keys) { if ([string]$key -ne $tokenParameter) { $pageQuery[[string]$key] = $QueryParameters[$key] } } } $pageQuery[$tokenParameter] = $token $next = New-OpenApiRequestUri -BaseUri $context.BaseUri -Path $path -Parameter $parameterSpecs -PathParameters $PathParameters -QueryParameters $pageQuery } } $uri = $next continue } if ($pagingKind -ne 'linkHeader' -and -not [string]::IsNullOrEmpty($nextLinkProperty) -and $parsedOk -and $null -ne $parsed -and -not $Raw) { $link = [string](Get-OpenApiMember -InputObject $parsed -Name $nextLinkProperty) } elseif ($pagingKind -ne 'linkHeader' -and -not [string]::IsNullOrEmpty($nextLinkProperty) -and $Raw -and $isText) { try { $link = [string](Get-OpenApiMember -InputObject (ConvertFrom-OpenApiResponseJson -Text $content) -Name $nextLinkProperty) } catch { $link = $null } } if ([string]::IsNullOrEmpty($link)) { $link = Get-OpenApiLinkHeaderNext -LinkHeader $response.Headers['Link'] } if (-not [string]::IsNullOrEmpty($link)) { if ($All) { $next = Resolve-OpenApiNextPageUri -Link $link -CurrentUri $uri -OriginUri $originUri if ($null -ne $next -and $visited.ContainsKey($next)) { Write-Warning -Message "Paging stopped: the next-page link '$next' was already requested." $next = $null } } else { Write-Verbose -Message 'More results are available; use -All to get every page.' } } $uri = $next # Next pages are fetched with GET and no body $pageMethod = 'GET' $pageFactory = $null } } |