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
12 changes: 12 additions & 0 deletions src/Enums/VersionMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Saloon\Enums;

enum VersionMode
{
case Header; // e.g., api-version: 2026-10
case QueryParam; // e.g., ?api-version=2026-10
case Url; // e.g., https://{version}.api.com or https://api.com/{version}
}
82 changes: 82 additions & 0 deletions src/Traits/Plugins/HasApiVersion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Saloon\Traits\Plugins;

use InvalidArgumentException;
use Saloon\Enums\VersionMode;
use Saloon\Http\PendingRequest;

/**
* @phpstan-ignore trait.unused
*/
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;

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) {
return;
}

match ($this->versionMode) {
VersionMode::Header => $pendingRequest->headers()->add($this->versionKey, $this->apiVersion),
VersionMode::QueryParam => $pendingRequest->query()->add($this->versionKey, $this->apiVersion),
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;
}
}
222 changes: 222 additions & 0 deletions tests/Unit/Concerns/HasApiVersionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

declare(strict_types=1);

use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Connector;
use Saloon\Enums\VersionMode;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
use Saloon\Traits\Plugins\HasApiVersion;

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;
});
});
});

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');

return true;
});
});
});

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');

return true;
});
});

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');

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'],
]);
});

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->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;
});
});
});

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
{
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;
}
Loading