diff --git a/.github/workflows/new-api-request.yml b/.github/workflows/new-api-request.yml new file mode 100644 index 0000000..e0d445c --- /dev/null +++ b/.github/workflows/new-api-request.yml @@ -0,0 +1,38 @@ +name: New-ApiRequest + +on: + push: + branches: [master] + paths: ['New-ApiRequest.ps1', 'Tests/New-ApiRequest.Tests.ps1', '.github/workflows/new-api-request.yml'] + pull_request: + paths: ['New-ApiRequest.ps1', 'Tests/New-ApiRequest.Tests.ps1', '.github/workflows/new-api-request.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = './Tests/New-ApiRequest.Tests.ps1' + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/New-ApiRequest.ps1 b/New-ApiRequest.ps1 index e3a907b..234e7dd 100644 --- a/New-ApiRequest.ps1 +++ b/New-ApiRequest.ps1 @@ -1,121 +1,123 @@ +#Requires -Version 7.4 + function New-ApiRequest { -<# + <# .SYNOPSIS - Function will query data from an URL API. + Sends an OAuth-style client request and returns the endpoint response. .DESCRIPTION - Function is intended as a wrapper around PowerShell built-in Invoke-RestMethod cmdlet allowing user to quickly generate web requests to APIs requiring OAuth2 authentication. + Builds client_id and grant_type fields, plus optional client_secret. Defaults + to POST with application/x-www-form-urlencoded. JSON is also supported. This + helper does not acquire a token and then call another API, or manage token refresh. + GET is allowed only without ApiSecret; Invoke-RestMethod turns its dictionary + body into query parameters. Requests containing ApiSecret require HTTPS. + Automatic redirects are disabled. HTTP failures terminate; no retry is performed. - .PARAMETER ApiKey - A string representing the API key to be used. + .PARAMETER Headers + Dictionary of HTTP headers. Specify Content-Type through ContentType instead. - .PARAMETER ApiSecret - A string representing the API key secret. + .EXAMPLE + New-ApiRequest -ApiKey 'client-id' -ApiSecret $secret -ApiUrl 'https://example.invalid/oauth/token' + #> - .PARAMETER ApiUrl - A string representing the API endpoint URL + [CmdletBinding()] + [OutputType([object])] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ApiKey, - .PARAMETER GrantType - A string representing the Grant Type supported by the API. + [ValidateNotNullOrEmpty()] + [string]$ApiSecret, - If not specified it will default to client_credentials. + [Parameter(Mandatory)] + [ValidateScript({ $_.IsAbsoluteUri -and $_.Scheme -in @('http', 'https') -and [string]::IsNullOrEmpty($_.UserInfo) })] + [uri]$ApiUrl, - .PARAMETER ContentType - A string representing the content type to send as part of the request in case request needs to be crafted with a special ContentType. + [ValidateNotNullOrEmpty()] + [string]$GrantType = 'client_credentials', - .PARAMETER Method - A string representing the method to be used in the web request. + [ValidateSet('application/x-www-form-urlencoded', 'application/json')] + [string]$ContentType = 'application/x-www-form-urlencoded', - If not specified it will default to GET. + [ValidateSet('GET', 'POST')] + [string]$Method = 'POST', - .PARAMETER Headers - A string representing custom headers to send as part of the webrequest. + [ValidateNotNull()] + [System.Collections.IDictionary]$Headers, - .EXAMPLE - PS C:\> New-ApiRequest -ApiKey 'Value1' -ApiUrl 'Value2' -ApiSecret 'MySecret' + [Alias('TimeoutSec')] + [ValidateRange(1, 300)] + [int]$ConnectionTimeoutSeconds = 30 + ) - .NOTES - Additional information about the function. -#> + $methodName = $Method.ToUpperInvariant() + $hasSecret = $PSBoundParameters.ContainsKey('ApiSecret') - [CmdletBinding()] - [OutputType([pscustomobject])] - param - ( - [Parameter(Mandatory = $true)] - [string] - $ApiKey, - [string] - $ApiSecret, - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] - $ApiUrl, - [ValidateNotNullOrEmpty()] - [string] - $GrantType = 'client_credentials', - [ValidateNotNullOrEmpty()] - [string] - $ContentType, - [ValidateNotNullOrEmpty()] - [ValidateSet('GET', 'POST', IgnoreCase = $true)] - [string] - $Method = 'GET', - [ValidateNotNullOrEmpty()] - [string] - $Headers - ) + if ($hasSecret -and $methodName -eq 'GET') + { + throw [ArgumentException]::new('ApiSecret cannot be sent with GET because it would enter the URL query string. Use POST.') + } - Process + if ($hasSecret -and $ApiUrl.Scheme -ne 'https') { - # Generate request post data - [hashtable]$requestBody = @{ - 'client_id' = $ApiKey; - 'grant_type' = $GrantType - } + throw [ArgumentException]::new('Requests containing ApiSecret require HTTPS.') + } - switch ($PSBoundParameters.Keys) + if ($PSBoundParameters.ContainsKey('Headers')) + { + foreach ($name in $Headers.Keys) { - 'ContentType' + if ([string]$name -ieq 'Content-Type') { - $requestBody.Add('ContentType', $ContentType) - - break + throw [ArgumentException]::new('Specify Content-Type with the ContentType parameter, not Headers.') } - 'ApiSecret' - { - $requestBody.Add('client_secret', $ApiSecret) - break + if ([string]$name -ieq 'Authorization' -and $ApiUrl.Scheme -ne 'https') + { + throw [ArgumentException]::new('Requests containing Authorization headers require HTTPS.') } } + } - # Define splat command - $paramInvokeWebRequest = @{ - Uri = $ApiUrl - Body = $requestBody - } + [hashtable]$requestBody = @{ + client_id = $ApiKey + grant_type = $GrantType + } - # Get passed parameters - switch ($PSBoundParameters.Keys) - { - 'Headers' - { - # Add custom header - $paramInvokeWebRequest.Add('Headers', $Headers) + if ($hasSecret) + { + $requestBody.client_secret = $ApiSecret + } - break - } - 'Method' - { - # Use custom method - $paramInvokeWebRequest.Add('Method', $Method) + [hashtable]$request = @{ + Uri = $ApiUrl + Method = $methodName + Body = $requestBody + MaximumRedirection = 0 + ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds + ErrorAction = 'Stop' + } - break - } + if ($methodName -eq 'POST') + { + $request.ContentType = $ContentType + + if ($ContentType -ieq 'application/json') + { + $request.Body = ConvertTo-Json -InputObject $requestBody -Compress } + } + elseif ($PSBoundParameters.ContainsKey('ContentType')) + { + throw [ArgumentException]::new('ContentType applies to POST requests. GET sends query parameters.') + } - return Invoke-RestMethod @paramInvokeWebRequest + if ($PSBoundParameters.ContainsKey('Headers')) + { + $request.Headers = $Headers } -} \ No newline at end of file + + return Invoke-RestMethod @request +} diff --git a/README.md b/README.md index 4366491..ad4af23 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ A PowerShell 7+ logging implementation for production automation with: See [`New-LogEntry/`](./New-LogEntry/) for documentation, source, tests, and change history. +### New-ApiRequest + +The standalone OAuth-style request helper now follows the IT-ToolBox implementation. It requires PowerShell 7.4+, defaults to a form POST, supports dictionary headers and prevents client secrets from entering GET URLs. See [usage and migration notes](./docs/New-ApiRequest.md) and [tests](./Tests/New-ApiRequest.Tests.ps1). No module installation is required. + ## Standalone functions The repository also contains standalone utilities including: diff --git a/Tests/New-ApiRequest.Tests.ps1 b/Tests/New-ApiRequest.Tests.ps1 new file mode 100644 index 0000000..03b2a59 --- /dev/null +++ b/Tests/New-ApiRequest.Tests.ps1 @@ -0,0 +1,89 @@ +BeforeAll { + $sourcePath = (Resolve-Path (Join-Path $PSScriptRoot '../New-ApiRequest.ps1')).Path + New-Module -Name StandaloneApiRequest -ArgumentList $sourcePath -ScriptBlock { + param($SourcePath) + . $SourcePath + Export-ModuleMember -Function New-ApiRequest + } | Import-Module -Force -ErrorAction Stop +} +AfterAll { Remove-Module StandaloneApiRequest -ErrorAction SilentlyContinue } + +Describe 'OAuth-style request construction' { + BeforeEach { + Mock Invoke-RestMethod { [pscustomobject]@{ access_token = 'mock-token'; token_type = 'Bearer' } } -ModuleName StandaloneApiRequest + } + It 'defaults to a form POST and returns the endpoint response' { + $response = New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' + $response.access_token | Should -BeExactly 'mock-token' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $ContentType -eq 'application/x-www-form-urlencoded' -and + $Body.client_id -eq 'client-id' -and $Body.grant_type -eq 'client_credentials' -and + -not $Body.ContainsKey('client_secret') + } + } + It 'processes secret, headers, method and content type together regardless of argument order' { + $headers = @{ 'X-Request-ID' = 'test-id' } + New-ApiRequest -ContentType 'application/json' -Method POST -Headers $headers -ApiSecret 'test-secret' -ApiUrl 'https://example.invalid/token' -ApiKey 'client-id' | Out-Null + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { + $json = ConvertFrom-Json $Body + $Method -eq 'POST' -and $ContentType -eq 'application/json' -and + $Headers['X-Request-ID'] -eq 'test-id' -and $json.client_secret -eq 'test-secret' -and + $json.client_id -eq 'client-id' -and $json.grant_type -eq 'client_credentials' + } + $headers.Count | Should -Be 1 + $headers['X-Request-ID'] | Should -BeExactly 'test-id' + } + It 'keeps ContentType outside the authentication body' { + New-ApiRequest -ApiKey 'client-id' -ApiSecret 'test-secret' -ApiUrl 'https://example.invalid/token' | Out-Null + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { + $Body -is [System.Collections.IDictionary] -and $Body.Count -eq 3 -and + -not $Body.ContainsKey('ContentType') -and $Body.client_secret -eq 'test-secret' + } + } + It 'accepts a custom grant type' { + New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -GrantType 'custom_grant' | Out-Null + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { $Body.grant_type -eq 'custom_grant' } + } + It 'allows a secret-free GET dictionary for query encoding' { + New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -Method GET | Out-Null + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'GET' -and $Body -is [System.Collections.IDictionary] -and $Body.client_id -eq 'client-id' + } + } + It 'rejects GET secrets before issuing any request' { + { New-ApiRequest -ApiKey 'client-id' -ApiSecret 'test-secret' -ApiUrl 'https://example.invalid/token' -Method GET } | Should -Throw '*cannot be sent with GET*' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 0 -Exactly + } + It 'rejects a secret sent over plain HTTP' { + { New-ApiRequest -ApiKey 'client-id' -ApiSecret 'test-secret' -ApiUrl 'http://example.invalid/token' } | Should -Throw '*require HTTPS*' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 0 -Exactly + } + It 'rejects an Authorization header sent over plain HTTP' { + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'http://example.invalid/token' -Headers @{ Authorization = 'Bearer test-token' } } | Should -Throw '*require HTTPS*' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 0 -Exactly + } + It 'rejects conflicting header content type and GET content type' { + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -Headers @{ 'content-type' = 'application/json' } } | Should -Throw '*ContentType parameter*' + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -Method GET -ContentType 'application/json' } | Should -Throw '*applies to POST*' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 0 -Exactly + } + It 'disables redirects and forwards the connection timeout' { + New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -ConnectionTimeoutSeconds 15 | Out-Null + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly -ParameterFilter { $MaximumRedirection -eq 0 -and $ConnectionTimeoutSeconds -eq 15 } + } + It 'preserves terminating endpoint errors without retrying' { + Mock Invoke-RestMethod { throw 'Mock endpoint failure' } -ModuleName StandaloneApiRequest + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' } | Should -Throw '*Mock endpoint failure*' + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 1 -Exactly + } + It 'validates URLs, dictionaries, methods, formats and timeout ranges' { + foreach ($url in @('relative/path', 'ftp://example.invalid/token', 'https://user:password@example.invalid/token')) { + { New-ApiRequest -ApiKey 'client-id' -ApiUrl $url } | Should -Throw + } + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -Headers 'not-a-dictionary' } | Should -Throw + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -Method PATCH } | Should -Throw + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -ContentType 'text/xml' } | Should -Throw + { New-ApiRequest -ApiKey 'client-id' -ApiUrl 'https://example.invalid/token' -TimeoutSec 0 } | Should -Throw + Should -Invoke Invoke-RestMethod -ModuleName StandaloneApiRequest -Times 0 -Exactly + } +} diff --git a/docs/New-ApiRequest.md b/docs/New-ApiRequest.md new file mode 100644 index 0000000..4aa4bf2 --- /dev/null +++ b/docs/New-ApiRequest.md @@ -0,0 +1,33 @@ +# New-ApiRequest + +Requires PowerShell 7.4 or later. Dot-source `New-ApiRequest.ps1`; IT-ToolBox does not need to be installed. + +This standalone function adopts IT-ToolBox’s Public/New-ApiRequest.ps1 from master a73f0af. Its implementation body is copied unchanged; the standalone file adds an explicit runtime requirement. The tests adapt Tests/ApiRequest.Tests.ps1 to an isolated standalone test module. + +## Usage + +```powershell +. ./New-ApiRequest.ps1 +# Supply the actual secret through your application's secret handling. +New-ApiRequest -ApiKey 'client-id' -ApiSecret $secret -ApiUrl 'https://example.invalid/token' +``` + +This submits an OAuth-style client request and returns the endpoint response. It does not acquire a token and then call a second API, refresh tokens or retry failures. + +## Changes from the historical standalone version + +- POST with application/x-www-form-urlencoded is now the default, rather than GET. +- Headers must be an IDictionary, for example a hashtable. Content-Type belongs in ContentType, not Headers. +- ContentType accepts application/x-www-form-urlencoded or application/json for POST. JSON bodies are serialized explicitly. +- Optional parameters are processed independently; method, headers, content type and client secret can be passed together. +- GET is allowed only without ApiSecret, and cannot specify ContentType. +- ApiSecret and Authorization headers require HTTPS. Other custom headers must also be handled according to their sensitivity: these guards are not a general-purpose secret detector. +- URLs must be absolute HTTP(S) addresses without embedded user information. +- Automatic redirects are disabled. Operational HTTP failures terminate and are not retried. +- ConnectionTimeoutSeconds defaults to 30, accepts 1–300, and has TimeoutSec as an alias. This is a connection timeout, not an overall request deadline. + +Update callers that depend on the old GET default, string headers, unrestricted content types or automatic redirects. Keep any future functional correction aligned with the module implementation and adapt its existing regression tests. + +## Tests + +Run `Invoke-Pester ./Tests/New-ApiRequest.Tests.ps1 -Output Detailed` with Pester 5.7.1. Tests mock all requests; they do not contact an identity provider. CI runs the same suite on Windows, Linux and macOS using each runner’s installed PowerShell, not every PowerShell release.