Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/new-api-request.yml
Original file line number Diff line number Diff line change
@@ -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
178 changes: 90 additions & 88 deletions New-ApiRequest.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}

return Invoke-RestMethod @request
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions Tests/New-ApiRequest.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
33 changes: 33 additions & 0 deletions docs/New-ApiRequest.md
Original file line number Diff line number Diff line change
@@ -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.
Loading