From ed14166accf7ecf83dbb3844fcf600b6fcf5b008 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Tue, 29 Sep 2026 14:13:12 +0100 Subject: [PATCH 1/5] add cleaner way of specifying api version --- src/Enums/VersionMode.php | 11 ++ src/Traits/HasApiVersion.php | 44 ++++++ tests/Unit/Concerns/HasApiVersionTest.php | 176 ++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/Enums/VersionMode.php create mode 100644 src/Traits/HasApiVersion.php create mode 100644 tests/Unit/Concerns/HasApiVersionTest.php diff --git a/src/Enums/VersionMode.php b/src/Enums/VersionMode.php new file mode 100644 index 00000000..8d38c6aa --- /dev/null +++ b/src/Enums/VersionMode.php @@ -0,0 +1,11 @@ +apiVersion = $version; + + return $this; + } + + public function getApiVersion(): ?string + { + return $this->apiVersion; + } + + public function bootHasApiVersion(PendingRequest $pendingRequest): void + { + if (!$this->apiVersion) { + return; + } + + match ($this->versionMode) { + VersionMode::Header => $pendingRequest->headers()->add($this->versionKey, $this->apiVersion), + + VersionMode::QueryParam => $pendingRequest->query()->add($this->versionKey, $this->apiVersion), + + VersionMode::Subdomain, + VersionMode::UrlPath => $pendingRequest->setUrl( + str_replace('{version}', $this->apiVersion, $pendingRequest->getUrl()) + ) + }; + } +} \ No newline at end of file diff --git a/tests/Unit/Concerns/HasApiVersionTest.php b/tests/Unit/Concerns/HasApiVersionTest.php new file mode 100644 index 00000000..1deb97fe --- /dev/null +++ b/tests/Unit/Concerns/HasApiVersionTest.php @@ -0,0 +1,176 @@ +assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('anthropic-version'); + + return true; + }); +}); + +test('query param mode appends the version to the query string', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com', VersionMode::QueryParam, '2026-10'), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getUri()->getQuery())->toBe('api-version=2026-10'); + + return true; + }); +}); + +test('url path mode replaces the version placeholder in the base url', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::UrlPath, 'v1beta'), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPendingRequest()->getUrl())->toBe('https://generativelanguage.googleapis.com/v1beta'); + expect((string) $response->getPsrRequest()->getUri())->toBe('https://generativelanguage.googleapis.com/v1beta'); + + return true; + }); +}); + +test('subdomain mode replaces the version placeholder in the host', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Subdomain, 'v2'), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPendingRequest()->getUrl())->toBe('https://v2.api.provider.com'); + expect((string) $response->getPsrRequest()->getUri())->toBe('https://v2.api.provider.com'); + + return true; + }); +}); + +test('the connector can override the api version at runtime', function () { + $connector = makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'); + $connector->setApiVersion('override-version'); + + $mockClient = sendVersionedRequest($connector, makeVersionedRequest()); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); + + return true; + }); +}); + +test('the request can override the api version at runtime', function () { + $request = makeVersionedRequest('override-version', VersionMode::Header); + + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com'), + $request, + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); + + return true; + }); +}); + +test('a null api version does not add a header, query parameter, or replace the url', function (VersionMode $versionMode) { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com/{version}', $versionMode), + makeVersionedRequest(null, $versionMode), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe(''); + expect($response->getPsrRequest()->getUri()->getQuery())->not->toContain('api-version'); + expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/{version}'); + + return true; + }); +})->with([ + 'header' => VersionMode::Header, + 'query param' => VersionMode::QueryParam, + 'subdomain' => VersionMode::Subdomain, + 'url path' => VersionMode::UrlPath, +]); + +function makeVersionedConnector(string $baseUrl, VersionMode $versionMode = VersionMode::Header, ?string $apiVersion = null): Connector +{ + return new class($baseUrl, $versionMode, $apiVersion) extends Connector { + use HasApiVersion; + + public function __construct( + private readonly string $baseUrl, + VersionMode $versionMode, + ?string $apiVersion, + ) { + $this->versionMode = $versionMode; + + if (is_string($apiVersion)) { + $this->setApiVersion($apiVersion); + } + } + + public function resolveBaseUrl(): string + { + return $this->baseUrl; + } + }; +} + +function makeVersionedRequest(?string $apiVersion = null, VersionMode $versionMode = VersionMode::Header): Request +{ + return new class($versionMode, $apiVersion) extends Request { + use HasApiVersion; + + protected Method $method = Method::GET; + + public function __construct( + VersionMode $versionMode, + ?string $apiVersion, + ) { + $this->versionMode = $versionMode; + + if (is_string($apiVersion)) { + $this->setApiVersion($apiVersion); + } + } + + public function resolveEndpoint(): string + { + return ''; + } + }; +} + +function sendVersionedRequest(Connector $connector, Request $request): MockClient +{ + $mockClient = new MockClient([ + MockResponse::make(), + ]); + + $connector->send($request, $mockClient); + + return $mockClient; +} From 593d2c9262cb299f0f9b1eb3b7406267b922bc6d Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Tue, 29 Sep 2026 14:18:54 +0100 Subject: [PATCH 2/5] add cleaner way of specifying api version --- src/Enums/VersionMode.php | 6 ++++-- src/Traits/{ => Plugins}/HasApiVersion.php | 18 ++++++++++++------ tests/Unit/Concerns/HasApiVersionTest.php | 7 ++----- 3 files changed, 18 insertions(+), 13 deletions(-) rename src/Traits/{ => Plugins}/HasApiVersion.php (84%) diff --git a/src/Enums/VersionMode.php b/src/Enums/VersionMode.php index 8d38c6aa..ee53822a 100644 --- a/src/Enums/VersionMode.php +++ b/src/Enums/VersionMode.php @@ -1,6 +1,8 @@ apiVersion) { + if (! $this->apiVersion) { return; } @@ -38,7 +44,7 @@ public function bootHasApiVersion(PendingRequest $pendingRequest): void VersionMode::Subdomain, VersionMode::UrlPath => $pendingRequest->setUrl( str_replace('{version}', $this->apiVersion, $pendingRequest->getUrl()) - ) + ), }; } -} \ No newline at end of file +} diff --git a/tests/Unit/Concerns/HasApiVersionTest.php b/tests/Unit/Concerns/HasApiVersionTest.php index 1deb97fe..5c44461c 100644 --- a/tests/Unit/Concerns/HasApiVersionTest.php +++ b/tests/Unit/Concerns/HasApiVersionTest.php @@ -2,16 +2,13 @@ declare(strict_types=1); -require_once __DIR__ . '/../../../src/Enums/VersionMode.php'; -require_once __DIR__ . '/../../../src/Traits/HasApiVersion.php'; - -use App\Enums\VersionMode; -use App\Traits\HasApiVersion; use Saloon\Enums\Method; +use Saloon\Enums\VersionMode; use Saloon\Http\Connector; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use Saloon\Http\Request; +use Saloon\Traits\Plugins\HasApiVersion; test('header mode adds the version to the outgoing request headers', function () { $mockClient = sendVersionedRequest( From 7f9f1891c18ffcf96c403d2b78455ab495aadf2f Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Tue, 29 Sep 2026 15:14:02 +0100 Subject: [PATCH 3/5] run cs fixer --- tests/Unit/Concerns/HasApiVersionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Concerns/HasApiVersionTest.php b/tests/Unit/Concerns/HasApiVersionTest.php index 5c44461c..09cb7bf4 100644 --- a/tests/Unit/Concerns/HasApiVersionTest.php +++ b/tests/Unit/Concerns/HasApiVersionTest.php @@ -3,11 +3,11 @@ declare(strict_types=1); use Saloon\Enums\Method; -use Saloon\Enums\VersionMode; +use Saloon\Http\Request; use Saloon\Http\Connector; +use Saloon\Enums\VersionMode; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; -use Saloon\Http\Request; use Saloon\Traits\Plugins\HasApiVersion; test('header mode adds the version to the outgoing request headers', function () { From 77c2e15daa0fc579479ca6e4ce45904b0f44af36 Mon Sep 17 00:00:00 2001 From: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:59:13 +0100 Subject: [PATCH 4/5] Combined version modes and added safety checks --- src/Enums/VersionMode.php | 3 +- src/Traits/Plugins/HasApiVersion.php | 42 +++++++++++++++++--- tests/Unit/Concerns/HasApiVersionTest.php | 47 +++++++++++++++++++++-- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/Enums/VersionMode.php b/src/Enums/VersionMode.php index ee53822a..6c61dae4 100644 --- a/src/Enums/VersionMode.php +++ b/src/Enums/VersionMode.php @@ -8,6 +8,5 @@ enum VersionMode { case Header; // e.g., api-version: 2026-10 case QueryParam; // e.g., ?api-version=2026-10 - case Subdomain; // e.g., v2 - case UrlPath; // e.g., /v2 + case Url; // e.g., https://{version}.api.com or https://api.com/{version} } diff --git a/src/Traits/Plugins/HasApiVersion.php b/src/Traits/Plugins/HasApiVersion.php index a1760a9c..5be6e8e7 100644 --- a/src/Traits/Plugins/HasApiVersion.php +++ b/src/Traits/Plugins/HasApiVersion.php @@ -4,6 +4,7 @@ namespace Saloon\Traits\Plugins; +use InvalidArgumentException; use Saloon\Enums\VersionMode; use Saloon\Http\PendingRequest; @@ -12,12 +13,26 @@ */ trait HasApiVersion { + /** + * API Version + */ protected ?string $apiVersion = null; + /** + * Where the API version should be applied to the request + */ protected VersionMode $versionMode = VersionMode::Header; + /** + * The header or query parameter name used for the API version + */ protected string $versionKey = 'api-version'; + /** + * Set the API version + * + * @return $this + */ public function setApiVersion(string $version): static { $this->apiVersion = $version; @@ -25,11 +40,17 @@ public function setApiVersion(string $version): static return $this; } + /** + * Get the API version + */ public function getApiVersion(): ?string { return $this->apiVersion; } + /** + * Boot HasApiVersion plugin. + */ public function bootHasApiVersion(PendingRequest $pendingRequest): void { if (! $this->apiVersion) { @@ -38,13 +59,24 @@ public function bootHasApiVersion(PendingRequest $pendingRequest): void match ($this->versionMode) { VersionMode::Header => $pendingRequest->headers()->add($this->versionKey, $this->apiVersion), - VersionMode::QueryParam => $pendingRequest->query()->add($this->versionKey, $this->apiVersion), - - VersionMode::Subdomain, - VersionMode::UrlPath => $pendingRequest->setUrl( - str_replace('{version}', $this->apiVersion, $pendingRequest->getUrl()) + VersionMode::Url => $pendingRequest->setUrl( + str_replace('{version}', $this->ensureVersionIsSafeForUrl($this->apiVersion), $pendingRequest->getUrl()) ), }; } + + /** + * Ensure the version can only ever form a single subdomain label or path segment. + * + * @throws InvalidArgumentException + */ + protected function ensureVersionIsSafeForUrl(string $version): string + { + if (preg_match('/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/', $version) !== 1) { + throw new InvalidArgumentException(sprintf('The API version "%s" is not safe to use in a URL. Versions may only contain letters, numbers, dashes, underscores and single dots.', $version)); + } + + return $version; + } } diff --git a/tests/Unit/Concerns/HasApiVersionTest.php b/tests/Unit/Concerns/HasApiVersionTest.php index 09cb7bf4..28bb0c7b 100644 --- a/tests/Unit/Concerns/HasApiVersionTest.php +++ b/tests/Unit/Concerns/HasApiVersionTest.php @@ -38,7 +38,7 @@ test('url path mode replaces the version placeholder in the base url', function () { $mockClient = sendVersionedRequest( - makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::UrlPath, 'v1beta'), + makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::Url, 'v1beta'), makeVersionedRequest(), ); @@ -52,7 +52,7 @@ test('subdomain mode replaces the version placeholder in the host', function () { $mockClient = sendVersionedRequest( - makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Subdomain, 'v2'), + makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Url, 'v2'), makeVersionedRequest(), ); @@ -108,10 +108,49 @@ })->with([ 'header' => VersionMode::Header, 'query param' => VersionMode::QueryParam, - 'subdomain' => VersionMode::Subdomain, - 'url path' => VersionMode::UrlPath, + 'url' => VersionMode::Url, ]); +test('url mode rejects versions that could alter the scheme, host, or path', function (string $baseUrl, string $version) { + $mockClient = new MockClient([MockResponse::make()]); + + $connector = makeVersionedConnector($baseUrl, VersionMode::Url, $version); + + expect(fn () => $connector->send(makeVersionedRequest(), $mockClient)) + ->toThrow(InvalidArgumentException::class, sprintf('The API version "%s" is not safe to use in a URL.', $version)); + + $mockClient->assertNothingSent(); +})->with([ + 'https scheme in path' => ['https://api.provider.com/{version}', 'https://evil.com'], + 'https scheme in subdomain' => ['https://{version}.api.provider.com', 'https://evil.com'], + 'file scheme' => ['{version}/api', 'file:///etc/passwd'], + 'bare scheme separator' => ['https://api.provider.com/{version}', '://evil.com'], + 'protocol relative host' => ['https://api.provider.com/{version}', '//evil.com'], + 'userinfo host takeover' => ['https://{version}.api.provider.com', 'evil.com@'], + 'path traversal' => ['https://api.provider.com/{version}', '..'], + 'nested path traversal' => ['https://api.provider.com/{version}', 'v1/../../admin'], + 'single dot' => ['https://api.provider.com/{version}', '.'], + 'query string' => ['https://api.provider.com/{version}', 'v1?admin=true'], + 'fragment' => ['https://api.provider.com/{version}', 'v1#admin'], + 'port' => ['https://{version}.api.provider.com', 'evil.com:8080'], + 'whitespace' => ['https://api.provider.com/{version}', 'v1 beta'], + 'crlf' => ['https://api.provider.com/{version}', "v1\r\nHost: evil.com"], + 'percent encoding' => ['https://api.provider.com/{version}', '%2e%2e'], +]); + +test('url mode accepts common version formats', function (string $version) { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, $version), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) use ($version) { + expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/' . $version); + + return true; + }); +})->with(['v1', 'v1beta', 'v2.1', '2026-10-01', 'v1_alpha']); + function makeVersionedConnector(string $baseUrl, VersionMode $versionMode = VersionMode::Header, ?string $apiVersion = null): Connector { return new class($baseUrl, $versionMode, $apiVersion) extends Connector { From 73ad2d2fe71ab9277c881ab471a9114ab6530feb Mon Sep 17 00:00:00 2001 From: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:02:55 +0100 Subject: [PATCH 5/5] Grouped tests together --- tests/Unit/Concerns/HasApiVersionTest.php | 234 +++++++++++----------- 1 file changed, 122 insertions(+), 112 deletions(-) diff --git a/tests/Unit/Concerns/HasApiVersionTest.php b/tests/Unit/Concerns/HasApiVersionTest.php index 28bb0c7b..b7f614a9 100644 --- a/tests/Unit/Concerns/HasApiVersionTest.php +++ b/tests/Unit/Concerns/HasApiVersionTest.php @@ -10,146 +10,156 @@ use Saloon\Http\Faking\MockResponse; use Saloon\Traits\Plugins\HasApiVersion; -test('header mode adds the version to the outgoing request headers', function () { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'), - makeVersionedRequest(), - ); - - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('anthropic-version'); - - return true; +describe('Header Mode', function () { + test('it adds the version to the outgoing request headers', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('anthropic-version'); + + return true; + }); }); }); -test('query param mode appends the version to the query string', function () { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://api.provider.com', VersionMode::QueryParam, '2026-10'), - makeVersionedRequest(), - ); +describe('Query Param Mode', function () { + test('it appends the version to the query string', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com', VersionMode::QueryParam, '2026-10'), + makeVersionedRequest(), + ); - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPsrRequest()->getUri()->getQuery())->toBe('api-version=2026-10'); + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getUri()->getQuery())->toBe('api-version=2026-10'); - return true; + return true; + }); }); }); -test('url path mode replaces the version placeholder in the base url', function () { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::Url, 'v1beta'), - makeVersionedRequest(), - ); +describe('Url Mode', function () { + test('it replaces the version placeholder in the path', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::Url, 'v1beta'), + makeVersionedRequest(), + ); - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPendingRequest()->getUrl())->toBe('https://generativelanguage.googleapis.com/v1beta'); - expect((string) $response->getPsrRequest()->getUri())->toBe('https://generativelanguage.googleapis.com/v1beta'); + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPendingRequest()->getUrl())->toBe('https://generativelanguage.googleapis.com/v1beta'); + expect((string) $response->getPsrRequest()->getUri())->toBe('https://generativelanguage.googleapis.com/v1beta'); - return true; + return true; + }); }); -}); -test('subdomain mode replaces the version placeholder in the host', function () { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Url, 'v2'), - makeVersionedRequest(), - ); + test('it replaces the version placeholder in the host', function () { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Url, 'v2'), + makeVersionedRequest(), + ); - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPendingRequest()->getUrl())->toBe('https://v2.api.provider.com'); - expect((string) $response->getPsrRequest()->getUri())->toBe('https://v2.api.provider.com'); + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPendingRequest()->getUrl())->toBe('https://v2.api.provider.com'); + expect((string) $response->getPsrRequest()->getUri())->toBe('https://v2.api.provider.com'); - return true; + return true; + }); }); + + test('it accepts common version formats', function (string $version) { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, $version), + makeVersionedRequest(), + ); + + $mockClient->assertSent(function (Request $request, $response) use ($version) { + expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/' . $version); + + return true; + }); + })->with(['v1', 'v1beta', 'v2.1', '2026-10-01', 'v1_alpha']); + + test('it rejects versions that could alter the scheme, host, or path', function (string $baseUrl, string $version) { + $mockClient = new MockClient([MockResponse::make()]); + + $connector = makeVersionedConnector($baseUrl, VersionMode::Url, $version); + + expect(fn () => $connector->send(makeVersionedRequest(), $mockClient)) + ->toThrow(InvalidArgumentException::class, sprintf('The API version "%s" is not safe to use in a URL.', $version)); + + $mockClient->assertNothingSent(); + })->with([ + 'https scheme in path' => ['https://api.provider.com/{version}', 'https://evil.com'], + 'https scheme in subdomain' => ['https://{version}.api.provider.com', 'https://evil.com'], + 'file scheme' => ['{version}/api', 'file:///etc/passwd'], + 'bare scheme separator' => ['https://api.provider.com/{version}', '://evil.com'], + 'protocol relative host' => ['https://api.provider.com/{version}', '//evil.com'], + 'userinfo host takeover' => ['https://{version}.api.provider.com', 'evil.com@'], + 'path traversal' => ['https://api.provider.com/{version}', '..'], + 'nested path traversal' => ['https://api.provider.com/{version}', 'v1/../../admin'], + 'single dot' => ['https://api.provider.com/{version}', '.'], + 'query string' => ['https://api.provider.com/{version}', 'v1?admin=true'], + 'fragment' => ['https://api.provider.com/{version}', 'v1#admin'], + 'port' => ['https://{version}.api.provider.com', 'evil.com:8080'], + 'whitespace' => ['https://api.provider.com/{version}', 'v1 beta'], + 'crlf' => ['https://api.provider.com/{version}', "v1\r\nHost: evil.com"], + 'percent encoding' => ['https://api.provider.com/{version}', '%2e%2e'], + ]); }); -test('the connector can override the api version at runtime', function () { - $connector = makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'); - $connector->setApiVersion('override-version'); +describe('Overriding The Version', function () { + test('the connector can override the api version at runtime', function () { + $connector = makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'); + $connector->setApiVersion('override-version'); - $mockClient = sendVersionedRequest($connector, makeVersionedRequest()); + $mockClient = sendVersionedRequest($connector, makeVersionedRequest()); - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); - return true; + return true; + }); }); -}); -test('the request can override the api version at runtime', function () { - $request = makeVersionedRequest('override-version', VersionMode::Header); + test('the request can override the api version at runtime', function () { + $request = makeVersionedRequest('override-version', VersionMode::Header); - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://api.provider.com'), - $request, - ); + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com'), + $request, + ); - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe('override-version'); - return true; + return true; + }); }); }); -test('a null api version does not add a header, query parameter, or replace the url', function (VersionMode $versionMode) { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://api.provider.com/{version}', $versionMode), - makeVersionedRequest(null, $versionMode), - ); - - $mockClient->assertSent(function (Request $request, $response) { - expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe(''); - expect($response->getPsrRequest()->getUri()->getQuery())->not->toContain('api-version'); - expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/{version}'); - - return true; - }); -})->with([ - 'header' => VersionMode::Header, - 'query param' => VersionMode::QueryParam, - 'url' => VersionMode::Url, -]); - -test('url mode rejects versions that could alter the scheme, host, or path', function (string $baseUrl, string $version) { - $mockClient = new MockClient([MockResponse::make()]); - - $connector = makeVersionedConnector($baseUrl, VersionMode::Url, $version); - - expect(fn () => $connector->send(makeVersionedRequest(), $mockClient)) - ->toThrow(InvalidArgumentException::class, sprintf('The API version "%s" is not safe to use in a URL.', $version)); - - $mockClient->assertNothingSent(); -})->with([ - 'https scheme in path' => ['https://api.provider.com/{version}', 'https://evil.com'], - 'https scheme in subdomain' => ['https://{version}.api.provider.com', 'https://evil.com'], - 'file scheme' => ['{version}/api', 'file:///etc/passwd'], - 'bare scheme separator' => ['https://api.provider.com/{version}', '://evil.com'], - 'protocol relative host' => ['https://api.provider.com/{version}', '//evil.com'], - 'userinfo host takeover' => ['https://{version}.api.provider.com', 'evil.com@'], - 'path traversal' => ['https://api.provider.com/{version}', '..'], - 'nested path traversal' => ['https://api.provider.com/{version}', 'v1/../../admin'], - 'single dot' => ['https://api.provider.com/{version}', '.'], - 'query string' => ['https://api.provider.com/{version}', 'v1?admin=true'], - 'fragment' => ['https://api.provider.com/{version}', 'v1#admin'], - 'port' => ['https://{version}.api.provider.com', 'evil.com:8080'], - 'whitespace' => ['https://api.provider.com/{version}', 'v1 beta'], - 'crlf' => ['https://api.provider.com/{version}', "v1\r\nHost: evil.com"], - 'percent encoding' => ['https://api.provider.com/{version}', '%2e%2e'], -]); - -test('url mode accepts common version formats', function (string $version) { - $mockClient = sendVersionedRequest( - makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, $version), - makeVersionedRequest(), - ); - - $mockClient->assertSent(function (Request $request, $response) use ($version) { - expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/' . $version); - - return true; - }); -})->with(['v1', 'v1beta', 'v2.1', '2026-10-01', 'v1_alpha']); +describe('Without A Version', function () { + test('it does not add a header, query parameter, or replace the url', function (VersionMode $versionMode) { + $mockClient = sendVersionedRequest( + makeVersionedConnector('https://api.provider.com/{version}', $versionMode), + makeVersionedRequest(null, $versionMode), + ); + + $mockClient->assertSent(function (Request $request, $response) { + expect($response->getPsrRequest()->getHeaderLine('api-version'))->toBe(''); + expect($response->getPsrRequest()->getUri()->getQuery())->not->toContain('api-version'); + expect($response->getPendingRequest()->getUrl())->toBe('https://api.provider.com/{version}'); + + return true; + }); + })->with([ + 'header' => VersionMode::Header, + 'query param' => VersionMode::QueryParam, + 'url' => VersionMode::Url, + ]); +}); function makeVersionedConnector(string $baseUrl, VersionMode $versionMode = VersionMode::Header, ?string $apiVersion = null): Connector {