From c4c5d756e798678bfd379bb0bc7c2b5bfe5bb2f5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:04:50 +0000
Subject: [PATCH 01/26] Reconcile Saloon request property tests with upstream
Ports Saloon's store, request property, query, config and timeout tests
(saloonphp/saloon v4 at 2f45e526f9d1) with their fixtures, merging the
existing Hypervel coverage into the upstream-named files under
tests/Saloon/{Unit,Feature,Fixtures}.
Requests keep the fluent API of Hypervel's HTTP client instead of
upstream's ArrayStore objects. The tests showed missing capabilities,
now added in that style:
- withoutHeader() and withoutHeaders() remove headers ignoring case.
- withoutQueryParameters() removes parameters added through the query
parameter methods. On a pending request it also clears the finalized
URI, so a removal after finalizeUri() takes effect.
- withoutOptions() removes options. Unlike a null option, removal lets
the connector value or the HTTP connection's configuration apply.
Removing a value from a request leaves connector defaults in place,
because connectors are read-only and merged into the pending request;
remove those from the pending request.
The HasTimeout plugin is ported. Plugins boot after connector and
request options are merged, so declared timeouts only fill options that
neither the declaring class nor the request sets. A request's declared
timeout therefore beats connector options, where upstream lets the
connector silently override it. Undeclared getters return null instead
of upstream's Config defaults, so the HTTP connection's timeouts apply.
IntegerRepository only held the request delay. HasDelay now stores a
nullable int, preserving the default hook and an explicit zero.
The README records the fluent API, read-only connectors and the
HasTimeout getter difference. The guide documents the removal methods
and HasTimeout. The six Saloon sync.yaml notes now describe how each
upstream repository maps into the unified package; checkpoints stay
unset until the reconciliation finishes.
Two upstream QueryParameterTest cases used a connector without default
query parameters, so they could not test overwriting one; they now use
QueryParameterConnector.
Validation: Saloon suite (393 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon, TelescopeTagTest and AfterEachTestSubscriberTest;
composer analyse; php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 11 +-
src/docs/saloon.md | 45 +++-
src/saloon/README.md | 6 +
src/saloon/src/Http/PendingRequest.php | 23 +-
src/saloon/src/Http/Request.php | 1 -
.../src/Repositories/IntegerRepository.php | 61 -----
src/saloon/src/Traits/Plugins/HasTimeout.php | 47 ++++
.../src/Traits/RequestProperties/HasDelay.php | 17 +-
.../Traits/RequestProperties/HasHeaders.php | 29 +++
.../Traits/RequestProperties/HasOptions.php | 13 ++
.../src/Traits/RequestProperties/HasQuery.php | 16 ++
tests/Saloon/Feature/ConfigTest.php | 100 +++++++++
.../Fixtures/Connectors/ConfigConnector.php | 31 +++
.../Fixtures/Connectors/HeaderConnector.php | 32 +++
.../Connectors/QueryParameterConnector.php | 41 ++++
.../Fixtures/Connectors/TestConnector.php | 47 ++++
.../Fixtures/Connectors/TimeoutConnector.php | 37 ++++
.../Fixtures/Requests/ConfigRequest.php | 40 ++++
.../Requests/DefaultPropertiesRequest.php | 70 ++++++
.../Fixtures/Requests/HeaderRequest.php | 50 +++++
...rwrittenQueryParameterConnectorRequest.php | 40 ++++
.../QueryParameterConnectorBlankRequest.php | 30 +++
.../QueryParameterConnectorRequest.php | 40 ++++
.../Requests/QueryParameterRequest.php | 47 ++++
.../Fixtures/Requests/TimeoutRequest.php | 37 ++++
.../Saloon/Fixtures/Requests/UserRequest.php | 31 +++
tests/Saloon/Http/RequestDelayTest.php | 84 +++++++
tests/Saloon/Http/RequestOptionsTest.php | 66 ------
.../Repositories/ArrayRepositoryTest.php | 63 ------
.../Repositories/IntegerRepositoryTest.php | 51 -----
tests/Saloon/Unit/ArrayStoreTest.php | 93 ++++++++
.../Unit/Plugins/HasTimeoutPluginTest.php | 208 ++++++++++++++++++
tests/Saloon/Unit/QueryParameterTest.php | 121 ++++++++++
.../RequestOptionValidatorTest.php | 2 +-
.../Unit/RequestProperties/ConfigTest.php | 151 +++++++++++++
.../Unit/RequestProperties/HeadersTest.php | 129 +++++++++++
.../Unit/RequestProperties/QueryTest.php | 133 +++++++++++
tests/Saloon/Unit/RequestPropertiesTest.php | 33 +++
38 files changed, 1810 insertions(+), 266 deletions(-)
delete mode 100644 src/saloon/src/Repositories/IntegerRepository.php
create mode 100644 src/saloon/src/Traits/Plugins/HasTimeout.php
create mode 100644 tests/Saloon/Feature/ConfigTest.php
create mode 100644 tests/Saloon/Fixtures/Connectors/ConfigConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/HeaderConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/TestConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/TimeoutConnector.php
create mode 100644 tests/Saloon/Fixtures/Requests/ConfigRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DefaultPropertiesRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HeaderRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/OverwrittenQueryParameterConnectorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/QueryParameterConnectorBlankRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/QueryParameterConnectorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/QueryParameterRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/TimeoutRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/UserRequest.php
delete mode 100644 tests/Saloon/Http/RequestOptionsTest.php
delete mode 100644 tests/Saloon/Repositories/ArrayRepositoryTest.php
delete mode 100644 tests/Saloon/Repositories/IntegerRepositoryTest.php
create mode 100644 tests/Saloon/Unit/ArrayStoreTest.php
create mode 100644 tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php
create mode 100644 tests/Saloon/Unit/QueryParameterTest.php
rename tests/Saloon/{Http => Unit}/RequestOptionValidatorTest.php (97%)
create mode 100644 tests/Saloon/Unit/RequestProperties/ConfigTest.php
create mode 100644 tests/Saloon/Unit/RequestProperties/HeadersTest.php
create mode 100644 tests/Saloon/Unit/RequestProperties/QueryTest.php
create mode 100644 tests/Saloon/Unit/RequestPropertiesTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 511f7690dc..1a9c5fb2fb 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,41 +182,42 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Integrated into src/saloon.
+ notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's HTTP layer is a mock client and middleware, not a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel.
saloonphp/cache-plugin:
branch: v3
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Integrated into src/saloon.
+ notes: Part of the unified src/saloon package. Upstream caching maps to Cache and SaloonManager using Hypervel's cache services; assess behavior against that integration instead of duplicating upstream drivers. Tests map to tests/Saloon/Cache.
saloonphp/pagination-plugin:
branch: v2
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Integrated into src/saloon.
+ notes: Part of the unified src/saloon package, under Pagination. Request-owned pagination and link paginators are also used by SDK generators; check affected consumers when adapting upstream changes. Tests map to tests/Saloon/Pagination.
saloonphp/rate-limit-plugin:
branch: v2
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Integrated into src/saloon.
+ notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Tests map to tests/Saloon/RateLimit.
Sammyjo20/saloon-docs:
branch: v3
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Shared documentation for Saloon and its plugins.
+ notes: Shared documentation for the unified src/saloon package and all four plugin entries above, mapped to src/docs/saloon.md. The v3 branch includes v4 guidance; verify examples against the tracked source and Hypervel's integrations, using Laravel-style prose.
getsentry/sentry-laravel:
branch: master
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 541201cdce..aa117cb513 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -455,7 +455,7 @@ Requests and pending requests provide fluent methods that mirror Hypervel's HTTP
### Headers
-Use `withHeader` to add one header or `withHeaders` to merge several headers. The `replaceHeaders` method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing:
+Use `withHeader` to add one header or `withHeaders` to merge several headers. Adding a header that is already present keeps both values. The `replaceHeaders` method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing:
```php
$request
@@ -468,6 +468,17 @@ $request
->withUserAgent('Acme Application/1.0');
```
+The `withoutHeader` and `withoutHeaders` methods remove headers, also ignoring case. Removing a header from a request only affects the request's own headers, since connector defaults are merged into the pending request when the request is sent. To remove a connector default, remove it from the pending request in a hook or middleware:
+
+```php
+use Hypervel\Saloon\Http\PendingRequest;
+
+public function boot(PendingRequest $pendingRequest): void
+{
+ $pendingRequest->withoutHeader('X-Api-Version');
+}
+```
+
### Request URL
@@ -495,6 +506,12 @@ $request->withQueryParameters([
Request values replace connector values with the same key. Values added later by middleware replace earlier values. Query parameters already present in the connector base URL or request endpoint are preserved unless the request contains the same top-level key.
+The `withoutQueryParameters` method removes parameters added through `withQueryParameters` or `defaultQuery`. As with headers, removing a parameter from a request does not remove a connector default; remove it from the pending request instead. Values embedded in the base URL, endpoint, or `withQueryString` query are unchanged:
+
+```php
+$request->withoutQueryParameters(['page', 'per_page']);
+```
+
Use `withQueryString` when an API supplies an already-encoded query, including repeated parameter names:
```php
@@ -756,6 +773,32 @@ $request
The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds.
+The `withoutOptions` method removes options from the request, so the connector's value or the HTTP connection's configured value applies again. To remove a connector default, call `withoutOptions` on the pending request:
+
+```php
+$request->withoutOptions(['timeout', 'connect_timeout']);
+```
+
+The `HasTimeout` plugin gives a connector or request default timeouts from its `$connectTimeout` and `$requestTimeout` properties, in seconds. You may override the `getConnectTimeout` or `getRequestTimeout` methods to calculate them instead. Options set on the same connector or request take precedence over its declared timeouts, and a request's timeouts take precedence over its connector's. A timeout that is not declared leaves the existing options and the HTTP connection's configured value in effect:
+
+```php
+use Hypervel\Saloon\Traits\Plugins\HasTimeout;
+
+class GitHubConnector extends Connector
+{
+ use HasTimeout;
+
+ protected int $connectTimeout = 5;
+
+ protected int $requestTimeout = 60;
+
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.github.com';
+ }
+}
+```
+
To specify a cookie's path or other attributes, use `withCookie` with a Guzzle `SetCookie` instance. The cookie must include a domain:
```php
diff --git a/src/saloon/README.md b/src/saloon/README.md
index d3573c8b9d..0933f3cbe9 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -4,6 +4,12 @@ Documentation: https://hypervel.org/docs/saloon
## Differences From Laravel
+Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`.
+
+Connectors are read-only. Their headers, query parameters, options, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware.
+
+The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options).
+
The upstream NTLM authenticator is omitted. Integrations requiring NTLM must supply their own authenticator and transport middleware.
Ported from: https://github.com/saloonphp/saloon
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 7ac7b7db11..3a8ad3b559 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -23,7 +23,6 @@
use Hypervel\Saloon\Http\PendingRequest\BootPlugins;
use Hypervel\Saloon\Repositories\ArrayRepository;
use Hypervel\Saloon\Repositories\Body\MultipartBodyRepository;
-use Hypervel\Saloon\Repositories\IntegerRepository;
use Hypervel\Saloon\Traits\Auth\AuthenticatesRequests;
use Hypervel\Saloon\Traits\Body\HasBody;
use Hypervel\Saloon\Traits\HasDebugging;
@@ -47,6 +46,7 @@ class PendingRequest
use HasDebugging;
use HasRequestProperties {
withQueryParameters as protected addQueryParameters;
+ withoutQueryParameters as protected removeQueryParameters;
withQueryString as protected replaceQueryString;
}
use Macroable;
@@ -126,9 +126,7 @@ public function __construct(
$connector->options(),
$request->options(),
));
- $this->delayRepository = new IntegerRepository(
- $request->delayMilliseconds() ?? $connector->delayMilliseconds(),
- );
+ $this->delay = $request->delayMilliseconds() ?? $connector->delayMilliseconds();
$this->middlewarePipeline = clone $request->middleware();
$connectorBody = $connector->copyDefaultBodyRepository();
@@ -224,6 +222,23 @@ public function withQueryParameters(array $parameters): static
return $this;
}
+ /**
+ * Remove query parameters and invalidate the finalized URI.
+ *
+ * Only values added through the query parameter methods are removed. Values embedded in the base URL, endpoint
+ * or raw query string are unchanged.
+ *
+ * @param array|string $keys
+ * @return $this
+ */
+ public function withoutQueryParameters(array|string $keys): static
+ {
+ $this->uri = null;
+ $this->removeQueryParameters($keys);
+
+ return $this;
+ }
+
/**
* Replace the raw query string and invalidate the finalized URI.
*/
diff --git a/src/saloon/src/Http/Request.php b/src/saloon/src/Http/Request.php
index e4cef0612e..249b68dffd 100644
--- a/src/saloon/src/Http/Request.php
+++ b/src/saloon/src/Http/Request.php
@@ -221,7 +221,6 @@ public function __clone(): void
$this->headerRepository = $this->headerRepository !== null ? clone $this->headerRepository : null;
$this->queryRepository = $this->queryRepository !== null ? clone $this->queryRepository : null;
$this->optionRepository = $this->optionRepository !== null ? clone $this->optionRepository : null;
- $this->delayRepository = $this->delayRepository !== null ? clone $this->delayRepository : null;
$this->middlewarePipeline = $this->middlewarePipeline !== null ? clone $this->middlewarePipeline : null;
$this->bodyRepository = $this->bodyRepository !== null ? clone $this->bodyRepository : null;
}
diff --git a/src/saloon/src/Repositories/IntegerRepository.php b/src/saloon/src/Repositories/IntegerRepository.php
deleted file mode 100644
index aa518f4656..0000000000
--- a/src/saloon/src/Repositories/IntegerRepository.php
+++ /dev/null
@@ -1,61 +0,0 @@
-set($value);
- }
-
- /**
- * Set the repository value.
- *
- * @return $this
- */
- public function set(?int $value): static
- {
- $this->data = $value;
-
- return $this;
- }
-
- /**
- * Get the repository value.
- */
- public function get(): ?int
- {
- return $this->data;
- }
-
- /**
- * Determine if the repository is empty.
- */
- public function isEmpty(): bool
- {
- return $this->data === null;
- }
-
- /**
- * Determine if the repository is not empty.
- */
- public function isNotEmpty(): bool
- {
- return ! $this->isEmpty();
- }
-}
diff --git a/src/saloon/src/Traits/Plugins/HasTimeout.php b/src/saloon/src/Traits/Plugins/HasTimeout.php
new file mode 100644
index 0000000000..91cf987a13
--- /dev/null
+++ b/src/saloon/src/Traits/Plugins/HasTimeout.php
@@ -0,0 +1,47 @@
+options() + $pendingRequest->request()->options();
+ $timeouts = [
+ RequestOptions::CONNECT_TIMEOUT => $this->getConnectTimeout(),
+ RequestOptions::TIMEOUT => $this->getRequestTimeout(),
+ ];
+
+ foreach ($timeouts as $option => $timeout) {
+ if ($timeout !== null && ! array_key_exists($option, $options)) {
+ $pendingRequest->withOptions([$option => $timeout]);
+ }
+ }
+ }
+
+ /**
+ * Get the request connection timeout.
+ */
+ public function getConnectTimeout(): ?float
+ {
+ return property_exists($this, 'connectTimeout') ? $this->connectTimeout : null;
+ }
+
+ /**
+ * Get the request timeout.
+ */
+ public function getRequestTimeout(): ?float
+ {
+ return property_exists($this, 'requestTimeout') ? $this->requestTimeout : null;
+ }
+}
diff --git a/src/saloon/src/Traits/RequestProperties/HasDelay.php b/src/saloon/src/Traits/RequestProperties/HasDelay.php
index d847f99c29..0af51cd6a6 100644
--- a/src/saloon/src/Traits/RequestProperties/HasDelay.php
+++ b/src/saloon/src/Traits/RequestProperties/HasDelay.php
@@ -4,15 +4,14 @@
namespace Hypervel\Saloon\Traits\RequestProperties;
-use Hypervel\Saloon\Repositories\IntegerRepository;
use InvalidArgumentException;
trait HasDelay
{
/**
- * The request delay.
+ * The request delay in milliseconds.
*/
- protected ?IntegerRepository $delayRepository = null;
+ protected ?int $delay = null;
/**
* Delay the request by the given milliseconds.
@@ -25,7 +24,7 @@ public function delay(int $milliseconds): static
throw new InvalidArgumentException('The request delay must be a representable non-negative number of milliseconds.');
}
- $this->delayRepository()->set($milliseconds);
+ $this->delay = $milliseconds;
return $this;
}
@@ -35,7 +34,7 @@ public function delay(int $milliseconds): static
*/
public function delayMilliseconds(): ?int
{
- return $this->delayRepository()->get();
+ return $this->delay ?? $this->defaultDelay();
}
/**
@@ -45,12 +44,4 @@ protected function defaultDelay(): ?int
{
return null;
}
-
- /**
- * Get the request delay repository.
- */
- protected function delayRepository(): IntegerRepository
- {
- return $this->delayRepository ??= new IntegerRepository($this->defaultDelay());
- }
}
diff --git a/src/saloon/src/Traits/RequestProperties/HasHeaders.php b/src/saloon/src/Traits/RequestProperties/HasHeaders.php
index 3ac4778276..7fc6cb9645 100644
--- a/src/saloon/src/Traits/RequestProperties/HasHeaders.php
+++ b/src/saloon/src/Traits/RequestProperties/HasHeaders.php
@@ -89,6 +89,35 @@ public function replaceHeaders(array $headers): static
return $this;
}
+ /**
+ * Remove a header from the request.
+ *
+ * @return $this
+ */
+ public function withoutHeader(string $name): static
+ {
+ return $this->withoutHeaders([$name]);
+ }
+
+ /**
+ * Remove the given headers from the request.
+ *
+ * @param array $names
+ * @return $this
+ */
+ public function withoutHeaders(array $names): static
+ {
+ $names = array_map(strtolower(...), $names);
+
+ $this->headerRepository()->set(array_filter(
+ $this->headers(),
+ static fn (string $header): bool => ! in_array(strtolower($header), $names, true),
+ ARRAY_FILTER_USE_KEY,
+ ));
+
+ return $this;
+ }
+
/**
* Specify the request content type.
*
diff --git a/src/saloon/src/Traits/RequestProperties/HasOptions.php b/src/saloon/src/Traits/RequestProperties/HasOptions.php
index 5c14e1bbfe..3d1c816368 100644
--- a/src/saloon/src/Traits/RequestProperties/HasOptions.php
+++ b/src/saloon/src/Traits/RequestProperties/HasOptions.php
@@ -38,6 +38,19 @@ public function withOptions(array $options): static
return $this;
}
+ /**
+ * Remove the given options from the request.
+ *
+ * @param array|string $keys
+ * @return $this
+ */
+ public function withoutOptions(array|string $keys): static
+ {
+ $this->optionRepository()->set(array_diff_key($this->options(), array_flip((array) $keys)));
+
+ return $this;
+ }
+
/**
* Specify the maximum number of redirects to allow.
*
diff --git a/src/saloon/src/Traits/RequestProperties/HasQuery.php b/src/saloon/src/Traits/RequestProperties/HasQuery.php
index 09ede6c2b0..e50fcd6cdd 100644
--- a/src/saloon/src/Traits/RequestProperties/HasQuery.php
+++ b/src/saloon/src/Traits/RequestProperties/HasQuery.php
@@ -59,6 +59,22 @@ public function withQueryParameters(array $parameters): static
return $this;
}
+ /**
+ * Remove the given query parameters from the request.
+ *
+ * Only values added through the query parameter methods are removed. Values embedded in the base URL, endpoint
+ * or raw query string are unchanged.
+ *
+ * @param array|string $keys
+ * @return $this
+ */
+ public function withoutQueryParameters(array|string $keys): static
+ {
+ $this->queryRepository()->set(array_diff_key($this->queryParameters(), array_flip((array) $keys)));
+
+ return $this;
+ }
+
/**
* Resolve the default request query parameters.
*
diff --git a/tests/Saloon/Feature/ConfigTest.php b/tests/Saloon/Feature/ConfigTest.php
new file mode 100644
index 0000000000..05225caa8e
--- /dev/null
+++ b/tests/Saloon/Feature/ConfigTest.php
@@ -0,0 +1,100 @@
+captureOptions();
+
+ (new TestConnector)->send(new UserRequest);
+
+ // The timeouts come from the Saloon HTTP connection. Saloon builds its own request exceptions, so it always
+ // sends `http_errors` as false where upstream's Guzzle sender defaults it to true.
+ $this->assertFalse($options->value['http_errors']);
+ $this->assertSame(10, $options->value['connect_timeout']);
+ $this->assertSame(30, $options->value['timeout']);
+ }
+
+ public function testYouCanPassAdditionalGuzzleConfigOptionsAndTheyAreMergedFromTheConnectorAndRequest(): void
+ {
+ $options = $this->captureOptions();
+
+ // Connectors are read-only, so connector options come from defaultOptions() instead of runtime changes.
+ $connector = new DebugOptionsConnectorStub;
+
+ $request = new UserRequest;
+
+ $request->withOptions(['verify' => false]);
+
+ $connector->send($request);
+
+ $this->assertFalse($options->value['http_errors']);
+ $this->assertSame(10, $options->value['connect_timeout']);
+ $this->assertSame(30, $options->value['timeout']);
+ $this->assertTrue($options->value['debug']);
+ $this->assertFalse($options->value['verify']);
+ }
+
+ public function testRemovingARequestOptionRestoresTheHttpConnectionValue(): void
+ {
+ $options = $this->captureOptions();
+
+ (new TestConnector)->send((new UserRequest)->timeout(5)->withoutOptions('timeout'));
+
+ $this->assertSame(30, $options->value['timeout']);
+ }
+
+ /**
+ * Capture the transport options of the next faked request.
+ *
+ * @return object{value: array}
+ */
+ protected function captureOptions(): object
+ {
+ $captured = new class {
+ public array $value = [];
+ };
+
+ Http::fake(function (HttpRequest $request, array $options) use ($captured): PromiseInterface {
+ $captured->value = $options;
+
+ return Http::response();
+ });
+
+ return $captured;
+ }
+}
+
+class DebugOptionsConnectorStub extends TestConnector
+{
+ /**
+ * Resolve the default request options.
+ *
+ * @return array
+ */
+ protected function defaultOptions(): array
+ {
+ return ['debug' => true];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/ConfigConnector.php b/tests/Saloon/Fixtures/Connectors/ConfigConnector.php
new file mode 100644
index 0000000000..8c4c9e4869
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/ConfigConnector.php
@@ -0,0 +1,31 @@
+ false,
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/HeaderConnector.php b/tests/Saloon/Fixtures/Connectors/HeaderConnector.php
new file mode 100644
index 0000000000..8cfcf39015
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/HeaderConnector.php
@@ -0,0 +1,32 @@
+ 'Sam',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php b/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php
new file mode 100644
index 0000000000..6774d40633
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php
@@ -0,0 +1,41 @@
+url)) {
+ $this->url = TestConnector::API_URL;
+ }
+ }
+
+ /**
+ * Define the base url of the api.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return $this->url;
+ }
+
+ /**
+ * Define the default query parameters.
+ */
+ protected function defaultQuery(): array
+ {
+ return [
+ 'sort' => 'first_name',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/TestConnector.php b/tests/Saloon/Fixtures/Connectors/TestConnector.php
new file mode 100644
index 0000000000..f7b7293f47
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/TestConnector.php
@@ -0,0 +1,47 @@
+url ?? self::API_URL;
+ }
+
+ /**
+ * Define the base headers that will be applied in every request.
+ *
+ * @return string[]
+ */
+ protected function defaultHeaders(): array
+ {
+ return [
+ 'Accept' => 'application/json',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php b/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php
new file mode 100644
index 0000000000..a620176f19
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php
@@ -0,0 +1,37 @@
+ false,
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/DefaultPropertiesRequest.php b/tests/Saloon/Fixtures/Requests/DefaultPropertiesRequest.php
new file mode 100644
index 0000000000..d861279626
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/DefaultPropertiesRequest.php
@@ -0,0 +1,70 @@
+ 'Luke Combs',
+ ];
+ }
+
+ /**
+ * Define the default query parameters.
+ */
+ protected function defaultQuery(): array
+ {
+ return [
+ 'format' => 'json',
+ ];
+ }
+
+ /**
+ * Define the default data.
+ */
+ protected function defaultData(): mixed
+ {
+ return [
+ 'song' => 'Call Me',
+ ];
+ }
+
+ /**
+ * Define the default request options.
+ */
+ protected function defaultOptions(): array
+ {
+ return [
+ 'debug' => true,
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/HeaderRequest.php b/tests/Saloon/Fixtures/Requests/HeaderRequest.php
new file mode 100644
index 0000000000..e2790ccf5d
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HeaderRequest.php
@@ -0,0 +1,50 @@
+ 'Howdy',
+ ];
+ }
+
+ /**
+ * Define the default request options.
+ */
+ protected function defaultOptions(): array
+ {
+ return [
+ 'timeout' => 5,
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/OverwrittenQueryParameterConnectorRequest.php b/tests/Saloon/Fixtures/Requests/OverwrittenQueryParameterConnectorRequest.php
new file mode 100644
index 0000000000..b5cd5e8ee6
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/OverwrittenQueryParameterConnectorRequest.php
@@ -0,0 +1,40 @@
+ 'date_of_birth',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/QueryParameterConnectorBlankRequest.php b/tests/Saloon/Fixtures/Requests/QueryParameterConnectorBlankRequest.php
new file mode 100644
index 0000000000..1254ccb6be
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/QueryParameterConnectorBlankRequest.php
@@ -0,0 +1,30 @@
+ 'user',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php b/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php
new file mode 100644
index 0000000000..e0d3f7fda2
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php
@@ -0,0 +1,47 @@
+endpoint;
+ }
+
+ /**
+ * Define the default query parameters.
+ */
+ protected function defaultQuery(): array
+ {
+ return [
+ 'per_page' => 100,
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/TimeoutRequest.php b/tests/Saloon/Fixtures/Requests/TimeoutRequest.php
new file mode 100644
index 0000000000..79aa79bfcb
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/TimeoutRequest.php
@@ -0,0 +1,37 @@
+assertNull($this->request()->delayMilliseconds());
+ }
+
public function testItStoresARepresentableDelay(): void
{
$request = $this->request()->delay(250);
@@ -17,6 +31,20 @@ public function testItStoresARepresentableDelay(): void
$this->assertSame(250, $request->delayMilliseconds());
}
+ public function testTheDefaultDelayIsUsedUntilADelayIsSet(): void
+ {
+ $request = new DefaultDelayRequestStub;
+
+ $this->assertSame(100, $request->delayMilliseconds());
+ $this->assertSame(0, $request->delay(0)->delayMilliseconds());
+ }
+
+ public function testAnExplicitZeroDelayOverridesTheConnectorDelay(): void
+ {
+ $this->assertSame(500, $this->pendingRequest(new DelayRequestStub)->delayMilliseconds());
+ $this->assertSame(0, $this->pendingRequest((new DelayRequestStub)->delay(0))->delayMilliseconds());
+ }
+
public function testItRejectsNegativeDelays(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -42,4 +70,60 @@ protected function request(): object
use HasDelay;
};
}
+
+ /**
+ * Create a pending request through a connector with a default delay.
+ */
+ protected function pendingRequest(Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ new DelayConnectorStub,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
+
+class DelayConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+
+ /**
+ * Resolve the default request delay in milliseconds.
+ */
+ protected function defaultDelay(): ?int
+ {
+ return 500;
+ }
+}
+
+class DelayRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+}
+
+class DefaultDelayRequestStub extends DelayRequestStub
+{
+ /**
+ * Resolve the default request delay in milliseconds.
+ */
+ protected function defaultDelay(): ?int
+ {
+ return 100;
+ }
}
diff --git a/tests/Saloon/Http/RequestOptionsTest.php b/tests/Saloon/Http/RequestOptionsTest.php
deleted file mode 100644
index 7679054440..0000000000
--- a/tests/Saloon/Http/RequestOptionsTest.php
+++ /dev/null
@@ -1,66 +0,0 @@
-request()
- ->withOptions(['allow_redirects' => ['strict' => true]])
- ->maxRedirects(3);
-
- $this->assertSame(['strict' => true, 'max' => 3], $request->options()['allow_redirects']);
- }
-
- public function testMaxRedirectsReenablesRedirectsWithoutDeprecation(): void
- {
- set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
- throw new ErrorException($message, 0, $severity, $file, $line);
- }, E_DEPRECATED);
-
- try {
- $request = $this->request()
- ->withoutRedirecting()
- ->maxRedirects(3);
-
- $this->assertSame(['max' => 3], $request->options()['allow_redirects']);
- } finally {
- restore_error_handler();
- }
- }
-
- public function testWithoutRedirectingDisablesAConfiguredRedirectLimit(): void
- {
- $request = $this->request()
- ->maxRedirects(3)
- ->withoutRedirecting();
-
- $this->assertFalse($request->options()['allow_redirects']);
- }
-
- public function testMaxRedirectsReplacesTheBooleanEnabledForm(): void
- {
- $request = $this->request()
- ->withOptions(['allow_redirects' => true])
- ->maxRedirects(3);
-
- $this->assertSame(['max' => 3], $request->options()['allow_redirects']);
- }
-
- /**
- * Create an object with operation-owned request options.
- */
- protected function request(): object
- {
- return new class {
- use HasOptions;
- };
- }
-}
diff --git a/tests/Saloon/Repositories/ArrayRepositoryTest.php b/tests/Saloon/Repositories/ArrayRepositoryTest.php
deleted file mode 100644
index 6f3b85225c..0000000000
--- a/tests/Saloon/Repositories/ArrayRepositoryTest.php
+++ /dev/null
@@ -1,63 +0,0 @@
- 'Sam',
- 'hero' => 'Iron Man',
- ]);
-
- $repository->merge(['sidekick' => 'Gareth'], ['hero' => 'Black Widow']);
- $repository->add('lazy', fn () => 'resolved');
- $repository->remove('name');
-
- $this->assertSame([
- 'hero' => 'Black Widow',
- 'sidekick' => 'Gareth',
- 'lazy' => 'resolved',
- ], $repository->all());
- $this->assertSame('Black Widow', $repository->get('hero'));
- $this->assertSame('fallback', $repository->get('missing', 'fallback'));
- }
-
- public function testItReplacesAllValues(): void
- {
- $repository = new ArrayRepository(['name' => 'Sam']);
-
- $repository->set(['name' => 'Gareth']);
-
- $this->assertSame(['name' => 'Gareth'], $repository->all());
- }
-
- public function testItMayBeConditionallyChanged(): void
- {
- $repository = new ArrayRepository;
-
- $repository->when(true, fn (ArrayRepository $values) => $values->add('name', 'Gareth'));
- $repository->when(false, fn (ArrayRepository $values) => $values->add('name', 'Sam'));
-
- $this->assertSame(['name' => 'Gareth'], $repository->all());
- }
-
- public function testItReportsWhetherItIsEmpty(): void
- {
- $repository = new ArrayRepository;
-
- $this->assertTrue($repository->isEmpty());
- $this->assertFalse($repository->isNotEmpty());
-
- $repository->add('name', 'Sam');
-
- $this->assertFalse($repository->isEmpty());
- $this->assertTrue($repository->isNotEmpty());
- }
-}
diff --git a/tests/Saloon/Repositories/IntegerRepositoryTest.php b/tests/Saloon/Repositories/IntegerRepositoryTest.php
deleted file mode 100644
index 76d8059377..0000000000
--- a/tests/Saloon/Repositories/IntegerRepositoryTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
-assertNull($repository->get());
- $this->assertTrue($repository->isEmpty());
- $this->assertFalse($repository->isNotEmpty());
- }
-
- public function testRepositoryCanBeConstructedWithAValue(): void
- {
- $repository = new IntegerRepository(1);
-
- $this->assertSame(1, $repository->get());
- }
-
- public function testValueCanBeSet(): void
- {
- $repository = new IntegerRepository;
-
- $this->assertSame($repository, $repository->set(1));
- $this->assertSame(1, $repository->get());
- }
-
- public function testZeroIsNotEmpty(): void
- {
- $repository = new IntegerRepository(0);
-
- $this->assertFalse($repository->isEmpty());
- $this->assertTrue($repository->isNotEmpty());
- }
-
- public function testRepositoryIsConditionable(): void
- {
- $repository = (new IntegerRepository)
- ->when(true, fn (IntegerRepository $repository) => $repository->set(1));
-
- $this->assertSame(1, $repository->get());
- }
-}
diff --git a/tests/Saloon/Unit/ArrayStoreTest.php b/tests/Saloon/Unit/ArrayStoreTest.php
new file mode 100644
index 0000000000..d716d991f3
--- /dev/null
+++ b/tests/Saloon/Unit/ArrayStoreTest.php
@@ -0,0 +1,93 @@
+assertSame([], $store->all());
+ }
+
+ public function testYouCanSetIt(): void
+ {
+ $store = new ArrayRepository(['name' => 'Gareth', 'superhero' => 'Iron Man']);
+
+ $store->set(['name' => 'Sam']);
+
+ $this->assertSame(['name' => 'Sam'], $store->all());
+ }
+
+ public function testYouCanAddAnItem(): void
+ {
+ $store = new ArrayRepository;
+ $store->add('name', 'Sam');
+ $store->add('superhero', fn (): string => 'Iron Man');
+
+ $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $store->all());
+ }
+
+ public function testYouCanConditionallyAddItemsToTheArrayStore(): void
+ {
+ $store = new ArrayRepository;
+
+ $store->when(true, fn (ArrayRepository $store): ArrayRepository => $store->add('name', 'Gareth'));
+ $store->when(false, fn (ArrayRepository $store): ArrayRepository => $store->add('name', 'Sam'));
+ $store->when(true, fn (ArrayRepository $store): ArrayRepository => $store->add('sidekick', 'Mantas'));
+ $store->when(false, fn (ArrayRepository $store): ArrayRepository => $store->add('sidekick', 'Teo'));
+
+ $this->assertSame(['name' => 'Gareth', 'sidekick' => 'Mantas'], $store->all());
+ }
+
+ public function testYouCanDeleteAnItem(): void
+ {
+ $store = new ArrayRepository(['name' => 'Sam']);
+ $store->remove('name');
+
+ $this->assertSame([], $store->all());
+ }
+
+ public function testYouCanGetAnItem(): void
+ {
+ $store = new ArrayRepository(['name' => 'Sam']);
+
+ $this->assertSame('Sam', $store->get('name'));
+ $this->assertSame('fallback', $store->get('missing', 'fallback'));
+ }
+
+ public function testYouCanGetAllItems(): void
+ {
+ $store = new ArrayRepository(['name' => 'Sam', 'superhero' => 'Iron Man']);
+
+ $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $store->all());
+ }
+
+ public function testYouCanMergeItemsTogetherIntoTheContentStore(): void
+ {
+ $store = new ArrayRepository(['name' => 'Sam', 'superhero' => 'Iron Man']);
+
+ $store->merge(['sidekick' => 'Gareth'], ['superhero' => 'Black Widow']);
+
+ $this->assertSame(['name' => 'Sam', 'superhero' => 'Black Widow', 'sidekick' => 'Gareth'], $store->all());
+ }
+
+ public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void
+ {
+ $store = new ArrayRepository;
+
+ $this->assertTrue($store->isEmpty());
+ $this->assertFalse($store->isNotEmpty());
+
+ $store->add('name', 'Sam');
+
+ $this->assertFalse($store->isEmpty());
+ $this->assertTrue($store->isNotEmpty());
+ }
+}
diff --git a/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php b/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php
new file mode 100644
index 0000000000..4d313a6e7a
--- /dev/null
+++ b/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php
@@ -0,0 +1,208 @@
+captureOptions();
+
+ // Hypervel's defaults come from the Saloon HTTP connection rather than the plugin.
+ (new TestConnector)->send(UserRequest::make());
+
+ $this->assertSame(10, $options->value['connect_timeout']);
+ $this->assertSame(30, $options->value['timeout']);
+ }
+
+ public function testARequestCanSetATimeoutAndConnectTimeout(): void
+ {
+ $request = new TimeoutRequest;
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $options = $pendingRequest->options();
+
+ $this->assertSame(1.0, $options['connect_timeout']);
+ $this->assertSame(2.0, $options['timeout']);
+ }
+
+ public function testAConnectorIsGivenADefaultTimeoutAndConnectTimeout(): void
+ {
+ $connector = new TimeoutConnector;
+
+ $pendingRequest = $connector
+ ->send(new UserRequest, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $options = $pendingRequest->options();
+
+ $this->assertSame(10.0, $options['connect_timeout']);
+ $this->assertSame(5.0, $options['timeout']);
+
+ $transportOptions = $this->captureOptions();
+
+ $connector->send(new UserRequest);
+
+ $this->assertSame(10.0, $transportOptions->value['connect_timeout']);
+ $this->assertSame(5.0, $transportOptions->value['timeout']);
+ }
+
+ public function testRequestOptionsOverrideTheConnectorTimeouts(): void
+ {
+ $request = (new UserRequest)->timeout(1)->connectTimeout(0);
+
+ $options = (new TimeoutConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest()
+ ->options();
+
+ $this->assertSame(0, $options['connect_timeout']);
+ $this->assertSame(1, $options['timeout']);
+ }
+
+ public function testRequestOptionsOverrideTheRequestTimeouts(): void
+ {
+ $options = (new TestConnector)
+ ->send((new TimeoutRequest)->timeout(0), new MockClient([MockResponse::make()]))
+ ->pendingRequest()
+ ->options();
+
+ $this->assertSame(1.0, $options['connect_timeout']);
+ $this->assertSame(0, $options['timeout']);
+ }
+
+ public function testRequestTimeoutsOverrideTheConnectorTimeouts(): void
+ {
+ $options = (new TimeoutConnector)
+ ->send(new TimeoutRequest, new MockClient([MockResponse::make()]))
+ ->pendingRequest()
+ ->options();
+
+ $this->assertSame(1.0, $options['connect_timeout']);
+ $this->assertSame(2.0, $options['timeout']);
+ }
+
+ public function testConnectorOptionsOverrideOnlyTheConnectorTimeouts(): void
+ {
+ $connector = new ConfiguredTimeoutConnectorStub;
+
+ $connectorOptions = $connector
+ ->send(new UserRequest, new MockClient([MockResponse::make()]))
+ ->pendingRequest()
+ ->options();
+ $requestOptions = $connector
+ ->send(new TimeoutRequest, new MockClient([MockResponse::make()]))
+ ->pendingRequest()
+ ->options();
+
+ $this->assertSame(20, $connectorOptions['timeout']);
+ $this->assertSame(2.0, $requestOptions['timeout']);
+ }
+
+ public function testUndeclaredTimeoutsKeepTheHttpConnectionDefaults(): void
+ {
+ $this->app->make(Factory::class)->registerConnection('slow', ['connect_timeout' => 15, 'timeout' => 90]);
+ $options = $this->captureOptions();
+
+ $pendingRequest = (new ConnectionTimeoutConnectorStub)->send(new UserRequest)->pendingRequest();
+
+ $this->assertSame([], $pendingRequest->options());
+ $this->assertSame(15, $options->value['connect_timeout']);
+ $this->assertSame(90, $options->value['timeout']);
+ }
+
+ public function testTheTimeoutGettersMayBeOverridden(): void
+ {
+ $this->app->make(Factory::class)->registerConnection('slow', ['connect_timeout' => 15, 'timeout' => 90]);
+ $options = $this->captureOptions();
+
+ (new OverriddenTimeoutConnectorStub)->send(new UserRequest);
+
+ $this->assertSame(15, $options->value['connect_timeout']);
+ $this->assertSame(12.5, $options->value['timeout']);
+ }
+
+ /**
+ * Capture the transport options of the next faked request.
+ *
+ * @return object{value: array}
+ */
+ protected function captureOptions(): object
+ {
+ $captured = new class {
+ public array $value = [];
+ };
+
+ Http::fake(function (HttpRequest $request, array $options) use ($captured): PromiseInterface {
+ $captured->value = $options;
+
+ return Http::response();
+ });
+
+ return $captured;
+ }
+}
+
+class ConnectionTimeoutConnectorStub extends TestConnector
+{
+ use HasTimeout;
+
+ /**
+ * Resolve the HTTP connection used by this connector.
+ */
+ public function resolveHttpConnection(): ?string
+ {
+ return 'slow';
+ }
+}
+
+class OverriddenTimeoutConnectorStub extends ConnectionTimeoutConnectorStub
+{
+ /**
+ * Get the request timeout.
+ */
+ public function getRequestTimeout(): ?float
+ {
+ return 12.5;
+ }
+}
+
+class ConfiguredTimeoutConnectorStub extends TimeoutConnector
+{
+ /**
+ * Resolve the default request options.
+ *
+ * @return array
+ */
+ protected function defaultOptions(): array
+ {
+ return ['timeout' => 20];
+ }
+}
diff --git a/tests/Saloon/Unit/QueryParameterTest.php b/tests/Saloon/Unit/QueryParameterTest.php
new file mode 100644
index 0000000000..3cde264ef7
--- /dev/null
+++ b/tests/Saloon/Unit/QueryParameterTest.php
@@ -0,0 +1,121 @@
+withQueryParameters(['sort' => '-created_at']);
+
+ $response = $this->send(new TestConnector, $request);
+ $query = $response->pendingRequest()->queryParameters();
+
+ $this->assertSame(100, $query['per_page']); // Test Default
+ $this->assertSame('-created_at', $query['sort']); // Test Adding Data After
+ $this->assertSame('per_page=100&sort=-created_at', $response->toPsrRequest()->getUri()->getQuery());
+ }
+
+ public function testIfSetQueryIsUsedAllOtherDefaultQueryParamsWontBeIncluded(): void
+ {
+ $request = new QueryParameterConnectorRequest;
+
+ // Upstream's set() replaces every parameter; removing the defaults before adding is the equivalent.
+ $request->withoutQueryParameters(array_keys($request->queryParameters()))->withQueryParameters([
+ 'sort' => 'nickname',
+ ]);
+
+ $query = $this->send(new TestConnector, $request)->pendingRequest()->queryParameters();
+
+ $this->assertSame(['sort' => 'nickname'], $query);
+ }
+
+ public function testAConnectorCanHaveQueryThatIsSet(): void
+ {
+ $request = new QueryParameterConnectorRequest;
+
+ $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters();
+
+ $this->assertSame('first_name', $query['sort']); // Added by connector
+ $this->assertSame('user', $query['include']); // Added by request
+ }
+
+ public function testARequestQueryParameterCanOverwriteAConnectorsParameter(): void
+ {
+ $request = new OverwrittenQueryParameterConnectorRequest;
+
+ // Upstream sends this through a connector without query parameters, so nothing is overwritten.
+ $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters();
+
+ $this->assertSame(['sort' => 'date_of_birth'], $query);
+ }
+
+ public function testManuallyOverwritingQueryParameterInRuntimeCanOverwriteConnectorParameter(): void
+ {
+ $request = new QueryParameterConnectorBlankRequest;
+
+ $request->withQueryParameters(['sort' => 'custom_field']);
+
+ // Upstream sends this through a connector without query parameters, so nothing is overwritten.
+ $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters();
+
+ $this->assertSame(['sort' => 'custom_field'], $query);
+ }
+
+ public function testManuallyOverwritingQueryParameterInRuntimeCanOverwriteRequestParameter(): void
+ {
+ $request = new QueryParameterRequest;
+
+ $request->withQueryParameters(['per_page' => 500]);
+
+ $query = $this->send(new TestConnector, $request)->pendingRequest()->queryParameters();
+
+ $this->assertSame(['per_page' => 500], $query);
+ }
+
+ public function testWhenNotSendingQueryParametersTheQueryOptionIsNotSet(): void
+ {
+ $request = new UserRequest;
+
+ $response = $this->send(new TestConnector, $request);
+
+ $this->assertSame([], $response->pendingRequest()->queryParameters());
+ $this->assertSame('', $response->toPsrRequest()->getUri()->getQuery());
+ }
+
+ /**
+ * Send a request with a mocked response.
+ */
+ protected function send(Connector $connector, Request $request): Response
+ {
+ return $connector->send($request, new MockClient([MockResponse::make()]));
+ }
+}
diff --git a/tests/Saloon/Http/RequestOptionValidatorTest.php b/tests/Saloon/Unit/RequestOptionValidatorTest.php
similarity index 97%
rename from tests/Saloon/Http/RequestOptionValidatorTest.php
rename to tests/Saloon/Unit/RequestOptionValidatorTest.php
index 671aef3fb7..9055c97d65 100644
--- a/tests/Saloon/Http/RequestOptionValidatorTest.php
+++ b/tests/Saloon/Unit/RequestOptionValidatorTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use Hypervel\Saloon\Exceptions\PendingRequestException;
use Hypervel\Saloon\Http\RequestOptionValidator;
diff --git a/tests/Saloon/Unit/RequestProperties/ConfigTest.php b/tests/Saloon/Unit/RequestProperties/ConfigTest.php
new file mode 100644
index 0000000000..561d1cdda9
--- /dev/null
+++ b/tests/Saloon/Unit/RequestProperties/ConfigTest.php
@@ -0,0 +1,151 @@
+assertSame(['debug' => false], $request->options());
+ }
+
+ public function testConfigCanBeManagedOnARequest(): void
+ {
+ $request = new ConfigRequest;
+
+ $this->assertSame($request, $request->withOptions(['timeout' => 60]));
+ $this->assertSame($request, $request->withOptions(['name' => 'Sam', 'category' => 'Cowboy', 'connect_timeout' => 200]));
+ $this->assertSame($request, $request->withoutOptions('category'));
+
+ $this->assertEquals([
+ 'timeout' => 60,
+ 'name' => 'Sam',
+ 'connect_timeout' => 200,
+ 'debug' => false,
+ ], $request->options());
+ $this->assertSame(60, $request->options()['timeout']);
+
+ // Options have no replace-all method, so upstream's set() becomes removing every option and adding the new one.
+ $request->withoutOptions(array_keys($request->options()))->withOptions(['debug' => true]);
+
+ $this->assertSame(['debug' => true], $request->options());
+ }
+
+ public function testConfigCanBeManagedOnAConnector(): void
+ {
+ // Connectors are read-only and may be shared between coroutines, so their default options are managed on
+ // the pending request that merges them.
+ $connector = new ConfigConnector;
+ $pendingRequest = $this->pendingRequest($connector, new UserRequest);
+
+ $pendingRequest->withOptions(['timeout' => 60]);
+ $pendingRequest->withOptions(['name' => 'Sam', 'category' => 'Cowboy', 'connect_timeout' => 200]);
+ $pendingRequest->withoutOptions('category');
+
+ $this->assertEquals([
+ 'timeout' => 60,
+ 'name' => 'Sam',
+ 'connect_timeout' => 200,
+ 'debug' => false,
+ ], $pendingRequest->options());
+ $this->assertSame(60, $pendingRequest->options()['timeout']);
+
+ $pendingRequest->withoutOptions(array_keys($pendingRequest->options()))->withOptions(['debug' => true]);
+
+ $this->assertSame(['debug' => true], $pendingRequest->options());
+ $this->assertSame(['debug' => false], $connector->options());
+ }
+
+ public function testOptionsAreMergedRecursively(): void
+ {
+ $request = (new ConfigRequest)
+ ->withOptions(['allow_redirects' => ['max' => 3]])
+ ->withOptions(['allow_redirects' => ['strict' => true]]);
+
+ $this->assertSame(['max' => 3, 'strict' => true], $request->options()['allow_redirects']);
+ }
+
+ public function testMaxRedirectsPreservesArraySettings(): void
+ {
+ $request = $this->request()
+ ->withOptions(['allow_redirects' => ['strict' => true]])
+ ->maxRedirects(3);
+
+ $this->assertSame(['strict' => true, 'max' => 3], $request->options()['allow_redirects']);
+ }
+
+ public function testMaxRedirectsReenablesRedirectsWithoutDeprecation(): void
+ {
+ set_error_handler(static function (int $severity, string $message, string $file, int $line): never {
+ throw new ErrorException($message, 0, $severity, $file, $line);
+ }, E_DEPRECATED);
+
+ try {
+ $request = $this->request()
+ ->withoutRedirecting()
+ ->maxRedirects(3);
+
+ $this->assertSame(['max' => 3], $request->options()['allow_redirects']);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ public function testWithoutRedirectingDisablesAConfiguredRedirectLimit(): void
+ {
+ $request = $this->request()
+ ->maxRedirects(3)
+ ->withoutRedirecting();
+
+ $this->assertFalse($request->options()['allow_redirects']);
+ }
+
+ public function testMaxRedirectsReplacesTheBooleanEnabledForm(): void
+ {
+ $request = $this->request()
+ ->withOptions(['allow_redirects' => true])
+ ->maxRedirects(3);
+
+ $this->assertSame(['max' => 3], $request->options()['allow_redirects']);
+ }
+
+ /**
+ * Create an object with operation-owned request options.
+ */
+ protected function request(): object
+ {
+ return new class {
+ use HasOptions;
+ };
+ }
+
+ /**
+ * Create a pending request with isolated framework dependencies.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ $connector,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
diff --git a/tests/Saloon/Unit/RequestProperties/HeadersTest.php b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
new file mode 100644
index 0000000000..46ead57b90
--- /dev/null
+++ b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
@@ -0,0 +1,129 @@
+assertSame(['X-Custom-Header' => 'Howdy'], $request->headers());
+ }
+
+ public function testHeadersCanBeManagedOnARequest(): void
+ {
+ $request = new HeaderRequest;
+
+ $this->assertSame($request, $request->withHeader('Content-Type', 'custom/saloon'));
+
+ // Upstream's merge() replaces existing values; withHeaders() appends, so replaceHeaders() is the equivalent.
+ $this->assertSame($request, $request->replaceHeaders([
+ 'X-Merge-A' => 'Hello',
+ 'Complex' => ['A', 'B'],
+ 'X-Merge-B' => 'Goodbye',
+ 'Content-Type' => 'overwritten',
+ ]));
+ $this->assertSame($request, $request->withoutHeader('X-Merge-B'));
+
+ $this->assertEquals([
+ 'X-Custom-Header' => 'Howdy',
+ 'Content-Type' => 'overwritten',
+ 'X-Merge-A' => 'Hello',
+ 'Complex' => ['A', 'B'],
+ ], $request->headers());
+ $this->assertSame('Howdy', $request->headers()['X-Custom-Header']);
+ $this->assertSame(['A', 'B'], $request->headers()['Complex']);
+
+ // Upstream's set() replaces every header; there is no replace-all method, so remove the headers before adding.
+ $request->withoutHeaders(array_keys($request->headers()))->withHeader('X-Different', 'Yo');
+
+ $this->assertSame(['X-Different' => 'Yo'], $request->headers());
+ $this->assertNotEmpty($request->headers());
+ }
+
+ public function testHeadersCanBeManagedOnAConnector(): void
+ {
+ // Connectors are read-only and may be shared between coroutines, so their default headers are managed on
+ // the pending request that merges them.
+ $connector = new HeaderConnector;
+ $pendingRequest = $this->pendingRequest($connector, new UserRequest);
+
+ $pendingRequest->withHeader('Content-Type', 'custom/saloon');
+ $pendingRequest->replaceHeaders([
+ 'X-Merge-A' => 'Hello',
+ 'Complex' => ['A', 'B'],
+ 'X-Merge-B' => 'Goodbye',
+ 'Content-Type' => 'overwritten',
+ ]);
+ $pendingRequest->withoutHeader('X-Merge-B');
+
+ $this->assertEquals([
+ 'X-Connector-Header' => 'Sam',
+ 'Content-Type' => 'overwritten',
+ 'X-Merge-A' => 'Hello',
+ 'Complex' => ['A', 'B'],
+ ], $pendingRequest->headers());
+ $this->assertSame('Sam', $pendingRequest->headers()['X-Connector-Header']);
+ $this->assertSame(['A', 'B'], $pendingRequest->headers()['Complex']);
+
+ $pendingRequest->withoutHeaders(array_keys($pendingRequest->headers()))->withHeader('X-Different', 'Yo');
+
+ $this->assertSame(['X-Different' => 'Yo'], $pendingRequest->headers());
+ $this->assertSame(['X-Connector-Header' => 'Sam'], $connector->headers());
+ }
+
+ public function testRemovingAHeaderFromARequestDoesNotRemoveConnectorDefaults(): void
+ {
+ $request = (new HeaderRequest)->withoutHeader('X-Connector-Header');
+ $pendingRequest = $this->pendingRequest(new HeaderConnector, $request);
+
+ $this->assertSame('Sam', $pendingRequest->headers()['X-Connector-Header']);
+
+ $pendingRequest->withoutHeader('x-connector-header');
+
+ $this->assertSame(['X-Custom-Header' => 'Howdy'], $pendingRequest->headers());
+ }
+
+ public function testAddedHeadersAppendWhileReplacementsAndRemovalsIgnoreCase(): void
+ {
+ $request = (new HeaderRequest)->withHeader('X-Custom-Header', 'Partner');
+
+ $this->assertSame(['X-Custom-Header' => ['Howdy', 'Partner']], $request->headers());
+
+ $request->replaceHeaders(['x-custom-header' => 'Yeehaw']);
+
+ $this->assertSame(['x-custom-header' => 'Yeehaw'], $request->headers());
+
+ $request->withoutHeaders(['X-CUSTOM-HEADER']);
+
+ $this->assertSame([], $request->headers());
+ $this->assertFalse($request->hasHeader('X-Custom-Header'));
+ }
+
+ /**
+ * Create a pending request with isolated framework dependencies.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ $connector,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
diff --git a/tests/Saloon/Unit/RequestProperties/QueryTest.php b/tests/Saloon/Unit/RequestProperties/QueryTest.php
new file mode 100644
index 0000000000..cd0b5395a8
--- /dev/null
+++ b/tests/Saloon/Unit/RequestProperties/QueryTest.php
@@ -0,0 +1,133 @@
+assertSame(['per_page' => 100], $request->queryParameters());
+ }
+
+ public function testQueryParametersCanBeManagedOnARequest(): void
+ {
+ $request = new QueryParameterRequest;
+
+ $this->assertSame($request, $request->withQueryParameters(['page' => 1]));
+ $this->assertSame($request, $request->withQueryParameters(['search' => 'Sam', 'category' => 'Cowboy', 'per_page' => 200]));
+ $this->assertSame($request, $request->withoutQueryParameters('category'));
+
+ $this->assertSame([
+ 'per_page' => 200,
+ 'page' => 1,
+ 'search' => 'Sam',
+ ], $request->queryParameters());
+ $this->assertSame(1, $request->queryParameters()['page']);
+
+ // Upstream's set() replaces every parameter; there is no replace-all method, so remove the parameters first.
+ $request->withoutQueryParameters(array_keys($request->queryParameters()))->withQueryParameters(['debug' => true]);
+
+ $this->assertSame(['debug' => true], $request->queryParameters());
+ }
+
+ public function testQueryParametersCanBeManagedOnAConnector(): void
+ {
+ // Connectors are read-only and may be shared between coroutines, so their default query parameters are
+ // managed on the pending request that merges them.
+ $connector = new QueryParameterConnector;
+ $pendingRequest = $this->pendingRequest($connector, new QueryParameterConnectorBlankRequest);
+
+ $pendingRequest->withQueryParameters(['page' => 1]);
+ $pendingRequest->withQueryParameters(['search' => 'Sam', 'category' => 'Cowboy', 'sort' => 'last_name']);
+ $pendingRequest->withoutQueryParameters('category');
+
+ $this->assertSame([
+ 'sort' => 'last_name',
+ 'page' => 1,
+ 'search' => 'Sam',
+ ], $pendingRequest->queryParameters());
+ $this->assertSame(1, $pendingRequest->queryParameters()['page']);
+
+ $pendingRequest
+ ->withoutQueryParameters(array_keys($pendingRequest->queryParameters()))
+ ->withQueryParameters(['debug' => true]);
+
+ $this->assertSame(['debug' => true], $pendingRequest->queryParameters());
+ $this->assertSame(['sort' => 'first_name'], $connector->queryParameters());
+ }
+
+ public function testRemovingAQueryParameterFromARequestDoesNotRemoveConnectorDefaults(): void
+ {
+ $request = (new QueryParameterConnectorRequest)->withoutQueryParameters('sort');
+ $pendingRequest = $this->pendingRequest(new QueryParameterConnector, $request);
+
+ $this->assertSame(['sort' => 'first_name', 'include' => 'user'], $pendingRequest->queryParameters());
+
+ $pendingRequest->withoutQueryParameters(['sort']);
+
+ $this->assertSame(['include' => 'user'], $pendingRequest->queryParameters());
+ }
+
+ public function testRemovingAQueryParameterInvalidatesTheFinalizedUri(): void
+ {
+ $pendingRequest = $this->pendingRequest(new QueryParameterConnector, new QueryParameterConnectorRequest)
+ ->finalizeUri();
+
+ $this->assertSame('sort=first_name&include=user', $pendingRequest->uri()->getQuery());
+
+ $pendingRequest->withoutQueryParameters('sort');
+
+ $this->assertSame('include=user', $pendingRequest->uri()->getQuery());
+
+ $pendingRequest->finalizeUri()->withQueryParameters(['sort' => 'nickname']);
+
+ $this->assertSame('include=user&sort=nickname', $pendingRequest->uri()->getQuery());
+ }
+
+ public function testRemovingAQueryParameterKeepsValuesWrittenInTheEndpointOrQueryString(): void
+ {
+ $pendingRequest = $this->pendingRequest(
+ new QueryParameterConnector,
+ new QueryParameterRequest('/user?sort=endpoint'),
+ );
+
+ $this->assertSame('sort=first_name&per_page=100', $pendingRequest->uri()->getQuery());
+
+ $pendingRequest->withoutQueryParameters('sort');
+
+ $this->assertSame('sort=endpoint&per_page=100', $pendingRequest->uri()->getQuery());
+
+ $pendingRequest->withQueryString('sort=raw')->withoutQueryParameters('per_page');
+
+ $this->assertSame('sort=raw', $pendingRequest->uri()->getQuery());
+ }
+
+ /**
+ * Create a pending request with isolated framework dependencies.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ $connector,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
diff --git a/tests/Saloon/Unit/RequestPropertiesTest.php b/tests/Saloon/Unit/RequestPropertiesTest.php
new file mode 100644
index 0000000000..7e9834975a
--- /dev/null
+++ b/tests/Saloon/Unit/RequestPropertiesTest.php
@@ -0,0 +1,33 @@
+assertSame([], $request->headers());
+ $this->assertSame([], $request->queryParameters());
+ $this->assertSame([], $request->options());
+ $this->assertInstanceOf(MiddlewarePipeline::class, $request->middleware());
+ }
+
+ public function testAllOfTheRequestPropertiesCanHaveDefaultProperties(): void
+ {
+ $request = new DefaultPropertiesRequest;
+
+ $this->assertSame(['X-Favourite-Artist' => 'Luke Combs'], $request->headers());
+ $this->assertSame(['format' => 'json'], $request->queryParameters());
+ $this->assertSame(['debug' => true], $request->options());
+ }
+}
From c34e50128d31174068a699d19b1c918416d6de3d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 16:41:05 +0000
Subject: [PATCH 02/26] Reconcile Saloon body tests with upstream
Ports Saloon's body repository, body trait, multipart value and
serialization tests (saloonphp/saloon v4 at 2f45e526f9d1) with their
fixtures, merging the existing Hypervel coverage into the upstream-named
files under tests/Saloon/{Unit,Feature,Fixtures}. The two cases that call
upstream's live test API run against a new /mixed-multipart route on the
engine test server under tests/Integration/Saloon. This includes the
current HasMultipartBodyTest from saloonphp/saloon#561; that PR's sender,
retry and dependency changes remain for later slices.
Every request has the body methods, so there is no HasBody contract or
ChecksForHasBody check. ArrayBodyRepository stays abstract, because
upstream's concrete class throws when converted to a stream, and
MultipartBodyRepository::boundary() is getBoundary() again, as upstream.
Defects fixed:
- attach() read the body property directly, so attaching to a request
whose default multipart body was not yet loaded discarded it.
asMultipart() also discarded attached values; both now keep an
existing multipart body.
- A multipart body kept any existing Content-Type, so a JSON body
trait's default, an earlier asJson() or a connector's JSON default
header sent the body without its boundary. A content type without a
boundary is now replaced, in prepared requests and pre-send PSR
snapshots; one that declares a boundary is kept. The HTTP client had
the same defect for Http::asJson()->attach() and connection default
headers, and now removes such a header from multipart requests so
Guzzle adds the boundary.
- Guzzle streams close their resource when destroyed. A PSR snapshot
taken in request middleware or a SendingSaloonRequest listener
therefore closed a resource body or attached file before sending.
StreamBodyRepository now wraps a resource once and shares the wrapper
across copies; MultipartValue caches its wrapper at first
serialization.
- List-valued multipart part headers, which SDK-generated forms pass,
failed in Guzzle; they are joined like repeated HTTP fields.
- Two upstream tests checked all() instead of get(), or never called the
set() they described.
attach() also accepts an array of argument tuples, like Http::attach().
withData() on a multipart body appends the values as fields, like
Http::attach()->post($url, $data). For that, MultipartValue also accepts
booleans, null and arrays without a filename or headers, which Guzzle
sends as fields; upstream's invalid empty-array case is now valid.
The README records the body differences, and the guide documents the
multipart behavior, the array form of attach() and connector-owned
JSON flags.
Validation: Saloon suite (470 tests) with ParaTest, tests/Http
(1,018 tests), tests/Integration/Saloon and tests/Integration/Engine
against the engine test servers, tests/Data/Saloon; composer analyse;
php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 21 +-
src/http/src/Client/PendingRequest.php | 11 +
src/saloon/README.md | 4 +-
src/saloon/src/Data/MultipartValue.php | 47 ++-
src/saloon/src/Http/PendingRequest.php | 23 +-
.../Body/MultipartBodyRepository.php | 15 +-
.../Body/StreamBodyRepository.php | 14 +-
src/saloon/src/Traits/Body/HasBody.php | 41 ++-
tests/Http/HttpClientTest.php | 68 ++++
.../Engine/Fixtures/http_server_v2.php | 13 +-
.../Feature/Body/HasMultipartBodyTest.php | 101 ++++++
tests/Integration/Saloon/Fixtures/Howdy.txt | 1 +
.../Requests/MixedMultipartRequest.php | 27 ++
tests/Saloon/Feature/Body/HasFormBodyTest.php | 69 ++++
tests/Saloon/Feature/Body/HasJsonBodyTest.php | 209 ++++++++++++
.../Feature/Body/HasMultipartBodyTest.php | 318 ++++++++++++++++++
.../Saloon/Feature/Body/HasStreamBodyTest.php | 82 +++++
.../Saloon/Feature/Body/HasStringBodyTest.php | 64 ++++
tests/Saloon/Feature/Body/HasXmlBodyTest.php | 66 ++++
.../Connectors/HasJsonBodyConnector.php | 55 +++
.../Connectors/HasMultipartBodyConnector.php | 56 +++
.../Fixtures/Connectors/TestConnector.php | 2 -
.../Fixtures/Requests/HasFormBodyRequest.php | 40 +++
.../Fixtures/Requests/HasJsonBodyRequest.php | 40 +++
.../Requests/HasMultipartBodyRequest.php | 40 +++
.../Requests/HasStreamBodyRequest.php | 43 +++
.../Requests/HasStringBodyRequest.php | 35 ++
.../Fixtures/Requests/HasXmlBodyRequest.php | 35 ++
tests/Saloon/Http/BodyTraitsTest.php | 252 --------------
.../Body/ArrayBodyRepositoryTest.php | 89 -----
.../Body/MultipartBodyRepositoryTest.php | 106 ------
.../Body/StreamBodyRepositoryTest.php | 92 -----
.../Unit/Body/ArrayBodyRepositoryTest.php | 177 ++++++++++
.../Unit/Body/MultipartBodyRepositoryTest.php | 213 ++++++++++++
.../Body/SerializationTest.php | 40 ++-
.../Unit/Body/StreamBodyRepositoryTest.php | 207 ++++++++++++
.../Body/StringBodyRepositoryTest.php | 48 ++-
tests/Saloon/Unit/BodyTraitTest.php | 189 +++++++++++
tests/Saloon/Unit/MultipartValueTest.php | 79 +++++
40 files changed, 2431 insertions(+), 603 deletions(-)
create mode 100644 tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php
create mode 100644 tests/Integration/Saloon/Fixtures/Howdy.txt
create mode 100644 tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php
create mode 100644 tests/Saloon/Feature/Body/HasFormBodyTest.php
create mode 100644 tests/Saloon/Feature/Body/HasJsonBodyTest.php
create mode 100644 tests/Saloon/Feature/Body/HasMultipartBodyTest.php
create mode 100644 tests/Saloon/Feature/Body/HasStreamBodyTest.php
create mode 100644 tests/Saloon/Feature/Body/HasStringBodyTest.php
create mode 100644 tests/Saloon/Feature/Body/HasXmlBodyTest.php
create mode 100644 tests/Saloon/Fixtures/Connectors/HasJsonBodyConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasFormBodyRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasStringBodyRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasXmlBodyRequest.php
delete mode 100644 tests/Saloon/Http/BodyTraitsTest.php
delete mode 100644 tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php
delete mode 100644 tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php
delete mode 100644 tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php
create mode 100644 tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
create mode 100644 tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
rename tests/Saloon/{Repositories => Unit}/Body/SerializationTest.php (73%)
create mode 100644 tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php
rename tests/Saloon/{Repositories => Unit}/Body/StringBodyRepositoryTest.php (59%)
create mode 100644 tests/Saloon/Unit/BodyTraitTest.php
create mode 100644 tests/Saloon/Unit/MultipartValueTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 1a9c5fb2fb..8711b7f4d3 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,7 +182,7 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index aa117cb513..29811dea60 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -621,7 +621,7 @@ class CreateIssue extends Request
}
```
-The JSON, form, XML, and multipart traits supply the appropriate content type unless you already defined one. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format; request body values take precedence over connector values.
+The JSON, form, and XML traits supply the appropriate content type unless you already defined one. A multipart body always receives `multipart/form-data` with its boundary unless the content type you define declares a boundary. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format; request body values take precedence over connector values.
When both a connector and request define a body, their body repositories must be the same type. For example, a request using `HasFormBody` cannot be sent through a connector using `HasJsonBody`. Saloon throws a `PendingRequestException` when the body types do not match.
@@ -634,12 +634,14 @@ $request->withBody($stream, 'application/octet-stream');
$request->withBody($stream, null);
```
-The `withData` method merges structured values into the current JSON or form body. If the request does not yet have a structured body, the method creates a JSON body:
+The `withData` method merges structured values into the current JSON or form body. If the request has no JSON, form, or multipart body, the method creates a JSON body:
```php
$request->withData(['active' => true]);
```
+On a multipart body, `withData` adds each value as a field after the existing parts. Fields are appended, so repeating a name adds another field instead of replacing the first. Nested arrays are sent as bracketed field names such as `roles[0]`, `true` is sent as `1`, and `false` and `null` are sent as empty fields.
+
Laravel `Arrayable` objects, `JsonSerializable` objects, and stringable values are normalized recursively in JSON, form, and query data.
JSON bodies use `JSON_THROW_ON_ERROR` by default. To use other encoding flags, return a configured `JsonBodyRepository` from `defaultBodyRepository`:
@@ -655,7 +657,7 @@ protected function defaultBodyRepository(): ?BodyRepository
}
```
-The request may still use `HasJsonBody` so Saloon supplies the JSON content type. Invalid JSON values throw a `BodyException` instead of being sent as an empty body.
+The request may still use `HasJsonBody` so Saloon supplies the JSON content type. When the connector also defines a JSON body, the request's values are merged into the connector's repository, so the connector's flags apply. Invalid JSON values throw a `BodyException` instead of being sent as an empty body.
### Multipart Requests
@@ -671,6 +673,17 @@ $request
->attach('description', 'Quarterly report');
```
+You may also pass an array of parts, each containing the same arguments:
+
+```php
+$request->attach([
+ ['document', fopen($path, 'rb'), 'report.pdf', ['Content-Type' => 'application/pdf']],
+ ['description', 'Quarterly report'],
+]);
+```
+
+When a request has a body in another format, `attach` replaces it with a multipart body. The `asMultipart` method selects a multipart body without adding a value. Neither method discards values that a multipart body already contains.
+
Saloon preserves multipart streams, filenames, headers, order, and the generated boundary. Caller-owned streams are not buffered automatically.
@@ -736,7 +749,7 @@ class NdjsonBodyRepository implements MergeableBody
}
```
-Implement `MergeableBody` when connector values should be merged with request values. A repository that implements only `BodyRepository` is replaced as a complete value instead. The `withData` method is reserved for the built-in JSON and form repositories; calling it on a custom repository selects a JSON body.
+Implement `MergeableBody` when connector values should be merged with request values. A repository that implements only `BodyRepository` is replaced as a complete value instead. The `withData` method is reserved for the built-in JSON, form, and multipart repositories; calling it on a custom repository selects a JSON body.
Return the repository from the request or connector's `defaultBodyRepository` method and set the appropriate content type during `boot`:
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 77cd7c30dc..0eb4120273 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -1321,6 +1321,17 @@ protected function sendRequest(string $method, string $url, array $options = [])
$mergedOptions = $this->normalizeRequestOptions($this->mergeOptions($requestOptions, $options));
+ // Guzzle adds the multipart Content-Type, with the body's boundary, only when none is set. A content type
+ // without a boundary, such as one from asJson() or a connection default, cannot describe the body.
+ if (isset($mergedOptions['multipart']) && is_array($mergedOptions['headers'] ?? null)) {
+ $mergedOptions['headers'] = array_filter(
+ $mergedOptions['headers'],
+ static fn (array|string $value, string $name): bool => strcasecmp($name, 'Content-Type') !== 0
+ || stripos(implode(', ', (array) $value), 'boundary=') !== false,
+ ARRAY_FILTER_USE_BOTH,
+ );
+ }
+
return $this->buildClient()->{$clientMethod}($method, $url, $mergedOptions);
}
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 0933f3cbe9..94f570c116 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -6,7 +6,9 @@ Documentation: https://hypervel.org/docs/saloon
Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`.
-Connectors are read-only. Their headers, query parameters, options, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware.
+Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware.
+
+Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own.
The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options).
diff --git a/src/saloon/src/Data/MultipartValue.php b/src/saloon/src/Data/MultipartValue.php
index e59dc7e6ce..224912c1a9 100644
--- a/src/saloon/src/Data/MultipartValue.php
+++ b/src/saloon/src/Data/MultipartValue.php
@@ -4,29 +4,60 @@
namespace Hypervel\Saloon\Data;
+use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;
-final readonly class MultipartValue
+final class MultipartValue
{
+ /**
+ * The stream that wraps a resource value.
+ */
+ private ?StreamInterface $resourceStream = null;
+
/**
* Create a multipart value.
*
- * @param float|int|resource|StreamInterface|string $value
+ * Guzzle expands an array value into one field per element, named with brackets such as `roles[0]`, so an array
+ * cannot have a filename or headers.
+ *
+ * @param null|array|bool|float|int|resource|StreamInterface|string $value
* @param array|string> $headers
*/
public function __construct(
- public string $name,
- public mixed $value,
- public ?string $filename = null,
- public array $headers = [],
+ public readonly string $name,
+ public readonly mixed $value,
+ public readonly ?string $filename = null,
+ public readonly array $headers = [],
) {
- if (! $value instanceof StreamInterface
+ if (is_array($value)) {
+ if ($filename !== null || $headers !== []) {
+ throw new InvalidArgumentException('An array value cannot have a filename or headers.');
+ }
+ } elseif ($value !== null
+ && ! is_bool($value)
+ && ! $value instanceof StreamInterface
&& ! is_resource($value)
&& ! is_string($value)
&& ! is_int($value)
&& (! is_float($value) || ! is_finite($value))) {
- throw new InvalidArgumentException(sprintf('The value property must be either a %s, resource, string, or finite number.', StreamInterface::class));
+ throw new InvalidArgumentException(sprintf('The value property must be either a %s, resource, string, finite number, boolean, null, or array.', StreamInterface::class));
}
}
+
+ /**
+ * Get the contents to add to a multipart stream.
+ *
+ * @return null|array|bool|float|int|StreamInterface|string
+ */
+ public function contents(): array|bool|float|int|StreamInterface|string|null
+ {
+ if (! is_resource($this->value)) {
+ return $this->value;
+ }
+
+ // A Guzzle stream closes its resource when destroyed, so every multipart stream built from this value shares
+ // one wrapper. Otherwise releasing one, such as a PSR request snapshot taken before sending, closes the file.
+ return $this->resourceStream ??= Utils::streamFor($this->value);
+ }
}
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 3a8ad3b559..ee62af66e8 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -480,6 +480,11 @@ public function toPsrRequest(): RequestInterface
$headers = $this->headers();
if (($contentType = $this->multipartContentType()) !== null) {
+ $headers = array_filter(
+ $headers,
+ static fn (string $name): bool => strcasecmp($name, 'Content-Type') !== 0,
+ ARRAY_FILTER_USE_KEY,
+ );
$headers['Content-Type'] = $contentType;
}
@@ -668,15 +673,25 @@ protected function streamOffset(?StreamInterface $stream): ?int
}
/**
- * Resolve the multipart content type when the caller did not provide one.
+ * Resolve the multipart content type unless the caller provided one with a boundary.
*/
protected function multipartContentType(): ?string
{
$repository = $this->bodyRepository();
- return $repository instanceof MultipartBodyRepository && ! $this->hasHeader('Content-Type')
- ? $repository->contentType()
- : null;
+ if (! $repository instanceof MultipartBodyRepository) {
+ return null;
+ }
+
+ // A content type without a boundary, such as a connector's JSON default or one kept from the body format
+ // the request used before attach(), cannot describe a multipart body, so it is replaced.
+ foreach (HeaderNormalizer::normalize($this->headers()) as $name => $value) {
+ if (strcasecmp($name, 'Content-Type') === 0 && stripos(implode(', ', (array) $value), 'boundary=') !== false) {
+ return null;
+ }
+ }
+
+ return $repository->contentType();
}
/**
diff --git a/src/saloon/src/Repositories/Body/MultipartBodyRepository.php b/src/saloon/src/Repositories/Body/MultipartBodyRepository.php
index 9a1de69d48..e5a5494782 100644
--- a/src/saloon/src/Repositories/Body/MultipartBodyRepository.php
+++ b/src/saloon/src/Repositories/Body/MultipartBodyRepository.php
@@ -76,7 +76,7 @@ public function merge(array ...$arrays): static
/**
* Add an element to the repository.
*
- * @param float|int|resource|StreamInterface|string $contents
+ * @param null|array|bool|float|int|resource|StreamInterface|string $contents
* @param array|string> $headers
* @return $this
*/
@@ -181,7 +181,7 @@ protected function parseMultipartArray(array $value): array
/**
* Get the multipart boundary.
*/
- public function boundary(): string
+ public function getBoundary(): string
{
return $this->boundary;
}
@@ -202,17 +202,20 @@ public function toStream(): StreamInterface
$parts = array_map(static function (MultipartValue $value): array {
$part = [
'name' => $value->name,
- 'contents' => is_int($value->value) || is_float($value->value)
- ? (string) $value->value
- : $value->value,
+ 'contents' => $value->contents(),
];
if ($value->filename !== null) {
$part['filename'] = $value->filename;
}
+ // Guzzle's multipart stream accepts one string per part header, so list values are combined the way
+ // HTTP combines repeated fields.
if ($value->headers !== []) {
- $part['headers'] = $value->headers;
+ $part['headers'] = array_map(
+ static fn (array|string $header): string => is_array($header) ? implode(', ', $header) : $header,
+ $value->headers,
+ );
}
return $part;
diff --git a/src/saloon/src/Repositories/Body/StreamBodyRepository.php b/src/saloon/src/Repositories/Body/StreamBodyRepository.php
index 8e40827d0e..94586aba22 100644
--- a/src/saloon/src/Repositories/Body/StreamBodyRepository.php
+++ b/src/saloon/src/Repositories/Body/StreamBodyRepository.php
@@ -21,6 +21,11 @@ class StreamBodyRepository implements BodyRepository
*/
protected mixed $stream = null;
+ /**
+ * The stream that wraps a resource body.
+ */
+ protected ?StreamInterface $resourceStream = null;
+
/**
* Create a stream body repository.
*
@@ -43,6 +48,13 @@ public function set(mixed $value): static
throw new InvalidArgumentException('The body value must be a resource, null, or an instance of ' . StreamInterface::class . '.');
}
+ // A Guzzle stream closes its resource when destroyed, so a resource is wrapped once, when it is supplied.
+ // Copies of the repository and every conversion then share one wrapper, which closes the resource only when
+ // nothing uses it, and setting the same resource again keeps that wrapper.
+ if ($value !== $this->stream) {
+ $this->resourceStream = is_resource($value) ? Utils::streamFor($value) : null;
+ }
+
$this->stream = $value;
return $this;
@@ -91,6 +103,6 @@ public function toStream(): StreamInterface
{
return $this->stream instanceof StreamInterface
? $this->stream
- : Utils::streamFor($this->stream);
+ : $this->resourceStream ?? Utils::streamFor();
}
}
diff --git a/src/saloon/src/Traits/Body/HasBody.php b/src/saloon/src/Traits/Body/HasBody.php
index cf55406a8c..32a51ea91a 100644
--- a/src/saloon/src/Traits/Body/HasBody.php
+++ b/src/saloon/src/Traits/Body/HasBody.php
@@ -6,6 +6,7 @@
use Hypervel\Saloon\Contracts\Body\BodyRepository;
use Hypervel\Saloon\Data\MultipartValue;
+use Hypervel\Saloon\Http\StructuredDataNormalizer;
use Hypervel\Saloon\Repositories\Body\ArrayBodyRepository;
use Hypervel\Saloon\Repositories\Body\FormBodyRepository;
use Hypervel\Saloon\Repositories\Body\JsonBodyRepository;
@@ -15,6 +16,10 @@
use Psr\Http\Message\StreamInterface;
use Stringable;
+/**
+ * Every request and pending request uses this trait, so body traits need no upstream `HasBody` contract or
+ * `ChecksForHasBody` check.
+ */
trait HasBody
{
/**
@@ -68,13 +73,15 @@ public function asForm(array $data = []): static
}
/**
- * Use a multipart request body.
+ * Use a multipart request body, keeping any existing multipart values.
*
* @return $this
*/
public function asMultipart(): static
{
- $this->bodyRepository = new MultipartBodyRepository;
+ if (! $this->bodyRepository() instanceof MultipartBodyRepository) {
+ $this->bodyRepository = new MultipartBodyRepository;
+ }
return $this;
}
@@ -82,22 +89,30 @@ public function asMultipart(): static
/**
* Attach a multipart value to the request.
*
- * @param float|int|resource|StreamInterface|string $contents
- * @param array $headers
+ * @param array>|string $name
+ * @param null|array|bool|float|int|resource|StreamInterface|string $contents
+ * @param array|string> $headers
* @return $this
*/
public function attach(
- string $name,
+ array|string $name,
mixed $contents = '',
?string $filename = null,
array $headers = [],
): static {
- $repository = $this->bodyRepository;
+ if (is_array($name)) {
+ foreach ($name as $file) {
+ $this->attach(...$file);
+ }
- if (! $repository instanceof MultipartBodyRepository) {
- $repository = $this->bodyRepository = new MultipartBodyRepository;
+ return $this;
}
+ $this->asMultipart();
+
+ /** @var MultipartBodyRepository $repository */
+ $repository = $this->bodyRepository;
+
$repository->attach(new MultipartValue($name, $contents, $filename, $headers));
return $this;
@@ -106,6 +121,8 @@ public function attach(
/**
* Merge structured values into the request body.
*
+ * A multipart body receives each value as an appended field.
+ *
* @param array $data
* @return $this
*/
@@ -113,6 +130,14 @@ public function withData(array $data): static
{
$repository = $this->bodyRepository();
+ if ($repository instanceof MultipartBodyRepository) {
+ foreach (StructuredDataNormalizer::forUrlEncoding($data) as $name => $value) {
+ $repository->add((string) $name, $value);
+ }
+
+ return $this;
+ }
+
if (! $repository instanceof ArrayBodyRepository) {
$repository = $this->bodyRepository = new JsonBodyRepository;
$this->contentType('application/json');
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index d5ec1be7a3..2eb7300b5b 100644
--- a/tests/Http/HttpClientTest.php
+++ b/tests/Http/HttpClientTest.php
@@ -1840,6 +1840,74 @@ public function testFilesCanBeAttached(): void
});
}
+ /**
+ * @param Closure(Factory): PendingRequest $pendingRequest
+ */
+ #[DataProvider('contentTypesWithoutABoundary')]
+ public function testAttachedFilesReplaceAContentTypeWithoutABoundary(Closure $pendingRequest): void
+ {
+ $sent = null;
+
+ $this->factory->fake(function (Request $request) use (&$sent): PromiseInterface {
+ $sent = $request;
+
+ return Factory::response();
+ });
+
+ $pendingRequest($this->factory)->attach('file', 'data', 'file.txt')->post('http://foo.com/file');
+
+ $contentType = $sent->header('Content-Type');
+
+ $this->assertCount(1, $contentType);
+ $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType[0]);
+ $this->assertStringStartsWith(
+ '--' . substr($contentType[0], strlen('multipart/form-data; boundary=')) . "\r\n",
+ $sent->body(),
+ );
+ }
+
+ /**
+ * Get the ways a request can carry a content type without a boundary.
+ *
+ * @return iterable
+ */
+ public static function contentTypesWithoutABoundary(): iterable
+ {
+ yield 'asJson' => [fn (Factory $factory): PendingRequest => $factory->asJson()];
+ yield 'string header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['Content-Type' => 'application/json'])];
+ yield 'lowercase list header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['content-type' => ['application/json']])];
+ yield 'stringable header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['Content-Type' => new Stringable('application/json')])];
+ yield 'connection default' => [fn (Factory $factory): PendingRequest => $factory
+ ->registerConnection('api', ['headers' => ['Content-Type' => 'application/json']])
+ ->connection('api')];
+ }
+
+ #[DataProvider('contentTypesWithABoundary')]
+ public function testAttachedFilesKeepAContentTypeWithABoundary(mixed $contentType): void
+ {
+ $this->factory->fake();
+
+ $this->factory->withHeaders(['Content-Type' => $contentType])
+ ->attach('file', 'data', 'file.txt')
+ ->post('http://foo.com/file');
+
+ $this->factory->assertSent(
+ fn (Request $request): bool => $request->header('Content-Type') === ['multipart/related; boundary=custom'],
+ );
+ }
+
+ /**
+ * Get the supported representations of a content type that declares a boundary.
+ *
+ * @return iterable
+ */
+ public static function contentTypesWithABoundary(): iterable
+ {
+ yield 'string' => ['multipart/related; boundary=custom'];
+ yield 'list' => [['multipart/related; boundary=custom']];
+ yield 'stringable' => [new Stringable('multipart/related; boundary=custom')];
+ }
+
public function testAttachPreservesEmptyContents(): void
{
$this->factory->fake();
diff --git a/tests/Integration/Engine/Fixtures/http_server_v2.php b/tests/Integration/Engine/Fixtures/http_server_v2.php
index 8124e30ffb..85f6332960 100644
--- a/tests/Integration/Engine/Fixtures/http_server_v2.php
+++ b/tests/Integration/Engine/Fixtures/http_server_v2.php
@@ -5,7 +5,7 @@
/**
* HTTP/2 test server for engine integration tests.
*
- * Listens on port 19505 and handles cookie-based test endpoints.
+ * Listens on port 19505 and handles cookie and multipart test endpoints.
* This is a simplified version that doesn't require HttpMessage classes.
*/
@@ -48,6 +48,17 @@
$response->setHeader('Content-Type', 'application/json');
$response->end(json_encode($cookies));
})(),
+ '/mixed-multipart' => (function () use ($request, $response): void {
+ // Return the parsed multipart fields and file for Saloon's multipart tests
+ $file = $request->files['file'] ?? null;
+
+ $response->setHeader('Content-Type', 'application/json');
+ $response->end(json_encode([
+ 'name' => $request->post['name'] ?? null,
+ 'fields' => $request->post ?? [],
+ 'file_contents' => $file === null ? null : file_get_contents($file['tmp_name']),
+ ]));
+ })(),
default => (function () use ($request, $response) {
$body = $request->rawContent();
$ret = 'Hello World.';
diff --git a/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php b/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php
new file mode 100644
index 0000000000..4690b5234d
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php
@@ -0,0 +1,101 @@
+setUpInteractsWithServer();
+ }
+
+ public function testCanSendARealMultipartRequestAndFilesAreSent(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $request = new MixedMultipartRequest;
+
+ $request->attach('name', 'Howdy');
+ $request->attach('file', file_get_contents(__DIR__ . '/../../Fixtures/Howdy.txt'), 'hi.txt');
+
+ $data = $connector->send($request)->json();
+
+ $this->assertSame('Howdy', $data['name']);
+ $this->assertSame('Hello World!' . PHP_EOL, $data['file_contents']);
+ }
+
+ public function testCanSendAnEmptyStringAsTheContents(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $request = new MixedMultipartRequest;
+
+ $request->attach('name', 'Howdy');
+ $request->attach('file', '', 'hi.txt');
+
+ $data = $connector->send($request)->json();
+
+ $this->assertSame('Howdy', $data['name']);
+ $this->assertSame('', $data['file_contents']);
+ }
+
+ public function testWithDataSendsFieldsAlongsideAnAttachedFile(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $request = (new MixedMultipartRequest)
+ ->attach('file', file_get_contents(__DIR__ . '/../../Fixtures/Howdy.txt'), 'hi.txt')
+ ->withData([
+ 'name' => 'Howdy',
+ 'active' => true,
+ 'inactive' => false,
+ 'nothing' => null,
+ 'roles' => ['admin', 'editor'],
+ 'profile' => ['city' => 'London'],
+ ]);
+
+ $data = $connector->send($request)->json();
+
+ $this->assertSame([
+ 'name' => 'Howdy',
+ 'active' => '1',
+ 'inactive' => '',
+ 'nothing' => '',
+ 'roles' => ['admin', 'editor'],
+ 'profile' => ['city' => 'London'],
+ ], $data['fields']);
+ $this->assertSame('Hello World!' . PHP_EOL, $data['file_contents']);
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/Fixtures/Howdy.txt b/tests/Integration/Saloon/Fixtures/Howdy.txt
new file mode 100644
index 0000000000..980a0d5f19
--- /dev/null
+++ b/tests/Integration/Saloon/Fixtures/Howdy.txt
@@ -0,0 +1 @@
+Hello World!
diff --git a/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php b/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php
new file mode 100644
index 0000000000..f7fb65d56c
--- /dev/null
+++ b/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php
@@ -0,0 +1,27 @@
+assertSame([
+ 'name' => 'Sam',
+ 'catchphrase' => 'Yeehaw!',
+ ], $request->body());
+
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']);
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasFormBodyRequest;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ $this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']);
+ });
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $this->assertSame(['application/x-www-form-urlencoded'], $httpRequest->header('Content-Type'));
+ $this->assertSame('name=Sam&catchphrase=Yeehaw%21', $httpRequest->body());
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+}
diff --git a/tests/Saloon/Feature/Body/HasJsonBodyTest.php b/tests/Saloon/Feature/Body/HasJsonBodyTest.php
new file mode 100644
index 0000000000..3718359e21
--- /dev/null
+++ b/tests/Saloon/Feature/Body/HasJsonBodyTest.php
@@ -0,0 +1,209 @@
+assertSame([
+ 'name' => 'Sam',
+ 'catchphrase' => 'Yeehaw!',
+ ], $request->body());
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+
+ $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']);
+ }
+
+ public function testTheContentTypeHeaderIsSetInThePendingRequest(): void
+ {
+ $pendingRequest = $this->pendingRequest(TestConnector::make(), new HasJsonBodyRequest);
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ ], $pendingRequest->headers());
+ }
+
+ public function testAnExplicitContentTypeOverridesTheBodyTraitDefault(): void
+ {
+ $request = (new HasJsonBodyRequest)->withHeader('Content-Type', 'application/vnd.api+json');
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+
+ $this->assertSame('application/vnd.api+json', $pendingRequest->headers()['Content-Type']);
+ }
+
+ public function testWhenJustTheConnectorHasBodyTheBodyWillBeSent(): void
+ {
+ // Connectors are read-only and expose no body, so their default body is checked on the pending request.
+ $pendingRequest = $this->pendingRequest(new HasJsonBodyConnector, new UserRequest);
+
+ $this->assertSame([
+ 'name' => 'Gareth',
+ 'drink' => 'Moonshine',
+ ], $pendingRequest->body());
+ $this->assertSame('{"name":"Gareth","drink":"Moonshine"}', (string) $pendingRequest->preparedBody());
+ }
+
+ public function testWhenBothTheConnectorAndTheRequestHaveTheSameRequestBodiesTheyWillBeMerged(): void
+ {
+ $request = new HasJsonBodyRequest;
+
+ $this->assertSame([
+ 'name' => 'Sam',
+ 'catchphrase' => 'Yeehaw!',
+ ], $request->body());
+
+ // Name should be overwritten to "Sam" and "catchphrase" should be merged in
+ $pendingRequest = $this->pendingRequest(new HasJsonBodyConnector, $request);
+
+ $this->assertSame([
+ 'name' => 'Sam',
+ 'drink' => 'Moonshine',
+ 'catchphrase' => 'Yeehaw!',
+ ], $pendingRequest->body());
+ $this->assertSame('{"name":"Sam","drink":"Moonshine","catchphrase":"Yeehaw!"}', (string) $pendingRequest->preparedBody());
+ }
+
+ public function testIfTheConnectorAndRequestImplementDifferentBodyRepositoriesThenAnExceptionIsThrown(): void
+ {
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('Connector and request body types must be the same.');
+
+ $this->pendingRequest(new HasJsonBodyConnector, new HasMultipartBodyRequest);
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasJsonBodyRequest;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']);
+ });
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $this->assertSame(['application/json'], $httpRequest->header('Content-Type'));
+ $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!"}', $httpRequest->body());
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+
+ public function testYouCanSpecifyDifferentJsonFlagsThatTheBodyRepositoryShouldUse(): void
+ {
+ // Request bodies are not exposed as repositories, so the flags are tested on the repository itself and then
+ // supplied through a request's defaultBodyRepository().
+ $body = new JsonBodyRepository((new HasJsonBodyRequest)->body());
+
+ // We'll add a property with slashes
+ $body->add('url', 'https://docs.saloon.dev');
+
+ // By default, PHP will escape slashes
+ $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!","url":"https:\/\/docs.saloon.dev"}', (string) $body);
+
+ // Now we'll customise the flags
+ $body->setJsonFlags(JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+
+ $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!","url":"https://docs.saloon.dev"}', (string) $body);
+ $this->assertSame(JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, $body->getJsonFlags());
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, new UnescapedSlashesJsonRequestStub);
+
+ $this->assertSame('{"url":"https://docs.saloon.dev"}', (string) $pendingRequest->preparedBody());
+ }
+
+ public function testTheJsonBodyRepositoryUsesTheJsonThrowOnErrorDefaultFlag(): void
+ {
+ $this->assertSame(JSON_THROW_ON_ERROR, (new JsonBodyRepository)->getJsonFlags());
+ }
+
+ /**
+ * Send the request with a mock response and return its pending request.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return $connector->send($request, new MockClient([MockResponse::make()]))->pendingRequest();
+ }
+}
+
+class UnescapedSlashesJsonRequestStub extends Request
+{
+ use HasJsonBody;
+
+ /**
+ * Define the method that the request will use.
+ */
+ protected Method $method = Method::POST;
+
+ /**
+ * Define the endpoint for the request.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/user';
+ }
+
+ /**
+ * Define the default body.
+ *
+ * @return array
+ */
+ protected function defaultBody(): array
+ {
+ return ['url' => 'https://docs.saloon.dev'];
+ }
+
+ /**
+ * Resolve the default body repository.
+ */
+ protected function defaultBodyRepository(): BodyRepository
+ {
+ return (new JsonBodyRepository($this->defaultBody()))
+ ->setJsonFlags(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
+ }
+}
diff --git a/tests/Saloon/Feature/Body/HasMultipartBodyTest.php b/tests/Saloon/Feature/Body/HasMultipartBodyTest.php
new file mode 100644
index 0000000000..e51fea0518
--- /dev/null
+++ b/tests/Saloon/Feature/Body/HasMultipartBodyTest.php
@@ -0,0 +1,318 @@
+assertEquals([
+ new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ ], $request->body());
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+ $contentType = $pendingRequest->headers()['Content-Type'];
+ $boundary = substr($contentType, strlen('multipart/form-data; boundary='));
+
+ $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType);
+ $this->assertStringStartsWith('--' . $boundary . "\r\n", (string) $pendingRequest->preparedBody());
+ }
+
+ public function testWhenBothTheConnectorAndTheRequestHaveTheSameRequestBodiesTheyWillBeMerged(): void
+ {
+ $request = new HasMultipartBodyRequest;
+
+ $this->assertEquals([
+ new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ ], $request->body());
+
+ // Connectors are read-only and expose no body, so their default body is checked on the pending request.
+ $pendingRequest = $this->pendingRequest(new HasMultipartBodyConnector, $request);
+
+ $this->assertEquals([
+ new MultipartValue('nickname', 'Gareth', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ new MultipartValue('drink', 'Moonshine', 'moonshine.txt', ['X-My-Head' => 'Spinning!']),
+ new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ ], $pendingRequest->body());
+
+ $contentType = $pendingRequest->headers()['Content-Type'];
+ $body = (string) $pendingRequest->preparedBody();
+
+ $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType);
+ $this->assertStringStartsWith('--' . substr($contentType, strlen('multipart/form-data; boundary=')) . "\r\n", $body);
+ $this->assertStringContainsString("\r\n\r\nGareth\r\n", $body);
+ $this->assertStringContainsString("\r\n\r\nMoonshine\r\n", $body);
+ $this->assertStringContainsString("\r\n\r\nSam\r\n", $body);
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasMultipartBodyRequest;
+
+ $asserted = false;
+
+ // The boundary content type is added when the body is prepared, after request middleware runs, so it always
+ // matches the final body. The upstream middleware assertion is therefore made on the sent request.
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $contentType = $httpRequest->header('Content-Type')[0];
+ $body = $httpRequest->body();
+
+ $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType);
+ $this->assertStringStartsWith('--' . substr($contentType, strlen('multipart/form-data; boundary=')), $body);
+ $this->assertStringContainsString('X-Saloon: Yee-haw!', $body);
+ $this->assertStringContainsString('Content-Disposition: form-data; name="nickname"; filename="user.txt"', $body);
+ $this->assertStringContainsString('Sam', $body);
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+
+ // The real-server cases "can send a real multipart request and files are sent" and "can send an empty string as
+ // the contents" are in tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php.
+
+ public function testCanSendMultipleMultipartFilesWithTheSameKeyName(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasMultipartBodyRequest;
+
+ $request->attach('nickname', 'Alfie', 'user.txt');
+ $request->attach('nickname', 'Tom', 'user.txt');
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $body = $httpRequest->body();
+
+ $this->assertStringContainsString('X-Saloon: Yee-haw!', $body);
+ $this->assertStringContainsString('Content-Disposition: form-data; name="nickname"; filename="user.txt"', $body);
+ $this->assertStringContainsString('Sam', $body);
+ $this->assertStringContainsString('Alfie', $body);
+ $this->assertStringContainsString('Tom', $body);
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+
+ public function testAMultipartBodyReplacesAContentTypeWithoutABoundary(): void
+ {
+ $request = (new UserRequest)
+ ->withHeader('content-type', 'application/json')
+ ->attach('nickname', 'Sam');
+ $snapshotContentType = null;
+
+ // Request middleware runs before the body is prepared, so its PSR snapshot resolves the content type itself.
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotContentType): void {
+ $snapshotContentType = $pendingRequest->toPsrRequest()->getHeader('Content-Type');
+ });
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+ $contentType = $pendingRequest->headers()['Content-Type'];
+
+ $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType);
+ $this->assertSame([$contentType], $snapshotContentType);
+ $this->assertStringStartsWith(
+ '--' . substr($contentType, strlen('multipart/form-data; boundary=')) . "\r\n",
+ (string) $pendingRequest->preparedBody(),
+ );
+ }
+
+ #[DataProvider('contentTypesWithABoundary')]
+ public function testAContentTypeThatDeclaresABoundaryIsKept(mixed $contentType): void
+ {
+ $request = (new ExplicitBoundaryMultipartRequestStub)->withHeader('Content-Type', $contentType);
+ $snapshotContentType = null;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotContentType): void {
+ $snapshotContentType = $pendingRequest->toPsrRequest()->getHeaderLine('Content-Type');
+ });
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+
+ $this->assertSame('multipart/related; boundary=saloon-boundary', $snapshotContentType);
+ $this->assertSame('multipart/related; boundary=saloon-boundary', $pendingRequest->toPsrRequest()->getHeaderLine('Content-Type'));
+ }
+
+ /**
+ * Get the supported representations of a content type that declares a boundary.
+ *
+ * @return iterable
+ */
+ public static function contentTypesWithABoundary(): iterable
+ {
+ yield 'string' => ['multipart/related; boundary=saloon-boundary'];
+ yield 'list' => [['multipart/related; boundary=saloon-boundary']];
+ yield 'stringable' => [new Stringable('multipart/related; boundary=saloon-boundary')];
+ }
+
+ public function testAsMultipartKeepsTheExistingMultipartBody(): void
+ {
+ $request = (new ExplicitBoundaryMultipartRequestStub)
+ ->asMultipart()
+ ->attach('drink', 'Moonshine')
+ ->asMultipart();
+
+ $this->assertEquals([
+ new MultipartValue('nickname', 'Sam'),
+ new MultipartValue('drink', 'Moonshine'),
+ ], $request->body());
+ $this->assertSame(
+ 'multipart/form-data; boundary=saloon-boundary',
+ $this->pendingRequest(new TestConnector, $request)->headers()['Content-Type'],
+ );
+ }
+
+ public function testWithDataAppendsFieldsToAMultipartBody(): void
+ {
+ $request = (new ExplicitBoundaryMultipartRequestStub)
+ ->attach('file', 'Howdy', 'howdy.txt')
+ ->withData([
+ 'nickname' => 'Alex',
+ 'active' => true,
+ 'inactive' => false,
+ 'nothing' => null,
+ 'roles' => ['admin', 'editor'],
+ 'profile' => ['city' => new Stringable('London')],
+ ]);
+
+ $this->assertEquals([
+ new MultipartValue('nickname', 'Sam'),
+ new MultipartValue('file', 'Howdy', 'howdy.txt'),
+ new MultipartValue('nickname', 'Alex'),
+ new MultipartValue('active', true),
+ new MultipartValue('inactive', false),
+ new MultipartValue('nothing', null),
+ new MultipartValue('roles', ['admin', 'editor']),
+ new MultipartValue('profile', ['city' => 'London']),
+ ], $request->body());
+
+ $pendingRequest = $this->pendingRequest(new TestConnector, $request);
+ $body = (string) $pendingRequest->preparedBody();
+
+ $this->assertSame('multipart/form-data; boundary=saloon-boundary', $pendingRequest->headers()['Content-Type']);
+ $this->assertStringStartsWith("--saloon-boundary\r\n", $body);
+
+ $fields = [
+ ['nickname', 'Sam'],
+ ['file', 'Howdy'],
+ ['nickname', 'Alex'],
+ ['active', '1'],
+ ['inactive', ''],
+ ['nothing', ''],
+ ['roles[0]', 'admin'],
+ ['roles[1]', 'editor'],
+ ['profile[city]', 'London'],
+ ];
+
+ foreach ($fields as [$name, $value]) {
+ $this->assertMatchesRegularExpression(
+ '/name="' . preg_quote($name, '/') . '"[^\r\n]*\r\n(?:[^\r\n]+\r\n)*\r\n' . preg_quote($value, '/') . '\r\n/',
+ $body,
+ );
+ }
+ }
+
+ public function testAPsrRequestSnapshotBeforeSendingKeepsAnAttachedResourceOpen(): void
+ {
+ $resource = fopen('php://memory', 'rw+');
+
+ try {
+ fwrite($resource, 'Howdy, Partner');
+ rewind($resource);
+
+ $request = (new UserRequest)->attach('file', $resource, 'howdy.txt');
+ $snapshotBody = null;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotBody): void {
+ $snapshotBody = (string) $pendingRequest->toPsrRequest()->getBody();
+ });
+
+ $body = (string) $this->pendingRequest(new TestConnector, $request)->preparedBody();
+
+ $this->assertStringContainsString("\r\n\r\nHowdy, Partner\r\n", $snapshotBody);
+ $this->assertStringContainsString("\r\n\r\nHowdy, Partner\r\n", $body);
+ } finally {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ }
+ }
+
+ /**
+ * Send the request with a mock response and return its pending request.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return $connector->send($request, new MockClient([MockResponse::make()]))->pendingRequest();
+ }
+}
+
+class ExplicitBoundaryMultipartRequestStub extends Request
+{
+ /**
+ * Define the method that the request will use.
+ */
+ protected Method $method = Method::POST;
+
+ /**
+ * Define the endpoint for the request.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/user';
+ }
+
+ /**
+ * Resolve the default body repository.
+ */
+ protected function defaultBodyRepository(): BodyRepository
+ {
+ return new MultipartBodyRepository([new MultipartValue('nickname', 'Sam')], 'saloon-boundary');
+ }
+}
diff --git a/tests/Saloon/Feature/Body/HasStreamBodyTest.php b/tests/Saloon/Feature/Body/HasStreamBodyTest.php
new file mode 100644
index 0000000000..061fdb5256
--- /dev/null
+++ b/tests/Saloon/Feature/Body/HasStreamBodyTest.php
@@ -0,0 +1,82 @@
+assertIsResource($request->body());
+
+ // Stream bodies do not assume a content type.
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $this->assertFalse($pendingRequest->hasHeader('Content-Type'));
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasStreamBodyRequest;
+
+ $request->withHeader('Content-Type', 'application/custom');
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $this->assertSame(['application/custom'], $httpRequest->header('Content-Type'));
+ $this->assertSame('Howdy, Partner', $httpRequest->body());
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+
+ public function testAPsrRequestSnapshotBeforeSendingKeepsAResourceBodyOpen(): void
+ {
+ $request = new HasStreamBodyRequest;
+ $snapshotBody = null;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotBody): void {
+ $snapshotBody = (string) $pendingRequest->toPsrRequest()->getBody();
+ });
+
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $this->assertSame('Howdy, Partner', $snapshotBody);
+ $this->assertSame('Howdy, Partner', (string) $pendingRequest->preparedBody());
+ }
+}
diff --git a/tests/Saloon/Feature/Body/HasStringBodyTest.php b/tests/Saloon/Feature/Body/HasStringBodyTest.php
new file mode 100644
index 0000000000..a9b8c923b1
--- /dev/null
+++ b/tests/Saloon/Feature/Body/HasStringBodyTest.php
@@ -0,0 +1,64 @@
+assertSame('name: Sam', $request->body());
+
+ // String bodies do not assume a content type.
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $this->assertFalse($pendingRequest->hasHeader('Content-Type'));
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasStringBodyRequest;
+
+ $request->withHeader('Content-Type', 'application/custom');
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $this->assertSame(['application/custom'], $httpRequest->header('Content-Type'));
+ $this->assertSame('name: Sam', $httpRequest->body());
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+}
diff --git a/tests/Saloon/Feature/Body/HasXmlBodyTest.php b/tests/Saloon/Feature/Body/HasXmlBodyTest.php
new file mode 100644
index 0000000000..57f74ffb97
--- /dev/null
+++ b/tests/Saloon/Feature/Body/HasXmlBodyTest.php
@@ -0,0 +1,66 @@
+assertSame('Howdy
', $request->body());
+
+ $pendingRequest = (new TestConnector)
+ ->send($request, new MockClient([MockResponse::make()]))
+ ->pendingRequest();
+
+ $this->assertSame('application/xml', $pendingRequest->headers()['Content-Type']);
+ }
+
+ public function testTheGuzzleSenderProperlySendsIt(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasXmlBodyRequest;
+
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ $this->assertSame('application/xml', $pendingRequest->headers()['Content-Type']);
+ });
+
+ $asserted = false;
+
+ Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface {
+ $this->assertSame(['application/xml'], $httpRequest->header('Content-Type'));
+ $this->assertSame('Howdy
', $httpRequest->body());
+
+ $asserted = true;
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($asserted);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/HasJsonBodyConnector.php b/tests/Saloon/Fixtures/Connectors/HasJsonBodyConnector.php
new file mode 100644
index 0000000000..ba5ef1e395
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/HasJsonBodyConnector.php
@@ -0,0 +1,55 @@
+url ?? TestConnector::API_URL;
+ }
+
+ /**
+ * Define the base headers that will be applied in every request.
+ *
+ * @return string[]
+ */
+ protected function defaultHeaders(): array
+ {
+ return [
+ 'Accept' => 'application/json',
+ ];
+ }
+
+ /**
+ * Define the default body.
+ *
+ * @return string[]
+ */
+ protected function defaultBody(): array
+ {
+ return [
+ 'name' => 'Gareth',
+ 'drink' => 'Moonshine',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php b/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php
new file mode 100644
index 0000000000..d39c42f1c2
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php
@@ -0,0 +1,56 @@
+url ?? TestConnector::API_URL;
+ }
+
+ /**
+ * Define the base headers that will be applied in every request.
+ *
+ * @return string[]
+ */
+ protected function defaultHeaders(): array
+ {
+ return [
+ 'Accept' => 'application/json',
+ ];
+ }
+
+ /**
+ * Define the default body.
+ *
+ * @return list
+ */
+ protected function defaultBody(): array
+ {
+ return [
+ new MultipartValue('nickname', 'Gareth', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ new MultipartValue('drink', 'Moonshine', 'moonshine.txt', ['X-My-Head' => 'Spinning!']),
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/TestConnector.php b/tests/Saloon/Fixtures/Connectors/TestConnector.php
index f7b7293f47..a9047fef67 100644
--- a/tests/Saloon/Fixtures/Connectors/TestConnector.php
+++ b/tests/Saloon/Fixtures/Connectors/TestConnector.php
@@ -16,8 +16,6 @@ class TestConnector extends Connector
*/
public const string API_URL = 'https://tests.saloon.dev/api';
- public bool $unique = false;
-
/**
* Create a new connector instance.
*/
diff --git a/tests/Saloon/Fixtures/Requests/HasFormBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasFormBodyRequest.php
new file mode 100644
index 0000000000..27a8d03efa
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HasFormBodyRequest.php
@@ -0,0 +1,40 @@
+ 'Sam',
+ 'catchphrase' => 'Yeehaw!',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php
new file mode 100644
index 0000000000..723bb7b6a9
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php
@@ -0,0 +1,40 @@
+ 'Sam',
+ 'catchphrase' => 'Yeehaw!',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php
new file mode 100644
index 0000000000..90d5aa68f8
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php
@@ -0,0 +1,40 @@
+
+ */
+ protected function defaultBody(): array
+ {
+ return [
+ new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php
new file mode 100644
index 0000000000..dc705d09ff
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php
@@ -0,0 +1,43 @@
+Howdy
';
+ }
+}
diff --git a/tests/Saloon/Http/BodyTraitsTest.php b/tests/Saloon/Http/BodyTraitsTest.php
deleted file mode 100644
index b35c7f8be7..0000000000
--- a/tests/Saloon/Http/BodyTraitsTest.php
+++ /dev/null
@@ -1,252 +0,0 @@
-Taylor'],
- ];
-
- foreach ($cases as [$request, $contentType, $body]) {
- $pendingRequest = $this->prepare($request);
-
- $this->assertSame($contentType, $pendingRequest->headers()['Content-Type']);
- $this->assertSame($body, (string) $pendingRequest->preparedBody());
- }
- }
-
- public function testExplicitContentTypeWinsTheBodyTraitDefault(): void
- {
- $pendingRequest = $this->prepare(
- (new BodyTraitsJsonRequest)->withHeader('Content-Type', 'application/vnd.api+json'),
- );
-
- $this->assertSame('application/vnd.api+json', $pendingRequest->headers()['Content-Type']);
- }
-
- public function testRawBodyTraitsKeepExactBytesWithoutInventingAContentType(): void
- {
- foreach ([new BodyTraitsStringRequest, new BodyTraitsStreamRequest] as $request) {
- $pendingRequest = $this->prepare($request);
-
- $this->assertArrayNotHasKey('Content-Type', $pendingRequest->headers());
- $this->assertSame('Taylor', (string) $pendingRequest->preparedBody());
- }
- }
-
- public function testMultipartDefaultsUseTheMergedRepositoryAndItsBoundary(): void
- {
- $pendingRequest = $this->prepare(
- new BodyTraitsMultipartRequest,
- new BodyTraitsMultipartConnector,
- );
-
- $contentType = $pendingRequest->headers()['Content-Type'];
- $body = (string) $pendingRequest->preparedBody();
-
- $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType);
- $this->assertStringContainsString('--' . substr($contentType, strlen('multipart/form-data; boundary=')), $body);
- $this->assertStringContainsString('name="connector"', $body);
- $this->assertStringContainsString("\r\nconnector\r\n", $body);
- $this->assertStringContainsString('name="request"', $body);
- $this->assertStringContainsString("\r\nrequest\r\n", $body);
- }
-
- public function testCustomBodyRepositoriesExposeTheirResolvedValue(): void
- {
- $this->assertSame('custom', (new BodyTraitsCustomRequest)->body());
- }
-
- /**
- * Prepare a request with isolated framework dependencies.
- */
- protected function prepare(Request $request, ?Connector $connector = null): PendingRequest
- {
- return (new PendingRequest(
- $connector ?? new BodyTraitsConnector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- ))->bootPlugins()->prepareBody();
- }
-}
-
-class BodyTraitsConnector extends Connector
-{
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-}
-
-class BodyTraitsMultipartConnector extends BodyTraitsConnector
-{
- use HasMultipartBody;
-
- protected function defaultBody(): array
- {
- return [new MultipartValue('connector', 'connector')];
- }
-}
-
-class BodyTraitsJsonRequest extends Request
-{
- use HasJsonBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): array
- {
- return ['name' => 'Taylor'];
- }
-}
-
-class BodyTraitsFormRequest extends Request
-{
- use HasFormBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): array
- {
- return ['name' => 'Taylor'];
- }
-}
-
-class BodyTraitsXmlRequest extends Request
-{
- use HasXmlBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): ?string
- {
- return 'Taylor';
- }
-}
-
-class BodyTraitsStringRequest extends Request
-{
- use HasStringBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): ?string
- {
- return 'Taylor';
- }
-}
-
-class BodyTraitsStreamRequest extends Request
-{
- use HasStreamBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): StreamInterface
- {
- return Utils::streamFor('Taylor');
- }
-}
-
-class BodyTraitsMultipartRequest extends Request
-{
- use HasMultipartBody;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBody(): array
- {
- return [new MultipartValue('request', 'request')];
- }
-}
-
-class BodyTraitsCustomRequest extends Request
-{
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- protected function defaultBodyRepository(): BodyRepository
- {
- return new CustomBodyRepository('custom');
- }
-}
-
-class CustomBodyRepository implements BodyRepository
-{
- public function __construct(protected mixed $value)
- {
- }
-
- public function set(mixed $value): static
- {
- $this->value = $value;
-
- return $this;
- }
-
- public function all(): mixed
- {
- return $this->value;
- }
-
- public function isEmpty(): bool
- {
- return $this->value === null;
- }
-
- public function isNotEmpty(): bool
- {
- return ! $this->isEmpty();
- }
-
- public function toStream(): StreamInterface
- {
- return Utils::streamFor((string) $this->value);
- }
-}
diff --git a/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php
deleted file mode 100644
index 969cd36730..0000000000
--- a/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php
+++ /dev/null
@@ -1,89 +0,0 @@
- 'Sam',
- 'sidekick' => 'Mantas',
- ]);
-
- $this->assertSame(['name' => 'Sam', 'sidekick' => 'Mantas'], $body->all());
-
- $body->set(['name' => 'Gareth']);
-
- $this->assertSame(['name' => 'Gareth'], $body->all());
- }
-
- public function testItRejectsNonArrayValues(): void
- {
- $body = new FormBodyRepository;
-
- $this->expectException(InvalidArgumentException::class);
-
- $body->set('Sam');
- }
-
- public function testItAddsGetsAndRemovesValues(): void
- {
- $body = new FormBodyRepository;
-
- $body->add('name', 'Sam');
- $body->add(1, 'Mantas');
- $body->add(value: 'Gareth');
-
- $this->assertSame('Sam', $body->get('name'));
- $this->assertSame('Mantas', $body->get(1));
- $this->assertSame('fallback', $body->get('missing', 'fallback'));
- $this->assertSame([
- 'name' => 'Sam',
- 1 => 'Mantas',
- 2 => 'Gareth',
- ], $body->get());
-
- $body->remove('name');
- $body->remove(1);
-
- $this->assertSame([2 => 'Gareth'], $body->all());
- }
-
- public function testItMergesAndConditionallyChangesValues(): void
- {
- $body = new FormBodyRepository(['name' => 'Sam']);
-
- $this->assertInstanceOf(MergeableBody::class, $body);
-
- $body->merge(['name' => 'Gareth'], ['sidekick' => 'Mantas']);
- $body->when(true, fn (FormBodyRepository $repository) => $repository->add('hero', 'Black Widow'));
- $body->when(false, fn (FormBodyRepository $repository) => $repository->add('hero', 'Iron Man'));
-
- $this->assertSame([
- 'name' => 'Gareth',
- 'sidekick' => 'Mantas',
- 'hero' => 'Black Widow',
- ], $body->all());
- }
-
- public function testItReportsWhetherItIsEmpty(): void
- {
- $body = new FormBodyRepository;
-
- $this->assertTrue($body->isEmpty());
- $this->assertFalse($body->isNotEmpty());
-
- $body->add('name', 'Sam');
-
- $this->assertFalse($body->isEmpty());
- $this->assertTrue($body->isNotEmpty());
- }
-}
diff --git a/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php
deleted file mode 100644
index 0b228f8c10..0000000000
--- a/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php
+++ /dev/null
@@ -1,106 +0,0 @@
-assertSame([], $body->all());
- $this->assertTrue($body->isEmpty());
- $this->assertFalse($body->isNotEmpty());
- }
-
- public function testItStoresAddsMergesAndRemovesValues(): void
- {
- $body = new MultipartBodyRepository([
- new MultipartValue('name', 'Sam'),
- ]);
-
- $this->assertInstanceOf(MergeableBody::class, $body);
-
- $body->add('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']);
- $body->merge([new MultipartValue('sidekick', 'Mantas')]);
-
- $this->assertEquals([
- new MultipartValue('name', 'Sam'),
- new MultipartValue('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']),
- new MultipartValue('sidekick', 'Mantas'),
- ], $body->all());
- $this->assertEquals([
- new MultipartValue('name', 'Sam'),
- new MultipartValue('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']),
- ], $body->get('name'));
- $this->assertEquals(new MultipartValue('sidekick', 'Mantas'), $body->get('sidekick'));
- $this->assertSame('fallback', $body->get('missing', 'fallback'));
-
- $body->remove('name');
-
- $this->assertEquals([new MultipartValue('sidekick', 'Mantas')], $body->all());
- }
-
- public function testItMayBeConditionallyChanged(): void
- {
- $body = new MultipartBodyRepository;
-
- $body->when(true, fn (MultipartBodyRepository $repository) => $repository->add('name', 'Gareth'));
- $body->when(false, fn (MultipartBodyRepository $repository) => $repository->add('name', 'Sam'));
-
- $this->assertEquals([new MultipartValue('name', 'Gareth')], $body->all());
- }
-
- public function testItRejectsNonArrayRepositoryValues(): void
- {
- $body = new MultipartBodyRepository;
-
- $this->expectException(InvalidArgumentException::class);
-
- $body->set('invalid');
- }
-
- public function testItRejectsArraysContainingOtherValues(): void
- {
- $this->expectException(InvalidArgumentException::class);
-
- new MultipartBodyRepository([
- new MultipartValue('name', 'Sam'),
- 'invalid',
- ]);
- }
-
- public function testItRejectsNonFiniteNumericValues(): void
- {
- $this->expectException(InvalidArgumentException::class);
-
- new MultipartValue('limit', INF);
- }
-
- public function testItCreatesOneMultipartStreamWithTheConfiguredBoundary(): void
- {
- $body = new MultipartBodyRepository([
- new MultipartValue('name', 'Sam'),
- new MultipartValue('count', 12),
- new MultipartValue('ratio', 1.5, 'ratio.txt', ['X-Part' => 'yes']),
- ], 'saloon-boundary');
-
- $contents = (string) $body->toStream();
-
- $this->assertSame('saloon-boundary', $body->boundary());
- $this->assertStringContainsString('name="name"', $contents);
- $this->assertStringContainsString("\r\n\r\nSam\r\n", $contents);
- $this->assertStringContainsString("\r\n\r\n12\r\n", $contents);
- $this->assertStringContainsString('filename="ratio.txt"', $contents);
- $this->assertStringContainsString("X-Part: yes\r\n", $contents);
- $this->assertStringContainsString("\r\n\r\n1.5\r\n", $contents);
- }
-}
diff --git a/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php
deleted file mode 100644
index f5b527f425..0000000000
--- a/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php
+++ /dev/null
@@ -1,92 +0,0 @@
-assertNull($body->all());
- $this->assertNull($body->get());
- $this->assertTrue($body->isEmpty());
-
- $stream = $body->toStream();
-
- $this->assertTrue($stream->isSeekable());
- $this->assertSame('', (string) $stream);
- }
-
- public function testItStoresAndReplacesResources(): void
- {
- $first = fopen('php://memory', 'rw+');
- $second = fopen('php://memory', 'rw+');
- $stream = null;
-
- try {
- fwrite($first, 'Howdy');
- fwrite($second, 'Yeehaw');
- rewind($second);
-
- $body = new StreamBodyRepository($first);
- $body->set($second);
- $stream = $body->toStream();
-
- $this->assertSame($second, $body->get());
- $this->assertSame('Yeehaw', (string) $stream);
- $this->assertFalse($body->isEmpty());
- $this->assertTrue($body->isNotEmpty());
- } finally {
- fclose($first);
- $stream?->close();
- }
- }
-
- public function testItReturnsTheOriginalPsrStream(): void
- {
- $stream = Utils::streamFor('Howdy!');
- $body = new StreamBodyRepository($stream);
-
- $this->assertSame($stream, $body->get());
- $this->assertSame($stream, $body->toStream());
- }
-
- public function testItMayBeConditionallyChanged(): void
- {
- $stream = Utils::streamFor('Howdy!');
- $body = new StreamBodyRepository;
-
- $body->when(true, fn (StreamBodyRepository $repository) => $repository->set($stream));
- $body->when(false, fn (StreamBodyRepository $repository) => $repository->set(null));
-
- $this->assertSame($stream, $body->get());
- }
-
- #[DataProvider('invalidValues')]
- public function testItRejectsValuesThatAreNotStreams(mixed $value): void
- {
- $this->expectException(InvalidArgumentException::class);
-
- new StreamBodyRepository($value);
- }
-
- /**
- * @return iterable
- */
- public static function invalidValues(): iterable
- {
- yield 'string' => ['Howdy'];
- yield 'integer' => [123];
- yield 'array' => [[]];
- yield 'boolean' => [false];
- }
-}
diff --git a/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
new file mode 100644
index 0000000000..6f258d3730
--- /dev/null
+++ b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
@@ -0,0 +1,177 @@
+assertSame([], $body->all());
+ }
+
+ public function testTheStoreCanHaveAnArrayProvided(): void
+ {
+ $body = new FormBodyRepository([
+ 'name' => 'Sam',
+ 'sidekick' => 'Mantas',
+ ]);
+
+ $this->assertSame([
+ 'name' => 'Sam',
+ 'sidekick' => 'Mantas',
+ ], $body->all());
+ }
+
+ public function testYouCanSetIt(): void
+ {
+ $body = new FormBodyRepository(['sidekick' => 'Mantas']);
+
+ $body->set(['name' => 'Sam']);
+
+ $this->assertSame(['name' => 'Sam'], $body->all());
+ }
+
+ public function testItWillThrowAnExceptionIfYouSetANonArray(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The body value must be an array.');
+
+ $body = new FormBodyRepository;
+ $body->set('Sam');
+ }
+
+ public function testYouCanAddAnItem(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add('name', 'Sam');
+
+ $this->assertSame(['name' => 'Sam'], $body->all());
+ }
+
+ public function testYouCanAddAnItemWithAnIntegerKey(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add(1, 'Sam');
+
+ $this->assertSame([1 => 'Sam'], $body->all());
+ }
+
+ public function testYouCanAddAnItemWithoutAKey(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add(null, 'Sam');
+
+ $this->assertSame(['Sam'], $body->all());
+
+ $body->add(5, 'Mantas');
+ $body->add(value: 'Gareth');
+
+ $this->assertSame([0 => 'Sam', 5 => 'Mantas', 6 => 'Gareth'], $body->all());
+ }
+
+ public function testYouCanConditionallyAddItemsToTheArrayStore(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->when(true, fn (FormBodyRepository $body): FormBodyRepository => $body->add('name', 'Gareth'));
+ $body->when(false, fn (FormBodyRepository $body): FormBodyRepository => $body->add('name', 'Sam'));
+ $body->when(true, fn (FormBodyRepository $body): FormBodyRepository => $body->add('sidekick', 'Mantas'));
+ $body->when(false, fn (FormBodyRepository $body): FormBodyRepository => $body->add('sidekick', 'Teo'));
+
+ $this->assertSame(['name' => 'Gareth', 'sidekick' => 'Mantas'], $body->all());
+ }
+
+ public function testYouCanDeleteAnItem(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->remove('name');
+
+ $this->assertSame([], $body->all());
+ }
+
+ public function testYouCanDeleteAnItemWithAnIntegerKey(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add(1, 'Sam');
+ $body->add(2, 'Gareth');
+ $body->remove(1);
+
+ $this->assertSame([2 => 'Gareth'], $body->all());
+ }
+
+ public function testYouCanGetAnItem(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add('name', 'Sam');
+
+ $this->assertSame('Sam', $body->get('name'));
+ $this->assertSame('fallback', $body->get('missing', 'fallback'));
+
+ // When omitting the key it should act like `->all()`
+ $this->assertSame(['name' => 'Sam'], $body->get());
+ }
+
+ public function testYouCanGetAnItemWithAnIntegerKey(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add(2, 'Sam');
+
+ $this->assertSame('Sam', $body->get(2));
+ }
+
+ public function testYouCanGetAllItems(): void
+ {
+ $body = new FormBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->add('superhero', 'Iron Man');
+
+ $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $body->all());
+ }
+
+ public function testYouCanMergeItemsTogetherIntoTheBodyRepository(): void
+ {
+ $body = new FormBodyRepository;
+
+ $this->assertInstanceOf(MergeableBody::class, $body);
+
+ $body->add('name', 'Sam');
+ $body->add('sidekick', 'Mantas');
+
+ $body->merge(['sidekick' => 'Gareth'], ['superhero' => 'Black Widow']);
+
+ $this->assertSame(['name' => 'Sam', 'sidekick' => 'Gareth', 'superhero' => 'Black Widow'], $body->all());
+ }
+
+ public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void
+ {
+ $body = new FormBodyRepository;
+
+ $this->assertTrue($body->isEmpty());
+ $this->assertFalse($body->isNotEmpty());
+
+ $body->add('name', 'Sam');
+
+ $this->assertFalse($body->isEmpty());
+ $this->assertTrue($body->isNotEmpty());
+ }
+}
diff --git a/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
new file mode 100644
index 0000000000..816d09bef6
--- /dev/null
+++ b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
@@ -0,0 +1,213 @@
+assertSame([], $body->all());
+ }
+
+ public function testTheStoreCanHaveAnArrayOfMultipartValuesProvided(): void
+ {
+ $body = new MultipartBodyRepository([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('sidekick', 'Mantas'),
+ ]);
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('sidekick', 'Mantas'),
+ ], $body->all());
+ }
+
+ public function testTheStoreWillThrowAnExceptionIfSetValueIsNotAnArray(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The multipart body value must be an array.');
+
+ $body = new MultipartBodyRepository;
+ $body->set('123');
+ }
+
+ public function testTheStoreWillThrowAnExceptionIfTheArrayDoesNotContainMultipartValues(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
+
+ new MultipartBodyRepository([
+ 'name' => 'Sam',
+ 'sidekick' => new MultipartValue('username', 'Sammyjo20'),
+ ]);
+ }
+
+ public function testYouCanSetIt(): void
+ {
+ $body = new MultipartBodyRepository([new MultipartValue('name', 'Sam')]);
+
+ $body->set([
+ new MultipartValue('username', 'Sammyjo20'),
+ ]);
+
+ $this->assertEquals([
+ new MultipartValue('username', 'Sammyjo20'),
+ ], $body->all());
+ }
+
+ public function testYouCanAddMultipleItems(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->add('name', 'Sam', 'welcome.txt', ['a' => 'b']);
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam', 'welcome.txt', ['a' => 'b']),
+ ], $body->all());
+
+ // Test it gets added to the array
+ $body->add('name', 'Charlotte', 'welcome.txt', ['a' => 'b']);
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam', 'welcome.txt', ['a' => 'b']),
+ new MultipartValue('name', 'Charlotte', 'welcome.txt', ['a' => 'b']),
+ ], $body->all());
+ }
+
+ public function testYouCanConditionallyAddItemsToTheArrayStore(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->when(true, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('name', 'Gareth'));
+ $body->when(false, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('name', 'Sam'));
+ $body->when(true, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('sidekick', 'Mantas'));
+ $body->when(false, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('sidekick', 'Teo'));
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Gareth'),
+ new MultipartValue('sidekick', 'Mantas'),
+ ], $body->all());
+ }
+
+ public function testYouCanDeleteAnItem(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->add('name', 'Charlotte');
+ $body->add('sidekick', 'Mantas');
+ $body->remove('name');
+
+ $this->assertEquals([new MultipartValue('sidekick', 'Mantas')], $body->all());
+ }
+
+ public function testYouCanGetAnItem(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->add('friend', 'Chris');
+
+ $this->assertEquals(new MultipartValue('name', 'Sam'), $body->get('name'));
+ $this->assertEquals(new MultipartValue('friend', 'Chris'), $body->get('friend'));
+ $this->assertSame('fallback', $body->get('missing', 'fallback'));
+ }
+
+ public function testYouCanGetMultipleItemsWithTheSameName(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->add('name', 'Alex');
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('name', 'Alex'),
+ ], $body->get('name'));
+ }
+
+ public function testYouCanGetAllItems(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $body->add('name', 'Sam');
+ $body->add('superhero', 'Iron Man');
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('superhero', 'Iron Man'),
+ ], $body->all());
+ }
+
+ public function testYouCanMergeItemsTogetherIntoTheBodyRepository(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $this->assertInstanceOf(MergeableBody::class, $body);
+
+ $body->add('name', 'Sam');
+ $body->add('sidekick', 'Mantas');
+
+ $body->merge([new MultipartValue('sidekick', 'Gareth')], [new MultipartValue('superhero', 'Black Widow')]);
+
+ $this->assertEquals([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('sidekick', 'Mantas'),
+ new MultipartValue('sidekick', 'Gareth'),
+ new MultipartValue('superhero', 'Black Widow'),
+ ], $body->all());
+ }
+
+ public function testItWillThrowAnExceptionIfTheMergedItemsAreNotMultipartValueObjects(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
+
+ $body->merge([new MultipartValue('sidekick', 'Gareth')], ['superhero' => 'Black Widow']);
+ }
+
+ public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void
+ {
+ $body = new MultipartBodyRepository;
+
+ $this->assertTrue($body->isEmpty());
+ $this->assertFalse($body->isNotEmpty());
+
+ $body->add('name', 'Sam');
+
+ $this->assertFalse($body->isEmpty());
+ $this->assertTrue($body->isNotEmpty());
+ }
+
+ public function testItCreatesOneMultipartStreamWithTheConfiguredBoundary(): void
+ {
+ $body = new MultipartBodyRepository([
+ new MultipartValue('name', 'Sam'),
+ new MultipartValue('count', 12),
+ new MultipartValue('ratio', 1.5, 'ratio.txt', ['X-Part' => 'yes', 'X-Tags' => ['a', 'b']]),
+ ], 'saloon-boundary');
+
+ $contents = (string) $body->toStream();
+
+ $this->assertSame('saloon-boundary', $body->getBoundary());
+ $this->assertStringContainsString('name="name"', $contents);
+ $this->assertStringContainsString("\r\n\r\nSam\r\n", $contents);
+ $this->assertStringContainsString("\r\n\r\n12\r\n", $contents);
+ $this->assertStringContainsString('filename="ratio.txt"', $contents);
+ $this->assertStringContainsString("X-Part: yes\r\n", $contents);
+ $this->assertStringContainsString("X-Tags: a, b\r\n", $contents);
+ $this->assertStringContainsString("\r\n\r\n1.5\r\n", $contents);
+ }
+}
diff --git a/tests/Saloon/Repositories/Body/SerializationTest.php b/tests/Saloon/Unit/Body/SerializationTest.php
similarity index 73%
rename from tests/Saloon/Repositories/Body/SerializationTest.php
rename to tests/Saloon/Unit/Body/SerializationTest.php
index 07eb696729..29aee71538 100644
--- a/tests/Saloon/Repositories/Body/SerializationTest.php
+++ b/tests/Saloon/Unit/Body/SerializationTest.php
@@ -2,11 +2,12 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Repositories\Body;
+namespace Hypervel\Tests\Saloon\Unit\Body;
use Hypervel\Saloon\Exceptions\BodyException;
use Hypervel\Saloon\Repositories\Body\FormBodyRepository;
use Hypervel\Saloon\Repositories\Body\JsonBodyRepository;
+use Hypervel\Saloon\Repositories\Body\StringBodyRepository;
use Hypervel\Support\Collection;
use Hypervel\Support\Stringable;
use Hypervel\Tests\TestCase;
@@ -16,6 +17,33 @@
class SerializationTest extends TestCase
{
+ public function testTheJsonBodyRepositoryCanBeEncodedIntoJson(): void
+ {
+ $body = new JsonBodyRepository([
+ 'name' => 'Sam',
+ 'sidekick' => 'Mantas',
+ ]);
+
+ $this->assertSame('{"name":"Sam","sidekick":"Mantas"}', (string) $body);
+ }
+
+ public function testTheFormBodyRepositoryCanBeEncodedIntoAQueryList(): void
+ {
+ $body = new FormBodyRepository([
+ 'name' => 'Sam',
+ 'sidekick' => 'Mantas',
+ ]);
+
+ $this->assertSame('name=Sam&sidekick=Mantas', (string) $body);
+ }
+
+ public function testTheStringBodyRepositoryCanBeEncodedIntoAString(): void
+ {
+ $body = new StringBodyRepository('name: Sam');
+
+ $this->assertSame('name: Sam', (string) $body);
+ }
+
public function testJsonBodyRepositoryEncodesSupportedNestedValuesOnce(): void
{
$body = new JsonBodyRepository([
@@ -37,16 +65,6 @@ public function jsonSerialize(): string
$this->assertSame((string) $body, (string) $body->toStream());
}
- public function testJsonBodyRepositoryHonorsCustomEncodingFlags(): void
- {
- $body = new JsonBodyRepository(['url' => 'https://hypervel.org']);
-
- $body->setJsonFlags(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
-
- $this->assertSame(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, $body->getJsonFlags());
- $this->assertSame('{"url":"https://hypervel.org"}', (string) $body);
- }
-
public function testJsonBodyRepositoryTranslatesThrownEncodingErrors(): void
{
$body = new JsonBodyRepository(['invalid' => "\xB1\x31"]);
diff --git a/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php b/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php
new file mode 100644
index 0000000000..40092f5d41
--- /dev/null
+++ b/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php
@@ -0,0 +1,207 @@
+assertNull($body->all());
+ $this->assertNull($body->get());
+ }
+
+ public function testTheStoreCanHaveADefaultStreamProvided(): void
+ {
+ $resource = tmpfile();
+
+ try {
+ $body = new StreamBodyRepository($resource);
+
+ $this->assertSame($resource, $body->all());
+ $this->assertSame($resource, $body->get());
+ } finally {
+ fclose($resource);
+ }
+ }
+
+ public function testYouCanSetIt(): void
+ {
+ $resourceA = fopen('php://memory', 'rw+');
+ $resourceB = fopen('php://memory', 'rw+');
+
+ try {
+ fwrite($resourceA, 'Howdy');
+ fwrite($resourceB, 'Yeehaw');
+ rewind($resourceB);
+
+ $body = new StreamBodyRepository($resourceA);
+
+ $body->set($resourceB);
+
+ $this->assertSame($resourceB, $body->get());
+ $this->assertSame('Yeehaw', (string) $body->toStream());
+ } finally {
+ // The repository closes resource A when it is replaced.
+ foreach ([$resourceA, $resourceB] as $resource) {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ }
+ }
+ }
+
+ public function testSettingTheSameResourceKeepsItOpen(): void
+ {
+ $resource = fopen('php://memory', 'rw+');
+
+ try {
+ fwrite($resource, 'Howdy');
+
+ $body = new StreamBodyRepository($resource);
+ $body->toStream();
+ $body->set($body->get());
+
+ $this->assertSame('Howdy', (string) $body->toStream());
+ } finally {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ }
+ }
+
+ public function testACopyKeepsTheResourceOpenWhenTheOriginalIsReleased(): void
+ {
+ $resource = fopen('php://memory', 'rw+');
+
+ try {
+ fwrite($resource, 'Howdy');
+
+ $body = new StreamBodyRepository($resource);
+ $copy = clone $body;
+
+ $body->toStream();
+ unset($body);
+
+ $this->assertSame('Howdy', (string) $copy->toStream());
+ } finally {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ }
+ }
+
+ public function testYouCanSetAnInstanceOfStreamInterface(): void
+ {
+ $streamA = Utils::streamFor('Howdy!');
+ $streamB = Utils::streamFor('Partner!');
+
+ $body = new StreamBodyRepository($streamA);
+ $body->set($streamB);
+
+ $this->assertSame($streamB, $body->get());
+ $this->assertSame($streamB, $body->toStream());
+ }
+
+ public function testYouCanConditionallySetOnTheStore(): void
+ {
+ $body = new StreamBodyRepository;
+
+ $resourceA = fopen('php://memory', 'rw+');
+ $resourceB = fopen('php://memory', 'rw+');
+
+ try {
+ fwrite($resourceA, 'Howdy');
+ fwrite($resourceB, 'Yeehaw');
+
+ $body->when(true, fn (StreamBodyRepository $body): StreamBodyRepository => $body->set($resourceA));
+ $body->when(false, fn (StreamBodyRepository $body): StreamBodyRepository => $body->set($resourceB));
+
+ $this->assertSame($resourceA, $body->get());
+ } finally {
+ fclose($resourceA);
+ fclose($resourceB);
+ }
+ }
+
+ public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void
+ {
+ $body = new StreamBodyRepository;
+
+ $this->assertTrue($body->isEmpty());
+ $this->assertFalse($body->isNotEmpty());
+
+ $resource = tmpfile();
+
+ try {
+ $body->set($resource);
+
+ $this->assertFalse($body->isEmpty());
+ $this->assertTrue($body->isNotEmpty());
+ } finally {
+ fclose($resource);
+ }
+ }
+
+ #[DataProvider('invalidValues')]
+ public function testItWillThrowAnExceptionIfTheValueIsNotAResourceOrStreamInterfaceWhenInstantiating(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ new StreamBodyRepository($value);
+ }
+
+ #[DataProvider('invalidValues')]
+ public function testItWillThrowAnExceptionIfTheValueIsNotAResourceOrStreamInterfaceWhenSetting(mixed $value): void
+ {
+ $body = new StreamBodyRepository;
+
+ $this->expectException(InvalidArgumentException::class);
+
+ $body->set($value);
+ }
+
+ /**
+ * Get the values a stream body rejects.
+ *
+ * @return iterable
+ */
+ public static function invalidValues(): iterable
+ {
+ yield 'string' => ['Howdy'];
+ yield 'integer' => [123];
+ yield 'array' => [[]];
+ yield 'boolean' => [false];
+ }
+
+ public function testItAllowsNullValues(): void
+ {
+ $body = new StreamBodyRepository(null);
+
+ $this->assertNull($body->get());
+ $this->assertTrue($body->isEmpty());
+
+ $body->set(Utils::streamFor('Howdy!'));
+ $body->set(null);
+
+ $this->assertNull($body->get());
+ $this->assertTrue($body->isEmpty());
+ }
+
+ public function testItCreatesAnEmptySeekableStreamForNull(): void
+ {
+ $stream = (new StreamBodyRepository)->toStream();
+
+ $this->assertTrue($stream->isSeekable());
+ $this->assertSame('', (string) $stream);
+ }
+}
diff --git a/tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php b/tests/Saloon/Unit/Body/StringBodyRepositoryTest.php
similarity index 59%
rename from tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php
rename to tests/Saloon/Unit/Body/StringBodyRepositoryTest.php
index e1525b2c47..910791aace 100644
--- a/tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php
+++ b/tests/Saloon/Unit/Body/StringBodyRepositoryTest.php
@@ -2,53 +2,67 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Repositories\Body;
+namespace Hypervel\Tests\Saloon\Unit\Body;
use Hypervel\Saloon\Repositories\Body\StringBodyRepository;
use Hypervel\Tests\TestCase;
class StringBodyRepositoryTest extends TestCase
{
- public function testItIsEmptyByDefault(): void
+ public function testTheStoreIsEmptyByDefault(): void
{
$body = new StringBodyRepository;
$this->assertNull($body->all());
- $this->assertTrue($body->isEmpty());
- $this->assertFalse($body->isNotEmpty());
$this->assertSame('', (string) $body);
$this->assertSame('', (string) $body->toStream());
}
- public function testItStoresAndReplacesStringBodies(): void
+ public function testTheStoreCanHaveADefaultStringProvided(): void
{
- $body = new StringBodyRepository('Sam');
+ $body = new StringBodyRepository('Yeehaw!');
+
+ $this->assertSame('Yeehaw!', $body->all());
+ }
- $this->assertSame('Sam', $body->all());
+ public function testYouCanSetIt(): void
+ {
+ $body = new StringBodyRepository('Sam');
$body->set('Yeehaw!');
$this->assertSame('Yeehaw!', $body->all());
- $this->assertFalse($body->isEmpty());
- $this->assertTrue($body->isNotEmpty());
$this->assertSame('Yeehaw!', (string) $body->toStream());
}
- public function testZeroIsNotAnEmptyBody(): void
+ public function testYouCanConditionallySetOnTheStore(): void
{
- $body = new StringBodyRepository('0');
+ $body = new StringBodyRepository;
- $this->assertFalse($body->isEmpty());
- $this->assertSame('0', (string) $body->toStream());
+ $body->when(true, fn (StringBodyRepository $body): StringBodyRepository => $body->set('Gareth'));
+ $body->when(false, fn (StringBodyRepository $body): StringBodyRepository => $body->set('Sam'));
+
+ $this->assertSame('Gareth', $body->all());
}
- public function testItMayBeConditionallyChanged(): void
+ public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void
{
$body = new StringBodyRepository;
- $body->when(true, fn (StringBodyRepository $repository) => $repository->set('Gareth'));
- $body->when(false, fn (StringBodyRepository $repository) => $repository->set('Sam'));
+ $this->assertTrue($body->isEmpty());
+ $this->assertFalse($body->isNotEmpty());
- $this->assertSame('Gareth', $body->all());
+ $body->set('Sam');
+
+ $this->assertFalse($body->isEmpty());
+ $this->assertTrue($body->isNotEmpty());
+ }
+
+ public function testZeroIsNotAnEmptyBody(): void
+ {
+ $body = new StringBodyRepository('0');
+
+ $this->assertFalse($body->isEmpty());
+ $this->assertSame('0', (string) $body->toStream());
}
}
diff --git a/tests/Saloon/Unit/BodyTraitTest.php b/tests/Saloon/Unit/BodyTraitTest.php
new file mode 100644
index 0000000000..b84837a055
--- /dev/null
+++ b/tests/Saloon/Unit/BodyTraitTest.php
@@ -0,0 +1,189 @@
+withBody(new Stringable('{"name":"Sam"}'));
+
+ $this->assertSame('{"name":"Sam"}', $request->body());
+ $this->assertSame('application/json', $request->headers()['Content-Type']);
+ }
+
+ public function testWithBodyUsesAStreamBodyForStreamsAndResources(): void
+ {
+ $stream = Utils::streamFor('Howdy');
+ $resource = fopen('php://memory', 'rb');
+
+ try {
+ $streamRequest = (new UserRequest)->withBody($stream, 'text/plain');
+ $resourceRequest = (new UserRequest)->withBody($resource, null);
+
+ $this->assertSame($stream, $streamRequest->body());
+ $this->assertSame('text/plain', $streamRequest->headers()['Content-Type']);
+ $this->assertSame($resource, $resourceRequest->body());
+ $this->assertFalse($resourceRequest->hasHeader('Content-Type'));
+ } finally {
+ fclose($resource);
+ }
+ }
+
+ public function testAsJsonAndAsFormReplaceTheBodyAndContentType(): void
+ {
+ $request = (new UserRequest)->asForm(['name' => 'Sam']);
+
+ $this->assertSame(['name' => 'Sam'], $request->body());
+ $this->assertSame('application/x-www-form-urlencoded', $request->headers()['Content-Type']);
+
+ $request->asJson(['drink' => 'Moonshine']);
+
+ $this->assertSame(['drink' => 'Moonshine'], $request->body());
+ $this->assertSame('application/json', $request->headers()['Content-Type']);
+ }
+
+ public function testWithDataMergesIntoAStructuredBody(): void
+ {
+ $request = (new HasFormBodyRequest)->withData(['name' => 'Gareth', 'drink' => 'Moonshine']);
+
+ $this->assertSame([
+ 'name' => 'Gareth',
+ 'catchphrase' => 'Yeehaw!',
+ 'drink' => 'Moonshine',
+ ], $request->body());
+ }
+
+ public function testWithDataStartsAJsonBodyWhenTheBodyIsNotStructured(): void
+ {
+ $request = (new HasStringBodyRequest)->withData(['name' => 'Sam']);
+
+ $this->assertSame(['name' => 'Sam'], $request->body());
+ $this->assertSame('application/json', $request->headers()['Content-Type']);
+ }
+
+ public function testAttachReplacesABodyThatIsNotMultipart(): void
+ {
+ $request = (new UserRequest)
+ ->asJson(['name' => 'Sam'])
+ ->attach('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']);
+
+ $this->assertEquals([
+ new MultipartValue('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']),
+ ], $request->body());
+ }
+
+ public function testAttachAcceptsAnArrayOfParts(): void
+ {
+ $request = (new HasMultipartBodyRequest)->attach([
+ ['avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']],
+ ['description', 'Profile picture'],
+ ]);
+
+ $this->assertEquals([
+ new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']),
+ new MultipartValue('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']),
+ new MultipartValue('description', 'Profile picture'),
+ ], $request->body());
+ }
+
+ public function testHasBodyReportsWhetherTheRequestHasABody(): void
+ {
+ $this->assertFalse((new UserRequest)->hasBody());
+ $this->assertNull((new UserRequest)->body());
+ $this->assertTrue((new UserRequest)->asMultipart()->hasBody());
+ }
+
+ public function testCustomBodyRepositoriesExposeTheirResolvedValue(): void
+ {
+ $this->assertSame('custom', (new CustomBodyRequestStub)->body());
+ }
+}
+
+class CustomBodyRequestStub extends Request
+{
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Resolve the default body repository.
+ */
+ protected function defaultBodyRepository(): BodyRepository
+ {
+ return new CustomBodyRepositoryStub('custom');
+ }
+}
+
+class CustomBodyRepositoryStub implements BodyRepository
+{
+ /**
+ * Create a custom body repository.
+ */
+ public function __construct(protected mixed $value)
+ {
+ }
+
+ /**
+ * Set the raw data in the repository.
+ */
+ public function set(mixed $value): static
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get the raw data in the repository.
+ */
+ public function all(): mixed
+ {
+ return $this->value;
+ }
+
+ /**
+ * Determine if the repository is empty.
+ */
+ public function isEmpty(): bool
+ {
+ return $this->value === null;
+ }
+
+ /**
+ * Determine if the repository is not empty.
+ */
+ public function isNotEmpty(): bool
+ {
+ return ! $this->isEmpty();
+ }
+
+ /**
+ * Convert the body repository into a stream.
+ */
+ public function toStream(): StreamInterface
+ {
+ return Utils::streamFor((string) $this->value);
+ }
+}
diff --git a/tests/Saloon/Unit/MultipartValueTest.php b/tests/Saloon/Unit/MultipartValueTest.php
new file mode 100644
index 0000000000..838de8cd91
--- /dev/null
+++ b/tests/Saloon/Unit/MultipartValueTest.php
@@ -0,0 +1,79 @@
+assertSame($value, $multipartValue->value);
+ } finally {
+ if (is_resource($value)) {
+ fclose($value);
+ }
+ }
+ }
+
+ /**
+ * Get the values a multipart value accepts.
+ *
+ * @return iterable
+ */
+ public static function validValues(): iterable
+ {
+ yield 'stream' => [Utils::streamFor('hello')];
+ yield 'resource' => [fopen(sprintf('data://text/plain,%s', 'hello'), 'rb')];
+ yield 'string' => ['hello'];
+ yield 'integer' => [123];
+ yield 'float' => [123.50];
+ // Upstream rejects these. Hypervel accepts Guzzle's field values so withData() can add fields to a multipart body.
+ yield 'boolean' => [false];
+ yield 'null' => [null];
+ yield 'empty array' => [[]];
+ yield 'array' => [['admin', 'profile' => ['city' => 'London']]];
+ }
+
+ #[DataProvider('invalidValues')]
+ public function testItWillThrowAnExceptionOnInvalidValues(mixed $value): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The value property must be either a Psr\Http\Message\StreamInterface, resource, string, finite number, boolean, null, or array.');
+
+ new MultipartValue('test', $value);
+ }
+
+ /**
+ * Get the values a multipart value rejects.
+ *
+ * @return iterable
+ */
+ public static function invalidValues(): iterable
+ {
+ yield 'object' => [new UserRequest];
+ yield 'infinite float' => [INF];
+ }
+
+ #[TestWith(['roles.txt', []])]
+ #[TestWith([null, ['X-Role' => 'admin']])]
+ public function testAnArrayValueCannotHaveAFilenameOrHeaders(?string $filename, array $headers): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('An array value cannot have a filename or headers.');
+
+ new MultipartValue('roles', ['admin'], $filename, $headers);
+ }
+}
From 26e19d8d56ba531246d5ba5e38738f72a790f853 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 17:59:06 +0000
Subject: [PATCH 03/26] Reconcile Saloon request, connector and plugin tests
with upstream
Ports Saloon's request, connector, pending request, URL helper, clone,
conditionable, plugin and API version tests (saloonphp/saloon v4 at
2f45e526f9d1) with their fixtures, merging the existing Hypervel
coverage into the upstream-named files under tests/Saloon. Cases that
call upstream's live test API run against new /user and /error routes on
the engine test server under tests/Integration/Saloon. This completes
saloonphp/saloon#526, #549 and #564 and the QUERY method commit
538994a591, which Hypervel already supported, plus the request and URL
parts of #539 and #542.
New APIs:
- PendingRequest::withMethod() and withUrl() change one operation
without touching the request. uri() replaces upstream's getUrl().
- withUrlParameters() fills URI template placeholders in the base URL,
endpoint or URL override, as Hypervel's HTTP client does. The package
now requires guzzlehttp/uri-template directly.
- HasApiVersion and VersionMode. Connectors are read-only, so there is
no setApiVersion(); a class assigns its version in the constructor or
overrides getApiVersion(). A request's version replaces its
connector's in every mode.
- createPendingRequest() on the manager, connectors and requests
prepares a request without sending it. Mock clients are still chosen
when the request is sent, so it takes no mock client.
- HasConnector, which SoloRequest now uses.
Defects fixed:
- Authentication ran before plugins, so a cookie authenticator recorded
an API-version host template before the version plugin filled it in,
and the cookie was never sent. Plugins now boot first, as upstream.
An authenticator a plugin selects is applied once after every plugin
boots, and the authenticator it replaced is no longer applied.
- Relative endpoints with a colon in the first segment, such as
documents:batchGet, were parsed as a scheme and rejected, and //search
as a host. Only scheme:// endpoints are absolute now.
- Merging query parameters into a URL with empty query segments
produced && or a leading &.
- A header written as a list item, such as ['Accept: application/json'],
caused a TypeError in a JSON-body request. It now fails with
upstream's message when the pending request is created.
- Upstream's Conditionable argument tests asserted inside the
callbacks, so they passed if the callbacks never ran.
Upstream's Macroable tests are covered by the framework's
SupportMacroableTest, which gains private member cases. The README
records the kept differences: resolveResponseClass(Response),
allowsBaseUrlOverride() methods, no mock client on
createPendingRequest(), and pending withMethod() and withUrl(). The
guide documents the new APIs, endpoint path rules and the order of
plugins and authentication.
Validation: Saloon suite (574 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the engine
test servers, tests/Data/Saloon, SupportMacroableTest and
FacadeDocblocksTest; composer analyse; php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 86 +++-
src/saloon/README.md | 8 +-
src/saloon/composer.json | 1 +
src/saloon/src/Enums/VersionMode.php | 12 +
src/saloon/src/Facades/Saloon.php | 1 +
src/saloon/src/Http/Connector.php | 17 +
src/saloon/src/Http/HeaderNormalizer.php | 23 +-
src/saloon/src/Http/PendingRequest.php | 110 ++++-
src/saloon/src/Http/SoloRequest.php | 26 +-
src/saloon/src/Http/UrlResolver.php | 31 +-
src/saloon/src/SaloonManager.php | 39 +-
.../src/Traits/Plugins/HasApiVersion.php | 71 ++++
.../src/Traits/Request/HasConnector.php | 67 ++++
.../HasRequestProperties.php | 1 +
.../RequestProperties/HasUrlParameters.php | 39 ++
.../Engine/Fixtures/http_server_v2.php | 11 +
.../Saloon/Feature/RequestTest.php | 105 +++++
.../Saloon/Feature/SoloRequestTest.php | 77 ++++
.../Fixtures/Requests/SoloErrorRequest.php | 33 ++
.../Fixtures/Requests/SoloUserRequest.php | 33 ++
tests/Saloon/Feature/BaseResourceTest.php | 40 ++
.../{Http => Feature}/SoloRequestTest.php | 51 +--
.../Connectors/CustomBaseUrlConnector.php | 27 ++
.../Connectors/CustomResponseConnector.php | 21 +
.../Connectors/RequestSelectionConnector.php | 38 ++
.../Fixtures/Connectors/ResourceConnector.php | 18 +
.../Fixtures/Plugins/WithBootTestPlugin.php | 21 +
.../Requests/AbsoluteEndpointRequest.php | 45 +++
.../Requests/CustomEndpointRequest.php | 39 ++
.../CustomResponseConnectorRequest.php | 26 ++
.../Requests/DefaultEndpointRequest.php | 24 ++
.../Saloon/Fixtures/Requests/ErrorRequest.php | 24 ++
.../Requests/HasConnectorUserRequest.php | 33 ++
.../Requests/InvalidResponseClass.php | 30 ++
.../Requests/MissingMethodRequest.php | 18 +
tests/Saloon/Fixtures/Requests/SubRequest.php | 9 +
.../Requests/UserRequestWithBootPlugin.php | 34 ++
.../UserRequestWithCustomResponse.php | 30 ++
.../Fixtures/Resources/UserBaseResource.php | 19 +
.../Fixtures/Responses/CustomResponse.php | 11 +
tests/Saloon/Fixtures/Responses/UserData.php | 16 +
.../Fixtures/Responses/UserResponse.php | 26 ++
.../UserResponseNoExtendSaloonResponse.php | 9 +
tests/Saloon/Fixtures/Saloon/user.json | 1 +
tests/Saloon/Http/HeaderNormalizerTest.php | 20 -
tests/Saloon/Http/RequestTest.php | 230 -----------
tests/Saloon/Http/UrlResolverTest.php | 142 -------
tests/Saloon/SaloonManagerTest.php | 43 +-
.../Unit/Concerns/HasApiVersionTest.php | 378 ++++++++++++++++++
tests/Saloon/Unit/ConditionableTest.php | 146 +++++++
tests/Saloon/Unit/ConnectorTest.php | 77 ++++
.../{Http => Unit}/PendingRequestTest.php | 227 ++++++++++-
tests/Saloon/Unit/PluginTest.php | 45 +++
tests/Saloon/Unit/RequestCloneTest.php | 179 +++++++++
.../Unit/RequestProperties/HeadersTest.php | 45 +++
.../Unit/RequestProperties/QueryTest.php | 36 ++
tests/Saloon/Unit/RequestTest.php | 290 ++++++++++++++
tests/Saloon/Unit/URLHelperTest.php | 307 ++++++++++++++
tests/Support/SupportMacroableTest.php | 20 +
60 files changed, 3095 insertions(+), 493 deletions(-)
create mode 100644 src/saloon/src/Enums/VersionMode.php
create mode 100644 src/saloon/src/Traits/Plugins/HasApiVersion.php
create mode 100644 src/saloon/src/Traits/Request/HasConnector.php
create mode 100644 src/saloon/src/Traits/RequestProperties/HasUrlParameters.php
create mode 100644 tests/Integration/Saloon/Feature/RequestTest.php
create mode 100644 tests/Integration/Saloon/Feature/SoloRequestTest.php
create mode 100644 tests/Integration/Saloon/Fixtures/Requests/SoloErrorRequest.php
create mode 100644 tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php
create mode 100644 tests/Saloon/Feature/BaseResourceTest.php
rename tests/Saloon/{Http => Feature}/SoloRequestTest.php (51%)
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomResponseConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/RequestSelectionConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/ResourceConnector.php
create mode 100644 tests/Saloon/Fixtures/Plugins/WithBootTestPlugin.php
create mode 100644 tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DefaultEndpointRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/ErrorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HasConnectorUserRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/InvalidResponseClass.php
create mode 100644 tests/Saloon/Fixtures/Requests/MissingMethodRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/SubRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/UserRequestWithBootPlugin.php
create mode 100644 tests/Saloon/Fixtures/Requests/UserRequestWithCustomResponse.php
create mode 100644 tests/Saloon/Fixtures/Resources/UserBaseResource.php
create mode 100644 tests/Saloon/Fixtures/Responses/CustomResponse.php
create mode 100644 tests/Saloon/Fixtures/Responses/UserData.php
create mode 100644 tests/Saloon/Fixtures/Responses/UserResponse.php
create mode 100644 tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php
create mode 100644 tests/Saloon/Fixtures/Saloon/user.json
delete mode 100644 tests/Saloon/Http/HeaderNormalizerTest.php
delete mode 100644 tests/Saloon/Http/RequestTest.php
delete mode 100644 tests/Saloon/Http/UrlResolverTest.php
create mode 100644 tests/Saloon/Unit/Concerns/HasApiVersionTest.php
create mode 100644 tests/Saloon/Unit/ConditionableTest.php
create mode 100644 tests/Saloon/Unit/ConnectorTest.php
rename tests/Saloon/{Http => Unit}/PendingRequestTest.php (61%)
create mode 100644 tests/Saloon/Unit/PluginTest.php
create mode 100644 tests/Saloon/Unit/RequestCloneTest.php
create mode 100644 tests/Saloon/Unit/RequestTest.php
create mode 100644 tests/Saloon/Unit/URLHelperTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 8711b7f4d3..f0182b75ad 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,7 +182,7 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 29811dea60..4cc1a8e937 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -17,6 +17,7 @@
- [Headers](#headers)
- [Request URL](#request-url)
- [Query Parameters](#query-parameters)
+ - [API Versions](#api-versions)
- [Authentication](#authentication)
- [Request Bodies](#request-bodies)
- [Multipart Requests](#multipart-requests)
@@ -373,6 +374,17 @@ $response = $github->send(
The returned `Hypervel\Saloon\Http\Response` extends Hypervel's normal HTTP response, so the same JSON, header, status, exception, and PSR-7 methods are available.
+To inspect an outgoing request without sending it, pass the request to the connector's `createPendingRequest` method. The pending request has run its plugins, authenticator, boot hooks, and request middleware:
+
+```php
+$pendingRequest = $github->createPendingRequest(GetUser::make('hypervel'));
+
+$pendingRequest->headers();
+$pendingRequest->uri();
+```
+
+The URL and body are finalized, and fake responses are matched, only when a request is sent. Sending creates a new pending request, so make outgoing changes on the request or in its hooks and middleware rather than on an inspected pending request.
+
### Standalone Requests
@@ -395,7 +407,26 @@ class GetStatus extends SoloRequest
$response = (new GetStatus)->send();
```
-Normal connector requests accept relative endpoints. An absolute endpoint is rejected unless the request or connector explicitly opts into base URL replacement. This protects credentials from being sent to an unexpected host.
+A request that always uses the same connector may send itself with the `HasConnector` trait. Define the connector class in a `$connector` property:
+
+```php
+use Hypervel\Saloon\Traits\Request\HasConnector;
+
+class GetUser extends Request
+{
+ use HasConnector;
+
+ protected string $connector = GitHubConnector::class;
+
+ // ...
+}
+
+$response = GetUser::make('hypervel')->send();
+```
+
+The request creates its connector once, and the `connector` method returns it. Use `setConnector` to supply a configured instance instead, or override `resolveConnector` when the connector needs constructor arguments. The `createPendingRequest` method prepares the request through that connector.
+
+Normal connector requests accept relative endpoints. An endpoint without a scheme is always a path on the connector's host, even when it contains a colon, such as `documents:batchGet`, or begins with `//`. An absolute endpoint is rejected unless the request or connector explicitly opts into base URL replacement. This protects credentials from being sent to an unexpected host.
Override `allowsBaseUrlOverride` on a request to return `true` when that request may use an application-controlled absolute endpoint. The request method returns `null` by default, which inherits the connector's decision:
@@ -492,6 +523,30 @@ The URL must be absolute HTTP or HTTPS. It replaces both the connector base URL
The `url` method returns the override, or `null` when none was supplied. Explicit query strings and query parameters are still applied as described below.
+The `withUrlParameters` method fills URI template placeholders in the connector base URL, the request endpoint, or the URL override, just like Hypervel's HTTP client:
+
+```php
+// Base URL: https://{region}.api.example.com
+// Endpoint: /accounts/{account}
+
+$request->withUrlParameters([
+ 'region' => 'eu',
+ 'account' => $accountId,
+]);
+```
+
+Values in simple placeholders such as `{account}` are percent-encoded, so slashes and query characters in a value stay part of that value. Placeholders are only replaced when parameters are given.
+
+Pending requests also provide `withUrl` and `withMethod`, so a hook or middleware may redirect a single operation without changing the request instance:
+
+```php
+use Hypervel\Saloon\Enums\Method;
+
+$pendingRequest
+ ->withUrl('https://uploads.example.com/files')
+ ->withMethod(Method::PUT);
+```
+
### Query Parameters
@@ -522,6 +577,31 @@ The query should not include a leading `?`. It replaces the query in the base UR
You may define a default query string by overriding `defaultQueryString(): ?string` on your request. Returning `null` leaves the URL's query unchanged. The `queryString` method returns this string, while `queryParameters` returns the separately configured array parameters. Middleware may also call `withQueryString` on a pending request to replace the query for that attempt.
+
+### API Versions
+
+The `HasApiVersion` plugin sends an API version with each operation. Assign the version in the connector or request constructor, and choose where it is sent with the `$versionMode` property: a header (the default), a query parameter, or the URL. The `$versionKey` property names the header or query parameter and defaults to `api-version`:
+
+```php
+use Hypervel\Saloon\Enums\VersionMode;
+use Hypervel\Saloon\Traits\Plugins\HasApiVersion;
+
+class AnthropicConnector extends Connector
+{
+ use HasApiVersion;
+
+ public function __construct()
+ {
+ $this->apiVersion = '2023-06-01';
+ $this->versionKey = 'anthropic-version';
+ }
+
+ // ...
+}
+```
+
+In URL mode, the version replaces a `{version}` placeholder in the base URL, endpoint, or URL override, such as `https://generativelanguage.googleapis.com/{version}`. The version may only contain letters, numbers, dashes, underscores, and single dots, so it can only fill that subdomain label or path segment. You may override `getApiVersion` to calculate the version. When both a connector and a request send a version, the request's version is used.
+
### Authentication
@@ -992,6 +1072,8 @@ trait AddsRequestId
Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation.
+Connector plugins boot before request plugins, and the request is authenticated once every plugin has booted. This lets an authenticator use the URL and headers set by plugins, such as an [API version](#api-versions) in the host. A plugin may select the authenticator by calling `authenticate` on the pending request; Saloon applies it after the remaining plugins boot. The connector and request `boot` methods and request middleware run after authentication, so calling `authenticate` there applies the authenticator immediately.
+
## Responses
@@ -2068,7 +2150,7 @@ Saloon::fake([
Each matching value may also be a callback that receives the pending request and returns a mock response or fixture. A lower-level `Http::fake()` still prevents a network request after the Saloon lifecycle reaches Hypervel's HTTP client. The response is recorded by the HTTP client and `isMocked()` returns false. When no Saloon mock client is active, Saloon facade assertions do not include the response.
-You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client.
+You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. Mock responses are matched after request middleware has run and the URL is final, so URL matches see any changes middleware made.
Mock clients are strict by default. An unmatched request throws `NoMockResponseFoundException` instead of reaching the network.
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 94f570c116..34810c7cac 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -4,9 +4,13 @@ Documentation: https://hypervel.org/docs/saloon
## Differences From Laravel
-Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`.
+Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, and `uri()` replaces `getUrl()`, including the query string.
-Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware.
+Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
+
+`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
+
+A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own.
diff --git a/src/saloon/composer.json b/src/saloon/composer.json
index 5445024de7..8296398e13 100644
--- a/src/saloon/composer.json
+++ b/src/saloon/composer.json
@@ -38,6 +38,7 @@
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.15.2 || ^8.2",
"guzzlehttp/psr7": "^2.13 || ^3.1",
+ "guzzlehttp/uri-template": "^1.0 || ^2.0",
"hypervel/cache": "^0.4",
"hypervel/collections": "^0.4",
"hypervel/conditionable": "^0.4",
diff --git a/src/saloon/src/Enums/VersionMode.php b/src/saloon/src/Enums/VersionMode.php
new file mode 100644
index 0000000000..a0593e1aa4
--- /dev/null
+++ b/src/saloon/src/Enums/VersionMode.php
@@ -0,0 +1,12 @@
+ $callbacks)
* @method static \Hypervel\Contracts\Cache\Factory cache()
* @method static \Hypervel\Saloon\SaloonManager clearFake()
+ * @method static \Hypervel\Saloon\Http\PendingRequest createPendingRequest(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request)
* @method static \Hypervel\Saloon\Http\Faking\MockClient fake(array|\Hypervel\Saloon\Http\Faking\MockClient $responses = [])
* @method static \Hypervel\Saloon\SaloonManager fixturePath(string $path)
* @method static string getFixturePath()
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index a86b0cf77b..5f6c165fa4 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -56,6 +56,23 @@ public function send(Request $request, ?MockClient $mockClient = null): Response
return $manager->send($this, $request, $mockClient);
}
+ /**
+ * Prepare a request for sending through this connector.
+ *
+ * The pending request has run its hooks and request middleware but has not been finalized or sent.
+ *
+ * @template TRequestDto
+ * @param Request $request
+ * @return PendingRequest
+ */
+ public function createPendingRequest(Request $request): PendingRequest
+ {
+ /** @var SaloonManager $manager */
+ $manager = Container::getInstance()->make('saloon');
+
+ return $manager->createPendingRequest($this, $request);
+ }
+
/**
* Create a bounded request pool.
*
diff --git a/src/saloon/src/Http/HeaderNormalizer.php b/src/saloon/src/Http/HeaderNormalizer.php
index 04b4aa506a..e15cf0d642 100644
--- a/src/saloon/src/Http/HeaderNormalizer.php
+++ b/src/saloon/src/Http/HeaderNormalizer.php
@@ -12,22 +12,35 @@ final class HeaderNormalizer
/**
* Normalize request header values.
*
- * @param array $headers
+ * @param array $headers
* @return array|string>
*/
public static function normalize(array $headers): array
{
- foreach ($headers as $name => $value) {
- if (! is_string($name)) {
- throw new InvalidHeaderException('HTTP header names must be strings.');
- }
+ self::ensureValidNames($headers);
+ foreach ($headers as $name => $value) {
$headers[$name] = self::value($value);
}
return $headers;
}
+ /**
+ * Ensure every header is keyed by its name.
+ *
+ * @param array $headers
+ * @phpstan-assert array $headers
+ */
+ public static function ensureValidNames(array $headers): void
+ {
+ foreach (array_keys($headers) as $name) {
+ if (! is_string($name)) {
+ throw new InvalidHeaderException('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].');
+ }
+ }
+ }
+
/**
* Normalize one request header value.
*
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index ee62af66e8..e681d136e2 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -48,9 +48,20 @@ class PendingRequest
withQueryParameters as protected addQueryParameters;
withoutQueryParameters as protected removeQueryParameters;
withQueryString as protected replaceQueryString;
+ withUrlParameters as protected addUrlParameters;
}
use Macroable;
+ /**
+ * The HTTP method used by the operation.
+ */
+ protected Method $method;
+
+ /**
+ * The absolute URL override.
+ */
+ protected ?string $url;
+
/**
* The finalized request URI.
*/
@@ -102,6 +113,11 @@ class PendingRequest
*/
protected ?FakeResponse $fakeResponse = null;
+ /**
+ * Whether the connector and request plugins are booting.
+ */
+ protected bool $bootingPlugins = false;
+
/**
* Create a side-effect-free pending request.
*
@@ -113,10 +129,16 @@ public function __construct(
protected CacheFactory $cache,
protected RateLimiter $rateLimiter,
) {
- $this->headerRepository = new ArrayRepository(array_merge(
- $connector->headers(),
- $request->headers(),
- ));
+ $this->method = $request->method();
+ $this->url = $request->url();
+ $this->urlParameters = $request->urlParameters();
+ $headers = array_merge($connector->headers(), $request->headers());
+
+ // Plugins and authenticators match header names case-insensitively, so headers written as a list such as
+ // ['Accept: application/json'] are rejected here with a clear message instead of failing inside them.
+ HeaderNormalizer::ensureValidNames($headers);
+
+ $this->headerRepository = new ArrayRepository($headers);
$this->queryRepository = new ArrayRepository(array_merge(
$connector->queryParameters(),
$request->queryParameters(),
@@ -170,7 +192,54 @@ public function request(): Request
*/
public function method(): Method
{
- return $this->request->method();
+ return $this->method;
+ }
+
+ /**
+ * Change the HTTP method used by the operation.
+ *
+ * @return $this
+ */
+ public function withMethod(Method $method): static
+ {
+ $this->method = $method;
+
+ return $this;
+ }
+
+ /**
+ * Get the absolute URL override.
+ */
+ public function url(): ?string
+ {
+ return $this->url;
+ }
+
+ /**
+ * Replace the connector base URL and request endpoint for this operation.
+ *
+ * @return $this
+ */
+ public function withUrl(string $url): static
+ {
+ $this->uri = null;
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * Specify the URL parameters and invalidate the finalized URI.
+ *
+ * @param array $parameters
+ * @return $this
+ */
+ public function withUrlParameters(array $parameters = []): static
+ {
+ $this->uri = null;
+ $this->addUrlParameters($parameters);
+
+ return $this;
}
/**
@@ -182,12 +251,11 @@ public function uri(): UriInterface
return $this->uri;
}
- $url = $this->request->url();
- $uri = $url !== null
- ? UrlResolver::resolve('', $url, false)
+ $uri = $this->url !== null
+ ? UrlResolver::resolve('', UrlResolver::expand($this->url, $this->urlParameters), false)
: UrlResolver::resolve(
- $this->connector->resolveBaseUrl(),
- $this->request->resolveEndpoint(),
+ UrlResolver::expand($this->connector->resolveBaseUrl(), $this->urlParameters),
+ UrlResolver::expand($this->request->resolveEndpoint(), $this->urlParameters),
$this->request->allowsBaseUrlOverride() ?? $this->connector->allowsBaseUrlOverride(),
);
$query = $this->queryString();
@@ -250,20 +318,28 @@ public function withQueryString(string $query): static
}
/**
- * Authenticate the pending request immediately.
+ * Authenticate the pending request.
+ *
+ * The authenticator is applied immediately, except while plugins boot: it is then applied once every plugin has
+ * booted.
*
* @return $this
*/
public function authenticate(Authenticator $authenticator): static
{
$this->setAuthenticator($authenticator);
- $authenticator->set($this);
+
+ // A later plugin may still change the URL or headers the authenticator reads, such as an API version in the
+ // host, so applyAuthentication() applies the selected authenticator after the plugins boot.
+ if (! $this->bootingPlugins) {
+ $authenticator->set($this);
+ }
return $this;
}
/**
- * Apply the configured authenticator.
+ * Apply the selected authenticator.
*/
public function applyAuthentication(): static
{
@@ -277,7 +353,13 @@ public function applyAuthentication(): static
*/
public function bootPlugins(): static
{
- (new BootPlugins)($this);
+ $this->bootingPlugins = true;
+
+ try {
+ (new BootPlugins)($this);
+ } finally {
+ $this->bootingPlugins = false;
+ }
return $this;
}
diff --git a/src/saloon/src/Http/SoloRequest.php b/src/saloon/src/Http/SoloRequest.php
index 1c1661dac6..fe73d7e55e 100644
--- a/src/saloon/src/Http/SoloRequest.php
+++ b/src/saloon/src/Http/SoloRequest.php
@@ -5,7 +5,7 @@
namespace Hypervel\Saloon\Http;
use Hypervel\Saloon\Http\Connectors\NullConnector;
-use Hypervel\Saloon\Http\Faking\MockClient;
+use Hypervel\Saloon\Traits\Request\HasConnector;
/**
* @template TDto
@@ -13,28 +13,8 @@
*/
abstract class SoloRequest extends Request
{
- /**
- * The connector used by the standalone request.
- */
- protected ?Connector $connector = null;
-
- /**
- * Get the connector used by the standalone request.
- */
- public function connector(): Connector
- {
- return $this->connector ??= $this->resolveConnector();
- }
-
- /**
- * Send the standalone request.
- *
- * @return Response
- */
- public function send(?MockClient $mockClient = null): Response
- {
- return $this->connector()->send($this, $mockClient);
- }
+ /** @use HasConnector */
+ use HasConnector;
/**
* Resolve whether this request may use an absolute endpoint.
diff --git a/src/saloon/src/Http/UrlResolver.php b/src/saloon/src/Http/UrlResolver.php
index 8466f7d6cb..d655a4bd30 100644
--- a/src/saloon/src/Http/UrlResolver.php
+++ b/src/saloon/src/Http/UrlResolver.php
@@ -5,16 +5,39 @@
namespace Hypervel\Saloon\Http;
use GuzzleHttp\Psr7\Uri;
+use GuzzleHttp\UriTemplate\UriTemplate;
use Hypervel\Saloon\Exceptions\PendingRequestException;
use Psr\Http\Message\UriInterface;
final class UrlResolver
{
+ /**
+ * Substitute the URL parameters in the given URL.
+ *
+ * @param array $parameters
+ */
+ public static function expand(string $url, array $parameters): string
+ {
+ if ($parameters === [] || ! str_contains($url, '{')) {
+ return $url;
+ }
+
+ return UriTemplate::expand($url, $parameters);
+ }
+
/**
* Resolve the connector base URL and request endpoint.
*/
public static function resolve(string $baseUrl, string $endpoint, bool $allowBaseUrlOverride): UriInterface
{
+ $absolute = preg_match('~^[A-Za-z][A-Za-z0-9+.-]*://~', $endpoint) === 1;
+
+ // Only a scheme followed by "//" names another host. Other endpoints are paths, so they are parsed with one
+ // leading slash: URI parsing would read "documents:batchGet" as a scheme and "//users" as a host.
+ if (! $absolute && $endpoint !== '' && ! str_starts_with($endpoint, '?') && ! str_starts_with($endpoint, '#')) {
+ $endpoint = '/' . ltrim($endpoint, '/');
+ }
+
$base = new Uri($baseUrl);
$endpointUri = new Uri($endpoint);
@@ -22,11 +45,11 @@ public static function resolve(string $baseUrl, string $endpoint, bool $allowBas
self::ensureAbsoluteHttpUri($base, 'connector base URL');
}
- if ($endpointUri->getScheme() !== '' || $endpointUri->getHost() !== '') {
+ if ($absolute) {
self::ensureAbsoluteHttpUri($endpointUri, 'request endpoint');
if ($baseUrl !== '' && ! $allowBaseUrlOverride) {
- throw new PendingRequestException('The request endpoint cannot replace the connector base URL.');
+ throw new PendingRequestException('The request endpoint cannot replace the connector base URL. To request a different host, use a connector with that host as its base URL, or return true from allowsBaseUrlOverride() on the connector or request when the endpoint is trusted.');
}
return $endpointUri;
@@ -69,6 +92,10 @@ public static function withQuery(UriInterface $uri, array $parameters): UriInter
$retained = [];
foreach ($pairs as $pair) {
+ if ($pair === '') {
+ continue;
+ }
+
$encodedName = explode('=', $pair, 2)[0];
$name = urldecode($encodedName);
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index 0cde149de9..ae5898fd96 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -95,20 +95,7 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
while (true) {
++$attempt;
- $pendingRequest = new PendingRequest(
- $connector,
- $request,
- $this->cache,
- $this->rateLimiter,
- );
- $pendingRequest
- ->applyAuthentication()
- ->bootPlugins();
- $connector->boot($pendingRequest);
- $request->boot($pendingRequest);
- $pendingRequest
- ->mergeMiddleware($this->middleware)
- ->executeRequestPipeline();
+ $pendingRequest = $this->createPendingRequest($connector, $request);
if ($this->events->hasListeners(SendingSaloonRequest::class)) {
$this->events->dispatch(new SendingSaloonRequest($pendingRequest));
@@ -249,6 +236,30 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
}
}
+ /**
+ * Prepare a request for sending through a connector.
+ *
+ * The pending request has run its plugins, authenticator, boot hooks, and global and request middleware, in that
+ * order. It has not been finalized, matched against mock responses or sent.
+ *
+ * @template TDto
+ * @param Request $request
+ * @return PendingRequest
+ */
+ public function createPendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ $pendingRequest = new PendingRequest($connector, $request, $this->cache, $this->rateLimiter);
+ $pendingRequest
+ ->bootPlugins()
+ ->applyAuthentication();
+ $connector->boot($pendingRequest);
+ $request->boot($pendingRequest);
+
+ return $pendingRequest
+ ->mergeMiddleware($this->middleware)
+ ->executeRequestPipeline();
+ }
+
/**
* Get the Saloon sender.
*/
diff --git a/src/saloon/src/Traits/Plugins/HasApiVersion.php b/src/saloon/src/Traits/Plugins/HasApiVersion.php
new file mode 100644
index 0000000000..4b5d8f6dbb
--- /dev/null
+++ b/src/saloon/src/Traits/Plugins/HasApiVersion.php
@@ -0,0 +1,71 @@
+apiVersion;
+ }
+
+ /**
+ * Boot HasApiVersion plugin.
+ */
+ public function bootHasApiVersion(PendingRequest $pendingRequest): void
+ {
+ $version = $this->getApiVersion();
+
+ if ($version === null || $version === '') {
+ return;
+ }
+
+ // Replacing the header lets a request's version override its connector's, since added headers append.
+ match ($this->versionMode) {
+ VersionMode::Header => $pendingRequest->replaceHeaders([$this->versionKey => $version]),
+ VersionMode::QueryParam => $pendingRequest->withQueryParameters([$this->versionKey => $version]),
+ VersionMode::Url => $pendingRequest->withUrlParameters(['version' => $this->ensureVersionIsSafeForUrl($version)]),
+ };
+ }
+
+ /**
+ * 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/src/saloon/src/Traits/Request/HasConnector.php b/src/saloon/src/Traits/Request/HasConnector.php
new file mode 100644
index 0000000000..bd795e0211
--- /dev/null
+++ b/src/saloon/src/Traits/Request/HasConnector.php
@@ -0,0 +1,67 @@
+loadedConnector ??= $this->resolveConnector();
+ }
+
+ /**
+ * Set the loaded connector at runtime.
+ *
+ * @return $this
+ */
+ public function setConnector(Connector $connector): static
+ {
+ $this->loadedConnector = $connector;
+
+ return $this;
+ }
+
+ /**
+ * Create a new connector instance.
+ */
+ protected function resolveConnector(): Connector
+ {
+ return new $this->connector;
+ }
+
+ /**
+ * Prepare the request for sending through its connector.
+ *
+ * @return PendingRequest
+ */
+ public function createPendingRequest(): PendingRequest
+ {
+ return $this->connector()->createPendingRequest($this);
+ }
+
+ /**
+ * Send the request through its connector.
+ *
+ * @return Response
+ */
+ public function send(?MockClient $mockClient = null): Response
+ {
+ return $this->connector()->send($this, $mockClient);
+ }
+}
diff --git a/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php b/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php
index 01bbbd019f..0dc35c155f 100644
--- a/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php
+++ b/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php
@@ -13,4 +13,5 @@ trait HasRequestProperties
use HasOptions;
use HasQuery;
use HasRetryPolicy;
+ use HasUrlParameters;
}
diff --git a/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php b/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php
new file mode 100644
index 0000000000..967f39606a
--- /dev/null
+++ b/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php
@@ -0,0 +1,39 @@
+
+ */
+ protected array $urlParameters = [];
+
+ /**
+ * Get the parameters substituted into URL templates.
+ *
+ * @return array
+ */
+ public function urlParameters(): array
+ {
+ return $this->urlParameters;
+ }
+
+ /**
+ * Specify the URL parameters that can be substituted into the request URL.
+ *
+ * @param array $parameters
+ * @return $this
+ */
+ public function withUrlParameters(array $parameters = []): static
+ {
+ // Replace by key so numeric template names such as {1} are not renumbered.
+ $this->urlParameters = array_replace($this->urlParameters, $parameters);
+
+ return $this;
+ }
+}
diff --git a/tests/Integration/Engine/Fixtures/http_server_v2.php b/tests/Integration/Engine/Fixtures/http_server_v2.php
index 85f6332960..90d7b78a9f 100644
--- a/tests/Integration/Engine/Fixtures/http_server_v2.php
+++ b/tests/Integration/Engine/Fixtures/http_server_v2.php
@@ -59,6 +59,17 @@
'file_contents' => $file === null ? null : file_get_contents($file['tmp_name']),
]));
})(),
+ '/user' => (function () use ($response): void {
+ // Return the user from Saloon's test API
+ $response->setHeader('Content-Type', 'application/json');
+ $response->end(json_encode(['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam']));
+ })(),
+ '/error' => (function () use ($response): void {
+ // Fail like the error endpoint of Saloon's test API
+ $response->status(500);
+ $response->setHeader('Content-Type', 'application/json');
+ $response->end(json_encode(['message' => 'Server Error']));
+ })(),
default => (function () use ($request, $response) {
$body = $request->rawContent();
$ret = 'Hello World.';
diff --git a/tests/Integration/Saloon/Feature/RequestTest.php b/tests/Integration/Saloon/Feature/RequestTest.php
new file mode 100644
index 0000000000..b553f78cdd
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/RequestTest.php
@@ -0,0 +1,105 @@
+setUpInteractsWithServer();
+ }
+
+ public function testARequestCanBeMadeSuccessfully(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $response = $connector->send(new UserRequest);
+
+ $data = $response->json();
+
+ // Upstream also asserts that the pending request is not asynchronous; coroutine pools replace sendAsync().
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(200, $response->status());
+
+ $this->assertSame([
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ], $data);
+ }
+
+ public function testARequestCanHandleAnExceptionProperly(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $response = $connector->send(new ErrorRequest);
+
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(500, $response->status());
+ }
+
+ public function testARequestWithHasConnectorCanBeSentIndividually(): void
+ {
+ $request = new HasConnectorUserRequest;
+
+ $this->assertInstanceOf(TestConnector::class, $request->connector());
+
+ // The engine test server replaces the connector's default URL. Upstream also asserts the connector's
+ // sender; Hypervel connectors send through the framework HTTP client instead.
+ $request->setConnector(new TestConnector($this->serverUrl()));
+
+ $this->assertInstanceOf(PendingRequest::class, $request->createPendingRequest());
+
+ $response = $request->send();
+
+ $data = $response->json();
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(200, $response->status());
+
+ $this->assertSame([
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ], $data);
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/Feature/SoloRequestTest.php b/tests/Integration/Saloon/Feature/SoloRequestTest.php
new file mode 100644
index 0000000000..7e982fa636
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/SoloRequestTest.php
@@ -0,0 +1,77 @@
+setUpInteractsWithServer();
+ }
+
+ public function testASoloRequestCanBeSentSynchronously(): void
+ {
+ $request = new SoloUserRequest($this->serverUrl());
+ $response = $request->send();
+
+ $data = $response->json();
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(200, $response->status());
+
+ $this->assertSame([
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ], $data);
+ }
+
+ public function testASynchronousSoloRequestCanHandleAnExceptionProperly(): void
+ {
+ $request = new SoloErrorRequest($this->serverUrl());
+ $response = $request->send();
+
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(500, $response->status());
+ }
+
+ // REMOVED: "a solo request can be sent asynchronously" and "a asynchronous solo request can handle an exception
+ // property". Coroutine pools replace promises and sendAsync().
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/Fixtures/Requests/SoloErrorRequest.php b/tests/Integration/Saloon/Fixtures/Requests/SoloErrorRequest.php
new file mode 100644
index 0000000000..02fbc3cf63
--- /dev/null
+++ b/tests/Integration/Saloon/Fixtures/Requests/SoloErrorRequest.php
@@ -0,0 +1,33 @@
+serverUrl . '/error';
+ }
+}
diff --git a/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php b/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php
new file mode 100644
index 0000000000..984ca1fc86
--- /dev/null
+++ b/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php
@@ -0,0 +1,33 @@
+serverUrl . '/user';
+ }
+}
diff --git a/tests/Saloon/Feature/BaseResourceTest.php b/tests/Saloon/Feature/BaseResourceTest.php
new file mode 100644
index 0000000000..d3008c8505
--- /dev/null
+++ b/tests/Saloon/Feature/BaseResourceTest.php
@@ -0,0 +1,40 @@
+assertSame([
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ], $connector->user()->get());
+ }
+}
diff --git a/tests/Saloon/Http/SoloRequestTest.php b/tests/Saloon/Feature/SoloRequestTest.php
similarity index 51%
rename from tests/Saloon/Http/SoloRequestTest.php
rename to tests/Saloon/Feature/SoloRequestTest.php
index 1d245ca031..91457366f7 100644
--- a/tests/Saloon/Http/SoloRequestTest.php
+++ b/tests/Saloon/Feature/SoloRequestTest.php
@@ -2,26 +2,28 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Feature;
-use Hypervel\Container\Container;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
-use Hypervel\Contracts\Config\Repository as ConfigRepository;
-use Hypervel\Events\Dispatcher;
-use Hypervel\Http\Client\Factory;
-use Hypervel\RateLimiter\RateLimiter;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Connectors\NullConnector;
use Hypervel\Saloon\Http\Faking\MockClient;
use Hypervel\Saloon\Http\Faking\MockResponse;
-use Hypervel\Saloon\Http\Sender;
use Hypervel\Saloon\Http\SoloRequest;
-use Hypervel\Saloon\SaloonManager;
-use Hypervel\Tests\TestCase;
-use Mockery as m;
+use Hypervel\Saloon\SaloonServiceProvider;
+use Hypervel\Testbench\TestCase;
+// Upstream's cases send real requests and are in tests/Integration/Saloon/Feature/SoloRequestTest.php.
class SoloRequestTest extends TestCase
{
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+
public function testItSendsAnAbsoluteEndpointThroughTheNormalLifecycle(): void
{
$request = new SoloRequestStub;
@@ -36,26 +38,14 @@ public function testItSendsAnAbsoluteEndpointThroughTheNormalLifecycle(): void
$this->assertSame('complete', $response->body());
}
- protected function setUp(): void
+ public function testItCanBePreparedThroughItsConnector(): void
{
- parent::setUp();
+ $request = new SoloRequestStub;
+ $pendingRequest = $request->createPendingRequest();
- $http = new Factory;
- $http->registerConnection('saloon');
- $config = m::mock(ConfigRepository::class);
- $config->shouldReceive('string')
- ->with('saloon.connection.name')
- ->andReturn('saloon');
- $manager = new SaloonManager(
- new Sender($http, $config),
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- $config,
- new Dispatcher,
- );
- $container = new Container;
- $container->instance('saloon', $manager);
- Container::setInstance($container);
+ $this->assertSame($request, $pendingRequest->request());
+ $this->assertSame($request->connector(), $pendingRequest->connector());
+ $this->assertSame('https://api.example.com/users', (string) $pendingRequest->uri());
}
}
@@ -63,6 +53,9 @@ class SoloRequestStub extends SoloRequest
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return 'https://api.example.com/users';
diff --git a/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php b/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php
new file mode 100644
index 0000000000..f5d7d42883
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php
@@ -0,0 +1,27 @@
+baseUrl;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/CustomResponseConnector.php b/tests/Saloon/Fixtures/Connectors/CustomResponseConnector.php
new file mode 100644
index 0000000000..359d8cdb24
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/CustomResponseConnector.php
@@ -0,0 +1,21 @@
+request();
+
+ $pendingRequest->withHeader('X-Plugin-User-Id', $request->userId);
+ $pendingRequest->withHeader('X-Plugin-Group-Id', $request->groupId);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php b/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php
new file mode 100644
index 0000000000..6686a9a78a
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php
@@ -0,0 +1,45 @@
+endpoint;
+ }
+
+ /**
+ * Resolve whether this request may replace the connector base URL.
+ */
+ public function allowsBaseUrlOverride(): ?bool
+ {
+ return $this->allowBaseUrlOverride;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php b/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php
new file mode 100644
index 0000000000..b916c4e0f9
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php
@@ -0,0 +1,39 @@
+endpoint;
+ }
+
+ /**
+ * Set an endpoint.
+ */
+ public function setEndpoint(string $endpoint): CustomEndpointRequest
+ {
+ $this->endpoint = $endpoint;
+
+ return $this;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php b/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php
new file mode 100644
index 0000000000..80ec2e6cba
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php
@@ -0,0 +1,26 @@
+connector->send(new UserRequest)->json();
+ }
+}
diff --git a/tests/Saloon/Fixtures/Responses/CustomResponse.php b/tests/Saloon/Fixtures/Responses/CustomResponse.php
new file mode 100644
index 0000000000..f28fa3de93
--- /dev/null
+++ b/tests/Saloon/Fixtures/Responses/CustomResponse.php
@@ -0,0 +1,11 @@
+json('foo'));
+ }
+
+ /**
+ * Get the foo value.
+ */
+ public function foo(): ?string
+ {
+ return $this->json('foo');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php b/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php
new file mode 100644
index 0000000000..310ff0b0e1
--- /dev/null
+++ b/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php
@@ -0,0 +1,9 @@
+expectException(InvalidHeaderException::class);
- $this->expectExceptionMessage('HTTP header names must be strings.');
-
- HeaderNormalizer::normalize(['Content-Type', 'application/json']);
- }
-}
diff --git a/tests/Saloon/Http/RequestTest.php b/tests/Saloon/Http/RequestTest.php
deleted file mode 100644
index ea866564c3..0000000000
--- a/tests/Saloon/Http/RequestTest.php
+++ /dev/null
@@ -1,230 +0,0 @@
-make(ContainerRequestStub::class);
- $second = $container->make(ContainerRequestStub::class);
-
- $this->assertNotSame($first, $second);
- }
-
- public function testMakeForwardsRequiredConstructorArguments(): void
- {
- $request = RequiredArgumentRequestStub::make('users/1');
-
- $this->assertSame('users/1', $request->resolveEndpoint());
- }
-
- public function testContainerResolutionOfRequiredArgumentRequestFailsNaturally(): void
- {
- $container = new Container;
-
- $this->expectException(ArgumentCountError::class);
-
- $container->make(RequiredArgumentRequestStub::class);
- }
-
- public function testReplaceHeadersMatchesNamesCaseInsensitively(): void
- {
- $request = (new ContainerRequestStub)
- ->withHeaders([
- 'Authorization' => 'Bearer old',
- 'X-Keep' => 'yes',
- ])
- ->replaceHeaders([
- 'authorization' => 'Bearer new',
- 'AUTHORIZATION' => 'Bearer newest',
- ]);
-
- $this->assertSame([
- 'X-Keep' => 'yes',
- 'AUTHORIZATION' => 'Bearer newest',
- ], $request->headers());
- }
-
- public function testIncomingHeaderWinsWhenAnExistingCaseVariantFollowsItsExactName(): void
- {
- $request = (new ContainerRequestStub)
- ->withHeaders([
- 'Authorization' => 'Bearer stale exact',
- 'authorization' => 'Bearer stale variant',
- 'X-Keep' => 'yes',
- ])
- ->replaceHeaders([
- 'Authorization' => 'Bearer new',
- ]);
-
- $this->assertSame([
- 'X-Keep' => 'yes',
- 'Authorization' => 'Bearer new',
- ], $request->headers());
- }
-
- public function testWithHeadersRemainsAdditive(): void
- {
- $request = (new ContainerRequestStub)
- ->withHeaders(['X-Value' => 'first'])
- ->withHeaders(['X-Value' => 'second']);
-
- $this->assertSame(['first', 'second'], $request->headers()['X-Value']);
- }
-
- public function testAcceptReplacesTheExistingAcceptHeader(): void
- {
- $request = (new ContainerRequestStub)
- ->accept('text/plain')
- ->acceptJson();
-
- $this->assertSame('application/json', $request->headers()['Accept']);
- }
-
- #[DataProvider('invalidCookies')]
- public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage($message);
-
- (new ContainerRequestStub)->withCookie(new SetCookie($cookie));
- }
-
- /**
- * Provide cookies that cannot be sent with a request.
- */
- public static function invalidCookies(): array
- {
- return [
- 'null domain' => [
- ['Name' => 'locale', 'Value' => 'en'],
- 'An outgoing cookie must have a domain.',
- ],
- 'empty domain' => [
- ['Name' => 'locale', 'Value' => 'en', 'Domain' => ''],
- 'Invalid cookie: The cookie domain must not be empty',
- ],
- 'null value' => [
- ['Name' => 'locale', 'Domain' => 'api.example.com'],
- 'Invalid cookie: The cookie value must not be empty',
- ],
- ];
- }
-
- public function testCloneOwnsIndependentInitializedRequestState(): void
- {
- $this->assertNull((new ContainerRequestStub)->url());
- $request = (new ContainerRequestStub)
- ->withUrl('https://api.example.test/users/1')
- ->withHeader('X-Original', 'yes')
- ->withQueryParameters(['page' => 1])
- ->withOptions(['verify' => true])
- ->delay(10)
- ->withCookies(['original' => 'yes'], '.example.test')
- ->retry([10])
- ->withData(['original' => true])
- ->disableCaching();
- $request->middleware()->onRequest(static fn ($pendingRequest) => $pendingRequest, 'original');
-
- $clone = clone $request;
- $clone
- ->withUrl('https://api.example.test/users/2')
- ->withHeader('X-Clone', 'yes')
- ->withQueryParameters(['page' => 2])
- ->withOptions(['verify' => false])
- ->delay(20)
- ->withCookies(['clone' => 'yes'], 'api.example.test')
- ->retry(3, 20)
- ->withData(['clone' => true])
- ->enableCaching()
- ->invalidateCache();
- $clone->middleware()->onRequest(static fn ($pendingRequest) => $pendingRequest, 'clone');
-
- $this->assertSame('https://api.example.test/users/1', $request->url());
- $this->assertSame('https://api.example.test/users/2', $clone->url());
- $this->assertSame('yes', $request->headers()['X-Original']);
- $this->assertSame('application/json', $request->headers()['Content-Type']);
- $this->assertSame(['page' => 1], $request->queryParameters());
- $this->assertTrue($request->options()['verify']);
- $this->assertSame(10, $request->delayMilliseconds());
- $this->assertCount(1, $request->middleware()->requestPipeline()->pipes());
- $this->assertSame(['original' => true], $request->body());
- $this->assertSame(CookieJar::fromArray(['original' => 'yes'], '.example.test')->toArray(), $request->cookies());
- $this->assertSame([10], $request->retryPolicy()->times);
- $this->assertFalse($request->cachingEnabled());
- $this->assertFalse($request->shouldInvalidateCache());
-
- $this->assertSame(['original' => true, 'clone' => true], $clone->body());
- $this->assertCount(2, $clone->cookies());
- $this->assertSame(3, $clone->retryPolicy()->times);
- $this->assertTrue($clone->cachingEnabled());
- $this->assertTrue($clone->shouldInvalidateCache());
- }
-
- public function testRawQueryDefaultsOverridesAndCloneIsolation(): void
- {
- $this->assertNull((new ContainerRequestStub)->queryString());
- $request = new RawQueryRequestStub;
- $this->assertSame('tag=a&tag=b', $request->queryString());
- $request->withQueryString('cursor=a%2Fb')->withQueryParameters(['limit' => 10]);
- $clone = clone $request;
-
- $this->assertSame($clone, $clone->withQueryString(''));
- $this->assertSame('', $clone->queryString());
- $this->assertSame('cursor=a%2Fb', $request->queryString());
- $this->assertSame(['limit' => 10], $clone->queryParameters());
- }
-}
-
-class ContainerRequestStub extends Request
-{
- use HasCaching;
-
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return 'users';
- }
-}
-
-class RawQueryRequestStub extends ContainerRequestStub
-{
- /**
- * Resolve the default raw query string override.
- */
- protected function defaultQueryString(): ?string
- {
- return 'tag=a&tag=b';
- }
-}
-
-class RequiredArgumentRequestStub extends Request
-{
- protected Method $method = Method::GET;
-
- public function __construct(protected string $endpoint)
- {
- }
-
- public function resolveEndpoint(): string
- {
- return $this->endpoint;
- }
-}
diff --git a/tests/Saloon/Http/UrlResolverTest.php b/tests/Saloon/Http/UrlResolverTest.php
deleted file mode 100644
index fb767c8c89..0000000000
--- a/tests/Saloon/Http/UrlResolverTest.php
+++ /dev/null
@@ -1,142 +0,0 @@
-assertSame('https://api.example.com/v1/users?active=1', (string) $uri);
- }
-
- public function testEmptyEndpointPreservesTheConnectorBasePath(): void
- {
- $this->assertSame(
- 'https://api.example.com/v1',
- (string) UrlResolver::resolve('https://api.example.com/v1', '', false),
- );
- $this->assertSame(
- 'https://api.example.com/v1/',
- (string) UrlResolver::resolve('https://api.example.com/v1', '/', false),
- );
- }
-
- public function testBaseAndEndpointQueriesAreRetainedBeforeRepositoryOverrides(): void
- {
- $this->assertSame(
- 'https://api.example.com/v1/users?key=base&keep=base',
- (string) UrlResolver::resolve('https://api.example.com/v1?key=base&keep=base', '/users', false),
- );
-
- $uri = UrlResolver::resolve(
- 'https://api.example.com/v1?key=base&keep=base',
- '/users?endpoint=value',
- false,
- );
-
- $this->assertSame('key=base&keep=base&endpoint=value', $uri->getQuery());
- $this->assertSame(
- 'keep=base&endpoint=value&key=repository',
- UrlResolver::withQuery($uri, ['key' => 'repository'])->getQuery(),
- );
- }
-
- public function testAbsoluteEndpointRequiresAnExplicitOverride(): void
- {
- $this->expectException(PendingRequestException::class);
-
- UrlResolver::resolve('https://api.example.com', 'https://uploads.example.com/files', false);
- }
-
- public function testAbsoluteHttpEndpointCanBeExplicitlyAllowed(): void
- {
- $uri = UrlResolver::resolve('https://api.example.com', 'https://uploads.example.com/files', true);
-
- $this->assertSame('https://uploads.example.com/files', (string) $uri);
- }
-
- public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void
- {
- $this->expectException(PendingRequestException::class);
-
- UrlResolver::resolve('', '/users', true);
- }
-
- public function testAlternateSchemesAreRejected(): void
- {
- $this->expectException(PendingRequestException::class);
-
- UrlResolver::resolve('', 'file:///etc/passwd', true);
- }
-
- public function testRepositoryQueryReplacesExactAndNestedRawFamilies(): void
- {
- $uri = UrlResolver::resolve(
- 'https://api.example.com',
- '/users?filter=old&filter%5Bname%5D=Taylor&keep=one&keep=two&flag',
- false,
- );
-
- $resolved = UrlResolver::withQuery($uri, [
- 'filter' => ['name' => 'Abigail'],
- 'page' => 2,
- ]);
-
- $this->assertSame(
- 'keep=one&keep=two&flag&filter%5Bname%5D=Abigail&page=2',
- $resolved->getQuery(),
- );
- }
-
- public function testNullAndEmptyArrayValuesRemoveMatchingRawFamilies(): void
- {
- $uri = UrlResolver::resolve(
- 'https://api.example.com',
- '/users?remove=one&remove%5Bnested%5D=two&empty%5B0%5D=value&keep=yes',
- false,
- );
-
- $resolved = UrlResolver::withQuery($uri, ['remove' => null, 'empty' => []]);
-
- $this->assertSame('keep=yes', $resolved->getQuery());
- }
-
- public function testDecodedNamesUseQueryStringSemanticsWithoutNormalizingRetainedPairs(): void
- {
- $uri = UrlResolver::resolve(
- 'https://api.example.com',
- '/users?first+name=old&retained=a+b&literal=%7Bvalue%7D',
- false,
- );
-
- $resolved = UrlResolver::withQuery($uri, ['first name' => 'Taylor']);
-
- $this->assertSame('retained=a+b&literal=%7Bvalue%7D&first%20name=Taylor', $resolved->getQuery());
- }
-
- public function testStructuredValuesAreNormalized(): void
- {
- $uri = UrlResolver::resolve('https://api.example.com', '/users', false);
-
- $resolved = UrlResolver::withQuery($uri, [
- 'collection' => new Collection(['one', 'two']),
- 'stringable' => new Stringable('value'),
- 'not-a-number' => NAN,
- ]);
-
- $this->assertSame(
- 'collection%5B0%5D=one&collection%5B1%5D=two&stringable=value¬-a-number=NAN',
- $resolved->getQuery(),
- );
- }
-}
diff --git a/tests/Saloon/SaloonManagerTest.php b/tests/Saloon/SaloonManagerTest.php
index 832f889d54..29e4f8cf92 100644
--- a/tests/Saloon/SaloonManagerTest.php
+++ b/tests/Saloon/SaloonManagerTest.php
@@ -5,12 +5,14 @@
namespace Hypervel\Tests\Saloon;
use Carbon\CarbonInterval;
+use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Events\Dispatcher;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Client\Request as HttpRequest;
use Hypervel\RateLimiter\RateLimiter;
+use Hypervel\Saloon\Contracts\Authenticator;
use Hypervel\Saloon\Contracts\FakeResponse;
use Hypervel\Saloon\Contracts\RequestMiddleware;
use Hypervel\Saloon\Enums\Method;
@@ -18,7 +20,6 @@
use Hypervel\Saloon\Events\SentSaloonRequest;
use Hypervel\Saloon\Exceptions\Request\FatalRequestException;
use Hypervel\Saloon\Exceptions\Request\RequestException;
-use Hypervel\Saloon\Http\Auth\TokenAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Faking\MockResponse;
use Hypervel\Saloon\Http\PendingRequest;
@@ -273,16 +274,22 @@ public function testConnectorAndRequestCanBothEnableAutomaticThrowing(): void
public function testPluginAuthenticationIsAppliedOnce(): void
{
$http = $this->http();
- $authorization = null;
- $http->fake(function (HttpRequest $request) use (&$authorization) {
- $authorization = $request->header('Authorization');
+ $sent = null;
+ $http->fake(function (HttpRequest $request) use (&$sent): PromiseInterface {
+ $sent = $request;
return Factory::response();
});
- $this->manager($http)->send(new ManagerConnectorStub, new PluginAuthenticatedManagerRequestStub);
+ $this->manager($http)->send(
+ new ManagerConnectorStub,
+ (new PluginAuthenticatedManagerRequestStub)->withToken('configured'),
+ );
- $this->assertSame(['Bearer secret'], $authorization);
+ // The plugin's authenticator adds its header, so applying it twice would repeat the value. The configured
+ // token it replaced is not applied.
+ $this->assertSame(['secret'], $sent->header('X-Api-Key'));
+ $this->assertFalse($sent->hasHeader('Authorization'));
}
public function testSendingAndSentEventsArePairedForFakeResponses(): void
@@ -483,9 +490,31 @@ class BodyManagerRequestStub extends ManagerRequestStub
trait AppliesManagerAuthentication
{
+ /**
+ * Authenticate the pending request with an API key.
+ */
public function bootAppliesManagerAuthentication(PendingRequest $pendingRequest): void
{
- $pendingRequest->authenticate(new TokenAuthenticator('secret'));
+ $pendingRequest->authenticate(new ApiKeyManagerAuthenticator('secret'));
+ }
+}
+
+readonly class ApiKeyManagerAuthenticator implements Authenticator
+{
+ /**
+ * Create an API key authenticator.
+ */
+ public function __construct(
+ public string $key,
+ ) {
+ }
+
+ /**
+ * Apply the authentication to the request.
+ */
+ public function set(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->withHeader('X-Api-Key', $this->key);
}
}
diff --git a/tests/Saloon/Unit/Concerns/HasApiVersionTest.php b/tests/Saloon/Unit/Concerns/HasApiVersionTest.php
new file mode 100644
index 0000000000..0d3f68449c
--- /dev/null
+++ b/tests/Saloon/Unit/Concerns/HasApiVersionTest.php
@@ -0,0 +1,378 @@
+sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('anthropic-version', $response->toPsrRequest()->getHeaderLine('api-version'));
+ }
+
+ public function testQueryParamModeAppendsTheVersionToTheQueryString(): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com', VersionMode::QueryParam, '2026-10'),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('api-version=2026-10', $response->toPsrRequest()->getUri()->getQuery());
+ }
+
+ public function testUrlModeReplacesTheVersionPlaceholderInThePath(): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::Url, 'v1beta'),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('https://generativelanguage.googleapis.com/v1beta', (string) $response->pendingRequest()->uri());
+ $this->assertSame('https://generativelanguage.googleapis.com/v1beta', (string) $response->toPsrRequest()->getUri());
+ }
+
+ public function testUrlModeReplacesTheVersionPlaceholderInTheHost(): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Url, 'v2'),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('https://v2.api.provider.com', (string) $response->pendingRequest()->uri());
+ $this->assertSame('https://v2.api.provider.com', (string) $response->toPsrRequest()->getUri());
+ }
+
+ #[DataProvider('commonVersionFormats')]
+ public function testUrlModeAcceptsCommonVersionFormats(string $version): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, $version),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('https://api.provider.com/' . $version, (string) $response->pendingRequest()->uri());
+ }
+
+ /**
+ * Get common API version formats.
+ *
+ * @return array
+ */
+ public static function commonVersionFormats(): array
+ {
+ return [['v1'], ['v1beta'], ['v2.1'], ['2026-10-01'], ['v1_alpha']];
+ }
+
+ #[DataProvider('unsafeVersions')]
+ public function testUrlModeRejectsVersionsThatCouldAlterTheSchemeHostOrPath(string $baseUrl, string $version): void
+ {
+ $mockClient = new MockClient([MockResponse::make()]);
+
+ $connector = $this->makeVersionedConnector($baseUrl, VersionMode::Url, $version);
+
+ try {
+ $connector->send($this->makeVersionedRequest(), $mockClient);
+
+ $this->fail('The unsafe version was accepted.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame(
+ 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),
+ $exception->getMessage(),
+ );
+ }
+
+ $mockClient->assertNothingSent();
+ }
+
+ /**
+ * Get versions that could change the request target.
+ *
+ * @return array
+ */
+ public static function unsafeVersions(): array
+ {
+ return [
+ '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'],
+ ];
+ }
+
+ // Upstream changes the connector's version with setApiVersion(). Connectors are read-only, so a connector
+ // supplies a different version by overriding getApiVersion().
+ public function testTheConnectorCanOverrideTheApiVersion(): void
+ {
+ $connector = new class('https://api.provider.com', VersionMode::Header, 'anthropic-version') extends VersionedConnector {
+ /**
+ * Get the API version.
+ */
+ public function getApiVersion(): ?string
+ {
+ return 'override-version';
+ }
+ };
+
+ $response = $this->sendVersionedRequest($connector, $this->makeVersionedRequest());
+
+ $this->assertSame('override-version', $response->toPsrRequest()->getHeaderLine('api-version'));
+ }
+
+ public function testTheRequestCanOverrideTheApiVersionAtRuntime(): void
+ {
+ $request = $this->makeVersionedRequest('override-version', VersionMode::Header);
+
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com'),
+ $request,
+ );
+
+ $this->assertSame('override-version', $response->toPsrRequest()->getHeaderLine('api-version'));
+ }
+
+ #[DataProvider('versionModes')]
+ public function testARequestVersionReplacesTheConnectorVersion(VersionMode $versionMode): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com/{version}', $versionMode, 'connector-version'),
+ $this->makeVersionedRequest('request-version', $versionMode),
+ );
+ $psrRequest = $response->toPsrRequest();
+
+ match ($versionMode) {
+ VersionMode::Header => $this->assertSame(['request-version'], $psrRequest->getHeader('api-version')),
+ VersionMode::QueryParam => $this->assertSame('api-version=request-version', $psrRequest->getUri()->getQuery()),
+ VersionMode::Url => $this->assertSame('https://api.provider.com/request-version', (string) $psrRequest->getUri()),
+ };
+ }
+
+ public function testAVersionOfZeroIsApplied(): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com', VersionMode::Header, '0'),
+ $this->makeVersionedRequest(),
+ );
+
+ $this->assertSame('0', $response->toPsrRequest()->getHeaderLine('api-version'));
+ }
+
+ public function testRemovingAQueryParameterKeepsAUrlVersion(): void
+ {
+ $request = $this->makeVersionedRequest()->withQueryParameters(['page' => 2]);
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ $pendingRequest->withoutQueryParameters('page');
+ });
+
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, 'v1'),
+ $request,
+ );
+
+ $this->assertSame('https://api.provider.com/v1', (string) $response->toPsrRequest()->getUri());
+ }
+
+ #[DataProvider('cookieAuthenticatedVersions')]
+ public function testCookieAuthenticationUsesTheVersionedHost(string $connectorClass, ?string $requestVersion, string $url): void
+ {
+ $sent = null;
+ Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface {
+ $sent = $request;
+
+ return Http::response();
+ });
+
+ $connector = new $connectorClass('https://{version}.api.provider.com', VersionMode::Url, 'v1');
+ $connector->send($this->makeVersionedRequest($requestVersion, VersionMode::Url));
+
+ $this->assertSame($url, $sent->url());
+ $this->assertSame(['session=secret'], $sent->header('Cookie'));
+ }
+
+ /**
+ * Get cookie-authenticated connectors and the request versions sent through them.
+ *
+ * @return array, null|string, string}>
+ */
+ public static function cookieAuthenticatedVersions(): array
+ {
+ return [
+ 'configured authenticator' => [CookieVersionedConnector::class, null, 'https://v1.api.provider.com'],
+ 'configured authenticator and request version' => [CookieVersionedConnector::class, 'v2', 'https://v2.api.provider.com'],
+ 'plugin authenticator and request version' => [PluginCookieVersionedConnector::class, 'v2', 'https://v2.api.provider.com'],
+ ];
+ }
+
+ #[DataProvider('versionModes')]
+ public function testWithoutAVersionItDoesNotAddAHeaderQueryParameterOrReplaceTheUrl(VersionMode $versionMode): void
+ {
+ $response = $this->sendVersionedRequest(
+ $this->makeVersionedConnector('https://api.provider.com/{version}', $versionMode),
+ $this->makeVersionedRequest(null, $versionMode),
+ );
+
+ $this->assertSame('', $response->toPsrRequest()->getHeaderLine('api-version'));
+ $this->assertStringNotContainsString('api-version', $response->toPsrRequest()->getUri()->getQuery());
+ // URI parsing encodes the braces of the placeholder that was left in the path.
+ $this->assertSame('https://api.provider.com/%7Bversion%7D', (string) $response->pendingRequest()->uri());
+ }
+
+ /**
+ * Get the API version modes.
+ *
+ * @return array
+ */
+ public static function versionModes(): array
+ {
+ return [
+ 'header' => [VersionMode::Header],
+ 'query param' => [VersionMode::QueryParam],
+ 'url' => [VersionMode::Url],
+ ];
+ }
+
+ /**
+ * Create a connector that applies an API version.
+ */
+ protected function makeVersionedConnector(string $baseUrl, VersionMode $versionMode = VersionMode::Header, ?string $apiVersion = null): Connector
+ {
+ return new VersionedConnector($baseUrl, $versionMode, $apiVersion);
+ }
+
+ /**
+ * Create a request that applies an API version.
+ */
+ protected function makeVersionedRequest(?string $apiVersion = null, VersionMode $versionMode = VersionMode::Header): Request
+ {
+ return new class($versionMode, $apiVersion) extends Request {
+ use HasApiVersion;
+
+ protected Method $method = Method::GET;
+
+ /**
+ * Create a new request instance.
+ */
+ public function __construct(
+ VersionMode $versionMode,
+ ?string $apiVersion,
+ ) {
+ $this->versionMode = $versionMode;
+ $this->apiVersion = $apiVersion;
+ }
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '';
+ }
+ };
+ }
+
+ /**
+ * Send a versioned request through a mock client.
+ */
+ protected function sendVersionedRequest(Connector $connector, Request $request): Response
+ {
+ return $connector->send($request, new MockClient([
+ MockResponse::make(),
+ ]));
+ }
+}
+
+class VersionedConnector extends Connector
+{
+ use HasApiVersion;
+
+ /**
+ * Create a new connector instance.
+ */
+ public function __construct(
+ private readonly string $baseUrl,
+ VersionMode $versionMode,
+ ?string $apiVersion,
+ ) {
+ $this->versionMode = $versionMode;
+ $this->apiVersion = $apiVersion;
+ }
+
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return $this->baseUrl;
+ }
+}
+
+class CookieVersionedConnector extends VersionedConnector
+{
+ /**
+ * Resolve the default authenticator.
+ */
+ protected function defaultAuth(): ?Authenticator
+ {
+ return new CookieAuthenticator('session', 'secret');
+ }
+}
+
+trait AuthenticatesWithSessionCookie
+{
+ /**
+ * Authenticate the pending request with a session cookie.
+ */
+ public function bootAuthenticatesWithSessionCookie(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->authenticate(new CookieAuthenticator('session', 'secret'));
+ }
+}
+
+class PluginCookieVersionedConnector extends VersionedConnector
+{
+ use AuthenticatesWithSessionCookie;
+}
diff --git a/tests/Saloon/Unit/ConditionableTest.php b/tests/Saloon/Unit/ConditionableTest.php
new file mode 100644
index 0000000000..58c767fc98
--- /dev/null
+++ b/tests/Saloon/Unit/ConditionableTest.php
@@ -0,0 +1,146 @@
+when(true, function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Sam');
+ });
+
+ $request->when(false, function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Alex');
+ });
+
+ $this->assertSame(['X-Name' => 'Sam'], $request->headers());
+ }
+
+ public function testYouCanUseTheUnlessMethodToInvokeACallbackWhenAGivenConditionIsFalsy(): void
+ {
+ $request = new UserRequest;
+
+ $request->unless(true, function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Sam');
+ });
+
+ $request->unless(false, function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Alex');
+ });
+
+ $this->assertSame(['X-Name' => 'Alex'], $request->headers());
+ }
+
+ public function testYouCanProvideACallbackAsTheValueOfTheWhenCondition(): void
+ {
+ $request = new UserRequest;
+
+ $request->when(
+ fn (): bool => true,
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Sam');
+ }
+ );
+
+ $this->assertSame(['X-Name' => 'Sam'], $request->headers());
+ }
+
+ public function testYouCanProvideACallbackAsTheValueOfTheUnlessCondition(): void
+ {
+ $request = new UserRequest;
+
+ $request->unless(
+ fn (): bool => false,
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Alex');
+ }
+ );
+
+ $this->assertSame(['X-Name' => 'Alex'], $request->headers());
+ }
+
+ public function testYouCanProvideACallbackAsTheDefaultValueOfTheWhenCondition(): void
+ {
+ $request = new UserRequest;
+
+ $request->when(
+ false,
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Sam');
+ },
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Alex');
+ }
+ );
+
+ $this->assertSame(['X-Name' => 'Alex'], $request->headers());
+ }
+
+ public function testYouCanProvideACallbackAsTheDefaultValueOfTheUnlessCondition(): void
+ {
+ $request = new UserRequest;
+
+ $request->unless(
+ true,
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Sam');
+ },
+ function (UserRequest $request): void {
+ $request->withHeader('X-Name', 'Alex');
+ }
+ );
+
+ $this->assertSame(['X-Name' => 'Alex'], $request->headers());
+ }
+
+ public function testItWillPassTheConditionValueAsTheSecondArgumentOfTheCallable(): void
+ {
+ $request = new UserRequest;
+ $values = [];
+
+ // Upstream asserts inside the callbacks, which would also pass if they never ran.
+ $request->when(true, function (UserRequest $request, mixed $value) use (&$values): void {
+ $values[] = $value;
+ });
+
+ $request->unless(false, function (UserRequest $request, mixed $value) use (&$values): void {
+ $values[] = $value;
+ });
+
+ $this->assertSame([true, false], $values);
+ }
+
+ public function testItWillPassTheConditionValueAsTheSecondArgumentOfTheDefaultCallable(): void
+ {
+ $request = new UserRequest;
+ $values = [];
+
+ $request->when(
+ false,
+ function (UserRequest $request, mixed $value): void {
+ },
+ function (UserRequest $request, mixed $value) use (&$values): void {
+ $values[] = $value;
+ }
+ );
+
+ $request->unless(
+ true,
+ function (UserRequest $request, mixed $value): void {
+ },
+ function (UserRequest $request, mixed $value) use (&$values): void {
+ $values[] = $value;
+ }
+ );
+
+ $this->assertSame([false, true], $values);
+ }
+}
diff --git a/tests/Saloon/Unit/ConnectorTest.php b/tests/Saloon/Unit/ConnectorTest.php
new file mode 100644
index 0000000000..2c4dc61d06
--- /dev/null
+++ b/tests/Saloon/Unit/ConnectorTest.php
@@ -0,0 +1,77 @@
+assertInstanceOf(TestConnector::class, $connectorA);
+
+ $connectorB = RequestSelectionConnector::make('yee-haw-1-2-3');
+
+ $this->assertInstanceOf(RequestSelectionConnector::class, $connectorB);
+ $this->assertSame('yee-haw-1-2-3', $connectorB->apiKey);
+ }
+
+ public function testTheSameConnectorInstanceIsKeptIfYouInstantiateItOnTheRequestWithHasConnector(): void
+ {
+ $request = new HasConnectorUserRequest;
+ $connector = $request->connector();
+
+ $this->assertInstanceOf(TestConnector::class, $connector);
+ $this->assertSame($connector, $request->connector());
+
+ $replacement = new TestConnector('https://other.example.com');
+
+ $this->assertSame($request, $request->setConnector($replacement));
+ $this->assertSame($replacement, $request->connector());
+ }
+
+ public function testYouCanSendARequestThroughTheConnector(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']),
+ ]);
+
+ $connector = new TestConnector;
+ $response = $connector->send(new UserRequest, $mockClient);
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam'], $response->json());
+ }
+
+ // REMOVED: "you can send an asynchronous request through the connector". Coroutine pools replace promises and sendAsync().
+
+ public function testConnectorsAreMacroable(): void
+ {
+ TestConnector::macro('apiUrl', function (): string {
+ return $this->resolveBaseUrl();
+ });
+
+ $this->assertSame(TestConnector::API_URL, (new TestConnector)->apiUrl());
+ }
+}
diff --git a/tests/Saloon/Http/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
similarity index 61%
rename from tests/Saloon/Http/PendingRequestTest.php
rename to tests/Saloon/Unit/PendingRequestTest.php
index 4467b5778f..b11de35687 100644
--- a/tests/Saloon/Http/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -2,12 +2,16 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Http\Client\Request as HttpRequest;
use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Contracts\Body\BodyRepository;
use Hypervel\Saloon\Enums\Method;
+use Hypervel\Saloon\Events\SendingSaloonRequest;
+use Hypervel\Saloon\Exceptions\InvalidHeaderException;
use Hypervel\Saloon\Exceptions\MissingAuthenticatorException;
use Hypervel\Saloon\Exceptions\PendingRequestException;
use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
@@ -19,16 +23,158 @@
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Repositories\Body\StringBodyRepository;
+use Hypervel\Saloon\SaloonServiceProvider;
use Hypervel\Saloon\Traits\Auth\RequiresAuth;
use Hypervel\Saloon\Traits\Body\HasJsonBody;
use Hypervel\Saloon\Traits\Body\HasStringBody;
-use Hypervel\Tests\TestCase;
+use Hypervel\Support\Facades\Event;
+use Hypervel\Support\Facades\Http;
+use Hypervel\Support\Stringable;
+use Hypervel\Testbench\TestCase;
+use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
+use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Http\Message\StreamInterface;
+use stdClass;
class PendingRequestTest extends TestCase
{
+ public function testYouCanOverwriteTheUrlAndTheMethodOfThePendingRequest(): void
+ {
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $connector = new TestConnector;
+ $request = new UserRequest;
+
+ // Connectors are read-only, so the middleware is registered on the request instead of the connector.
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ $pendingRequest->withUrl('https://other-endpoint.co.uk' . $pendingRequest->request()->resolveEndpoint());
+ $pendingRequest->withMethod(Method::POST);
+ });
+
+ $this->assertSame(Method::GET, $request->method());
+
+ $response = $connector->send($request);
+ $pendingRequest = $response->pendingRequest();
+
+ $this->assertSame('https://other-endpoint.co.uk/user', (string) $pendingRequest->uri());
+ $this->assertSame(Method::POST, $pendingRequest->method());
+ $this->assertSame(Method::GET, $request->method());
+ $this->assertNull($request->url());
+ Http::assertSent(fn (HttpRequest $sent): bool => $sent->method() === 'POST'
+ && $sent->url() === 'https://other-endpoint.co.uk/user');
+ }
+
+ public function testThePendingRequestIsMacroable(): void
+ {
+ PendingRequest::macro('yee', fn (): string => 'haw');
+
+ $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest);
+
+ $this->assertSame('haw', $pendingRequest->yee());
+ }
+
+ public function testThePendingRequestValidatesProperlyFormedHeaders(): void
+ {
+ $request = (new UserRequest)->withHeaders([
+ 'Content-Type: application/json',
+ ]);
+
+ $this->expectException(InvalidHeaderException::class);
+ $this->expectExceptionMessage('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].');
+
+ (new TestConnector)->createPendingRequest($request);
+ }
+
+ public function testHeadersWrittenAsAListAreRejectedBeforePluginsMatchHeaderNames(): void
+ {
+ $request = (new PendingRequestRequestStub)->withHeaders(['Accept: application/json']);
+
+ $this->expectException(InvalidHeaderException::class);
+
+ (new PendingRequestConnectorStub)->createPendingRequest($request);
+ }
+
+ public function testHeaderValuesAreNormalizedForTheOutgoingRequest(): void
+ {
+ $pendingRequest = (new TestConnector)->createPendingRequest((new UserRequest)->withHeaders([
+ 'X-Null' => null,
+ 'X-Empty' => [],
+ 'X-List' => ['one', 2, true],
+ 'X-Stringable' => new Stringable('value'),
+ 'X-Float' => 1.5,
+ 'X-Not-A-Number' => NAN,
+ 'X-Infinite' => -INF,
+ ]));
+
+ $headers = $pendingRequest->toPsrRequest()->getHeaders();
+
+ $this->assertSame([''], $headers['X-Null']);
+ $this->assertSame([''], $headers['X-Empty']);
+ $this->assertSame(['one', '2', '1'], $headers['X-List']);
+ $this->assertSame(['value'], $headers['X-Stringable']);
+ $this->assertSame(['1.5'], $headers['X-Float']);
+ $this->assertSame(['NAN'], $headers['X-Not-A-Number']);
+ $this->assertSame(['-INF'], $headers['X-Infinite']);
+ }
+
+ public function testHeaderValuesMustBeScalarNullOrStringable(): void
+ {
+ $pendingRequest = (new TestConnector)->createPendingRequest(
+ (new UserRequest)->withHeader('X-Object', new stdClass),
+ );
+
+ $this->expectException(InvalidHeaderException::class);
+ $this->expectExceptionMessage('HTTP header values must be scalar, null, Hypervel Stringable, or arrays of those values.');
+
+ $pendingRequest->toPsrRequest();
+ }
+
+ public function testPreparingARequestRunsItsHooksAndMiddlewareWithoutSendingIt(): void
+ {
+ Http::fake();
+ Event::fake([SendingSaloonRequest::class]);
+
+ $request = (new PendingRequestRequestStub)->withData(['name' => 'Taylor']);
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void {
+ ++PendingRequestRequestStub::$middlewareCalls;
+ });
+
+ $pendingRequest = (new PendingRequestConnectorStub)->createPendingRequest($request);
+
+ $this->assertSame(1, PendingRequestConnectorStub::$bootCalls);
+ $this->assertSame(1, PendingRequestRequestStub::$bootCalls);
+ $this->assertSame(1, PendingRequestRequestStub::$middlewareCalls);
+ $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']);
+ $this->assertNull($pendingRequest->preparedBody());
+ Event::assertNotDispatched(SendingSaloonRequest::class);
+ Http::assertNothingSent();
+ }
+
+ public function testUrlParametersAreExpandedWithoutChangingTheRequest(): void
+ {
+ $request = (new PendingRequestRequestStub)
+ ->withUrl('https://{region}.example.com/{version}/users')
+ ->withUrlParameters(['region' => 'eu', 'version' => 'v1']);
+ $pendingRequest = $this->pendingRequest(new PendingRequestConnectorWithoutBodyStub, $request);
+
+ $this->assertSame('https://eu.example.com/v1/users', (string) $pendingRequest->finalizeUri()->uri());
+
+ $pendingRequest->withUrlParameters(['version' => 'v2']);
+
+ $this->assertSame('https://eu.example.com/v2/users', (string) $pendingRequest->uri());
+ $this->assertSame(['region' => 'eu', 'version' => 'v1'], $request->urlParameters());
+ }
+
+ public function testUrlParametersAreExpandedInTheBaseUrlAndEndpoint(): void
+ {
+ $request = (new PendingTemplateRequestStub)->withUrlParameters(['tenant' => 'acme', 'id' => 'a b/c']);
+ $pendingRequest = $this->pendingRequest(new PendingTemplateConnectorStub, $request);
+
+ $this->assertSame('https://acme.example.com/v1/users/a%20b%2Fc', (string) $pendingRequest->uri());
+ }
+
public function testConstructionOnlySnapshotsOperationState(): void
{
$connector = new PendingRequestConnectorStub;
@@ -222,6 +368,14 @@ public function testAuthenticatorsReplaceLogicalHeadersRegardlessOfCase(): void
], $pendingRequest->headers());
}
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+
protected function setUp(): void
{
parent::setUp();
@@ -251,31 +405,49 @@ class PendingRequestConnectorStub extends Connector
public static int $bootCalls = 0;
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com/v1';
}
+ /**
+ * Configure a pending request for this resource.
+ */
public function boot(PendingRequest $pendingRequest): void
{
++static::$bootCalls;
}
+ /**
+ * Resolve the default headers.
+ */
protected function defaultHeaders(): array
{
return ['X-Connector' => 'connector'];
}
+ /**
+ * Resolve the default query parameters.
+ */
protected function defaultQuery(): array
{
return ['version' => 1];
}
+ /**
+ * Resolve the default request options.
+ */
protected function defaultOptions(): array
{
return ['allow_redirects' => ['strict' => true]];
}
+ /**
+ * Resolve the default JSON body.
+ */
protected function defaultBody(): array
{
return ['connector' => true];
@@ -293,6 +465,30 @@ public function resolveBaseUrl(): string
}
}
+class PendingTemplateConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://{tenant}.example.com/v1';
+ }
+}
+
+class PendingTemplateRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users/{id}';
+ }
+}
+
class PendingRawQueryRequestStub extends Request
{
protected Method $method = Method::GET;
@@ -324,11 +520,17 @@ class PendingRequestRequestStub extends Request
protected Method $method = Method::POST;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
}
+ /**
+ * Configure a pending request for this resource.
+ */
public function boot(PendingRequest $pendingRequest): void
{
++static::$bootCalls;
@@ -341,6 +543,9 @@ class PendingRequestStringBodyStub extends Request
protected Method $method = Method::POST;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
@@ -349,6 +554,9 @@ public function resolveEndpoint(): string
class PendingRequestConnectorWithoutBodyStub extends Connector
{
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
@@ -359,11 +567,17 @@ class PendingRequestCountingBodyStub extends Request
{
protected Method $method = Method::POST;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
}
+ /**
+ * Resolve the default body repository.
+ */
protected function defaultBodyRepository(): ?BodyRepository
{
return new CountingBodyRepository('prepared');
@@ -374,6 +588,9 @@ class CountingBodyRepository extends StringBodyRepository
{
public static int $streamCalls = 0;
+ /**
+ * Convert the body repository into a stream.
+ */
public function toStream(): StreamInterface
{
++static::$streamCalls;
@@ -388,11 +605,17 @@ class CustomRequiresAuthRequestStub extends Request
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
}
+ /**
+ * Get the missing authenticator message.
+ */
protected function getRequiresAuthMessage(PendingRequest $pendingRequest): string
{
return 'Custom authentication is required.';
diff --git a/tests/Saloon/Unit/PluginTest.php b/tests/Saloon/Unit/PluginTest.php
new file mode 100644
index 0000000000..56e048c742
--- /dev/null
+++ b/tests/Saloon/Unit/PluginTest.php
@@ -0,0 +1,45 @@
+createPendingRequest($request);
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame(1, $headers['X-Plugin-User-Id']);
+ $this->assertSame(2, $headers['X-Plugin-Group-Id']);
+ }
+
+ public function testSubRequestDoesNotNeedToUsePlugins(): void
+ {
+ $request = new SubRequest(1, 2);
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame(1, $headers['X-Plugin-User-Id']);
+ $this->assertSame(2, $headers['X-Plugin-Group-Id']);
+ }
+}
diff --git a/tests/Saloon/Unit/RequestCloneTest.php b/tests/Saloon/Unit/RequestCloneTest.php
new file mode 100644
index 0000000000..34a5fd6f08
--- /dev/null
+++ b/tests/Saloon/Unit/RequestCloneTest.php
@@ -0,0 +1,179 @@
+withQueryParameters(['key' => 'value']);
+
+ $a = clone $original;
+ $b = clone $original;
+
+ $a->withQueryParameters(['page' => 1]);
+ $b->withQueryParameters(['page' => 2]);
+
+ $this->assertSame(1, $a->queryParameters()['page']);
+ $this->assertSame(2, $b->queryParameters()['page']);
+ $this->assertArrayNotHasKey('page', $original->queryParameters());
+ $this->assertSame('value', $original->queryParameters()['key']);
+ }
+
+ public function testCloningARequestAfterHeadersConfigAndDelayAreInitializedGivesIndependentStores(): void
+ {
+ $original = (new UserRequest)
+ ->withHeader('X-Test', 'one')
+ ->withOptions(['timeout' => 10])
+ ->delay(5);
+
+ $clone = clone $original;
+
+ // Upstream's header store replaces a value on add; Hypervel's withHeader appends, so the clone replaces it.
+ $clone
+ ->replaceHeaders(['X-Test' => 'two'])
+ ->withOptions(['timeout' => 20])
+ ->delay(15);
+
+ $this->assertSame('one', $original->headers()['X-Test']);
+ $this->assertSame('two', $clone->headers()['X-Test']);
+ $this->assertSame(10, $original->options()['timeout']);
+ $this->assertSame(20, $clone->options()['timeout']);
+ $this->assertSame(5, $original->delayMilliseconds());
+ $this->assertSame(15, $clone->delayMilliseconds());
+ }
+
+ public function testCloningARequestAfterMiddlewareIsInitializedGivesIndependentPipelines(): void
+ {
+ $original = new UserRequest;
+ $original->middleware()->onResponse(static fn (Response $response): Response => $response);
+
+ $clone = clone $original;
+
+ $this->assertNotSame($original->middleware(), $clone->middleware());
+ }
+
+ public function testConcurrentPoolSendsWithClonedRequestsDoNotShareQueryMutation(): void
+ {
+ $sequence = [];
+
+ for ($i = 0; $i < 10; ++$i) {
+ $sequence[] = MockResponse::make(['ok' => true]);
+ }
+
+ // Connectors are read-only, so the shared mock client is attached to the request that is cloned.
+ $base = (new UserRequest)
+ ->withMockClient(new MockClient($sequence))
+ ->withQueryParameters(['key' => 'value']);
+
+ $requests = [];
+
+ for ($i = 1; $i <= 10; ++$i) {
+ $requests[] = (clone $base)->withQueryParameters(['page' => $i]);
+ }
+
+ $pagesSeen = [];
+
+ $pool = (new TestConnector)->pool($requests, 5);
+ $pool->withResponseHandler(function (Response $response) use (&$pagesSeen): void {
+ $pagesSeen[] = (int) $response->request()->queryParameters()['page'];
+ });
+
+ $pool->send();
+
+ $this->assertCount(10, $pagesSeen);
+ $this->assertCount(10, array_unique($pagesSeen));
+ }
+
+ public function testCloneOwnsIndependentInitializedRequestState(): void
+ {
+ $this->assertNull((new CloneRequestStub)->url());
+ $request = (new CloneRequestStub)
+ ->withUrl('https://api.example.test/users/1')
+ ->withHeader('X-Original', 'yes')
+ ->withQueryParameters(['page' => 1])
+ ->withUrlParameters(['version' => 'v1'])
+ ->withOptions(['verify' => true])
+ ->delay(10)
+ ->withCookies(['original' => 'yes'], '.example.test')
+ ->retry([10])
+ ->withData(['original' => true])
+ ->disableCaching();
+ $request->middleware()->onRequest(static fn (PendingRequest $pendingRequest): PendingRequest => $pendingRequest, 'original');
+
+ $clone = clone $request;
+ $clone
+ ->withUrl('https://api.example.test/users/2')
+ ->withHeader('X-Clone', 'yes')
+ ->withQueryParameters(['page' => 2])
+ ->withUrlParameters(['version' => 'v2'])
+ ->withOptions(['verify' => false])
+ ->delay(20)
+ ->withCookies(['clone' => 'yes'], 'api.example.test')
+ ->retry(3, 20)
+ ->withData(['clone' => true])
+ ->enableCaching()
+ ->invalidateCache();
+ $clone->middleware()->onRequest(static fn (PendingRequest $pendingRequest): PendingRequest => $pendingRequest, 'clone');
+
+ $this->assertSame('https://api.example.test/users/1', $request->url());
+ $this->assertSame('https://api.example.test/users/2', $clone->url());
+ $this->assertSame('yes', $request->headers()['X-Original']);
+ $this->assertSame('application/json', $request->headers()['Content-Type']);
+ $this->assertSame(['page' => 1], $request->queryParameters());
+ $this->assertSame(['version' => 'v1'], $request->urlParameters());
+ $this->assertTrue($request->options()['verify']);
+ $this->assertSame(10, $request->delayMilliseconds());
+ $this->assertCount(1, $request->middleware()->requestPipeline()->pipes());
+ $this->assertSame(['original' => true], $request->body());
+ $this->assertSame(CookieJar::fromArray(['original' => 'yes'], '.example.test')->toArray(), $request->cookies());
+ $this->assertSame([10], $request->retryPolicy()->times);
+ $this->assertFalse($request->cachingEnabled());
+ $this->assertFalse($request->shouldInvalidateCache());
+
+ $this->assertSame(['original' => true, 'clone' => true], $clone->body());
+ $this->assertCount(2, $clone->cookies());
+ $this->assertSame(3, $clone->retryPolicy()->times);
+ $this->assertTrue($clone->cachingEnabled());
+ $this->assertTrue($clone->shouldInvalidateCache());
+ }
+}
+
+class CloneRequestStub extends Request
+{
+ use HasCaching;
+
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return 'users';
+ }
+}
diff --git a/tests/Saloon/Unit/RequestProperties/HeadersTest.php b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
index 46ead57b90..50b63a59fd 100644
--- a/tests/Saloon/Unit/RequestProperties/HeadersTest.php
+++ b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
@@ -114,6 +114,51 @@ public function testAddedHeadersAppendWhileReplacementsAndRemovalsIgnoreCase():
$this->assertFalse($request->hasHeader('X-Custom-Header'));
}
+ public function testReplaceHeadersMatchesNamesCaseInsensitively(): void
+ {
+ $request = (new UserRequest)
+ ->withHeaders([
+ 'Authorization' => 'Bearer old',
+ 'X-Keep' => 'yes',
+ ])
+ ->replaceHeaders([
+ 'authorization' => 'Bearer new',
+ 'AUTHORIZATION' => 'Bearer newest',
+ ]);
+
+ $this->assertSame([
+ 'X-Keep' => 'yes',
+ 'AUTHORIZATION' => 'Bearer newest',
+ ], $request->headers());
+ }
+
+ public function testIncomingHeaderWinsWhenAnExistingCaseVariantFollowsItsExactName(): void
+ {
+ $request = (new UserRequest)
+ ->withHeaders([
+ 'Authorization' => 'Bearer stale exact',
+ 'authorization' => 'Bearer stale variant',
+ 'X-Keep' => 'yes',
+ ])
+ ->replaceHeaders([
+ 'Authorization' => 'Bearer new',
+ ]);
+
+ $this->assertSame([
+ 'X-Keep' => 'yes',
+ 'Authorization' => 'Bearer new',
+ ], $request->headers());
+ }
+
+ public function testAcceptReplacesTheExistingAcceptHeader(): void
+ {
+ $request = (new UserRequest)
+ ->accept('text/plain')
+ ->acceptJson();
+
+ $this->assertSame('application/json', $request->headers()['Accept']);
+ }
+
/**
* Create a pending request with isolated framework dependencies.
*/
diff --git a/tests/Saloon/Unit/RequestProperties/QueryTest.php b/tests/Saloon/Unit/RequestProperties/QueryTest.php
index cd0b5395a8..95c6fd83b2 100644
--- a/tests/Saloon/Unit/RequestProperties/QueryTest.php
+++ b/tests/Saloon/Unit/RequestProperties/QueryTest.php
@@ -6,6 +6,7 @@
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\RateLimiter\RateLimiter;
+use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Request;
@@ -118,6 +119,20 @@ public function testRemovingAQueryParameterKeepsValuesWrittenInTheEndpointOrQuer
$this->assertSame('sort=raw', $pendingRequest->uri()->getQuery());
}
+ public function testRawQueryDefaultsOverridesAndCloneIsolation(): void
+ {
+ $this->assertNull((new QueryParameterRequest)->queryString());
+ $request = new RawQueryRequestStub;
+ $this->assertSame('tag=a&tag=b', $request->queryString());
+ $request->withQueryString('cursor=a%2Fb')->withQueryParameters(['limit' => 10]);
+ $clone = clone $request;
+
+ $this->assertSame($clone, $clone->withQueryString(''));
+ $this->assertSame('', $clone->queryString());
+ $this->assertSame('cursor=a%2Fb', $request->queryString());
+ $this->assertSame(['limit' => 10], $clone->queryParameters());
+ }
+
/**
* Create a pending request with isolated framework dependencies.
*/
@@ -131,3 +146,24 @@ protected function pendingRequest(Connector $connector, Request $request): Pendi
);
}
}
+
+class RawQueryRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return 'users';
+ }
+
+ /**
+ * Resolve the default raw query string override.
+ */
+ protected function defaultQueryString(): ?string
+ {
+ return 'tag=a&tag=b';
+ }
+}
diff --git a/tests/Saloon/Unit/RequestTest.php b/tests/Saloon/Unit/RequestTest.php
new file mode 100644
index 0000000000..c939798a06
--- /dev/null
+++ b/tests/Saloon/Unit/RequestTest.php
@@ -0,0 +1,290 @@
+assertFalse($request->hasMockClient());
+ $this->assertFalse((new TestConnector)->send($request)->isMocked());
+ }
+
+ public function testYouCanPassAMockClientToTheSaloonRequestAndItWillBeInMockMode(): void
+ {
+ $request = new UserRequest;
+ $mockClient = new MockClient([MockResponse::make([])]);
+
+ $request->withMockClient($mockClient);
+
+ $this->assertTrue($request->hasMockClient());
+ $this->assertSame($mockClient, $request->mockClient());
+ $this->assertTrue((new TestConnector)->send($request)->isMocked());
+ }
+
+ public function testYouCantSendARequestWithAMockClientWithoutAnyResponses(): void
+ {
+ $mockClient = new MockClient;
+ $request = new UserRequest;
+
+ $this->expectException(NoMockResponseFoundException::class);
+
+ (new TestConnector)->send($request, $mockClient);
+ }
+
+ public function testSaloonWorksWithACustomResponseClassInConnector(): void
+ {
+ $connector = new CustomResponseConnector;
+
+ // Hypervel passes the base response so the class may depend on it.
+ $this->assertSame(CustomResponse::class, $connector->resolveResponseClass($this->response()));
+ }
+
+ public function testSaloonCanHandleWithCustomResponseInConnector(): void
+ {
+ $request = new CustomResponseConnectorRequest;
+ $response = (new CustomResponseConnector)->send($request, new MockClient([MockResponse::make([])]));
+
+ $this->assertInstanceOf(CustomResponse::class, $response);
+ }
+
+ public function testSaloonCanHandleWithCustomResponseInRequest(): void
+ {
+ $request = new UserRequestWithCustomResponse;
+
+ $this->assertSame(UserResponse::class, $request->resolveResponseClass($this->response()));
+ }
+
+ public function testSaloonThrowsAnExceptionIfTheCustomResponseIsNotAResponseClass(): void
+ {
+ $invalidConnectorClassRequest = new InvalidResponseClass;
+
+ $this->expectException(InvalidResponseClassException::class);
+
+ $connector = new TestConnector;
+
+ $connector->send($invalidConnectorClassRequest, new MockClient([
+ InvalidResponseClass::class => MockResponse::make([], 200),
+ ]));
+ }
+
+ public function testDefineEndpointMethodMayBeBlankInRequestClassToUseTheBaseUrl(): void
+ {
+ $pendingRequest = (new TestConnector)->createPendingRequest(new DefaultEndpointRequest);
+
+ $this->assertSame(TestConnector::API_URL, (string) $pendingRequest->uri());
+ }
+
+ public function testARequestClassCanBeInstantiatedUsingTheMakeMethod(): void
+ {
+ $requestA = UserRequest::make();
+
+ $this->assertInstanceOf(UserRequest::class, $requestA);
+ $this->assertNull($requestA->userId);
+ $this->assertNull($requestA->groupId);
+
+ $requestB = UserRequest::make(1, 2);
+
+ $this->assertInstanceOf(UserRequest::class, $requestB);
+ $this->assertSame(1, $requestB->userId);
+ $this->assertSame(2, $requestB->groupId);
+
+ $this->assertSame('users/1', RequiredArgumentRequestStub::make('users/1')->resolveEndpoint());
+ }
+
+ #[DataProvider('urlsToJoin')]
+ public function testYouCanJoinVariousUrlsTogether(string $baseUrl, string $endpoint, string $expected): void
+ {
+ $connector = new CustomBaseUrlConnector($baseUrl);
+ $request = new CustomEndpointRequest;
+
+ $request->setEndpoint($endpoint);
+
+ $this->assertSame($expected, (string) $connector->createPendingRequest($request)->uri());
+ }
+
+ /**
+ * Get the base URLs and endpoints to join.
+ *
+ * @return array
+ */
+ public static function urlsToJoin(): array
+ {
+ return [
+ ['https://google.com', '/search', 'https://google.com/search'],
+ ['https://google.com', 'search', 'https://google.com/search'],
+ ['https://google.com/', '/search', 'https://google.com/search'],
+ ['https://google.com/', 'search', 'https://google.com/search'],
+ ['https://google.com//', '//search', 'https://google.com/search'],
+ ];
+ }
+
+ public function testARelativeEndpointCannotBeJoinedToAnEmptyBaseUrl(): void
+ {
+ // Upstream's last join case returns the unsendable relative URL "/google.com/search".
+ $request = (new CustomEndpointRequest)->setEndpoint('google.com/search');
+ $pendingRequest = (new CustomBaseUrlConnector)->createPendingRequest($request);
+
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
+
+ $pendingRequest->uri();
+ }
+
+ public function testItThrowsAnExceptionIfYouForgetToAddAMethod(): void
+ {
+ $connector = new TestConnector;
+ $request = new MissingMethodRequest;
+
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('Your request is missing an HTTP method. Add a method property such as [protected Method $method = Method::GET].');
+
+ $connector->send($request);
+ }
+
+ public function testContainerResolutionAlwaysReturnsAFreshRequest(): void
+ {
+ $container = new Container;
+
+ $first = $container->make(ContainerRequestStub::class);
+ $second = $container->make(ContainerRequestStub::class);
+
+ $this->assertNotSame($first, $second);
+ }
+
+ public function testContainerResolutionOfRequiredArgumentRequestFailsNaturally(): void
+ {
+ $container = new Container;
+
+ $this->expectException(ArgumentCountError::class);
+
+ $container->make(RequiredArgumentRequestStub::class);
+ }
+
+ #[DataProvider('invalidCookies')]
+ public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ (new ContainerRequestStub)->withCookie(new SetCookie($cookie));
+ }
+
+ /**
+ * Provide cookies that cannot be sent with a request.
+ */
+ public static function invalidCookies(): array
+ {
+ return [
+ 'null domain' => [
+ ['Name' => 'locale', 'Value' => 'en'],
+ 'An outgoing cookie must have a domain.',
+ ],
+ 'empty domain' => [
+ ['Name' => 'locale', 'Value' => 'en', 'Domain' => ''],
+ 'Invalid cookie: The cookie domain must not be empty',
+ ],
+ 'null value' => [
+ ['Name' => 'locale', 'Domain' => 'api.example.com'],
+ 'Invalid cookie: The cookie value must not be empty',
+ ],
+ ];
+ }
+
+ public function testRequestsAreMacroable(): void
+ {
+ UserRequest::macro('endpoint', function (): string {
+ return $this->resolveEndpoint();
+ });
+
+ $this->assertSame('/user', (new UserRequest)->endpoint());
+ }
+
+ /**
+ * Send a request through a mock client and return its response.
+ */
+ protected function response(): Response
+ {
+ return (new TestConnector)->send(new UserRequest, new MockClient([MockResponse::make([])]));
+ }
+}
+
+class ContainerRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return 'users';
+ }
+}
+
+class RequiredArgumentRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Create a new request instance.
+ */
+ public function __construct(protected string $endpoint)
+ {
+ }
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return $this->endpoint;
+ }
+}
diff --git a/tests/Saloon/Unit/URLHelperTest.php b/tests/Saloon/Unit/URLHelperTest.php
new file mode 100644
index 0000000000..2438e29a1b
--- /dev/null
+++ b/tests/Saloon/Unit/URLHelperTest.php
@@ -0,0 +1,307 @@
+assertSame($expected, (string) UrlResolver::resolve($baseUrl, $endpoint, false));
+ }
+
+ /**
+ * Get the base URLs and endpoints to join.
+ *
+ * Upstream's last case joins an empty base URL and a relative endpoint into "/google.com/search", which cannot
+ * be sent, so it is rejected instead (see testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint).
+ *
+ * @return array
+ */
+ public static function urlsToJoin(): array
+ {
+ return [
+ ['https://google.com', '/search', 'https://google.com/search'],
+ ['https://google.com', 'search', 'https://google.com/search'],
+ ['https://google.com/', '/search', 'https://google.com/search'],
+ ['https://google.com/', 'search', 'https://google.com/search'],
+ ['https://google.com//', '//search', 'https://google.com/search'],
+ ];
+ }
+
+ public function testJoinThrowsWhenEndpointIsAnAbsoluteUrlToPreventSsrfAndCredentialLeakage(): void
+ {
+ $trustedBaseUrl = 'https://api.trusted.com';
+ $attackerUrl = 'https://attacker.example.com/steal';
+
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+
+ UrlResolver::resolve($trustedBaseUrl, $attackerUrl, false);
+ }
+
+ public function testJoinAllowsAbsoluteEndpointWhenAllowBaseUrlOverrideIsTrue(): void
+ {
+ $this->assertSame(
+ 'https://auth.provider.com/token',
+ (string) UrlResolver::resolve('https://api.example.com', 'https://auth.provider.com/token', true),
+ );
+ }
+
+ public function testJoinWithEmptyBaseAndAbsoluteEndpointIgnoresAllowBaseUrlOverrideFlag(): void
+ {
+ $this->assertSame(
+ 'https://solo.example.com/hook',
+ (string) UrlResolver::resolve('', 'https://solo.example.com/hook', false),
+ );
+ }
+
+ public function testCreatingAPendingRequestWithARequestThatReturnsAbsoluteUrlFromResolveEndpointThrows(): void
+ {
+ $connector = new TestConnector('https://api.trusted.com');
+ $request = new AbsoluteEndpointRequest('https://attacker.example.com/callback');
+
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+
+ // The URL is resolved when it is first read rather than when the pending request is created.
+ $connector->createPendingRequest($request)->uri();
+ }
+
+ public function testConnectorAllowBaseUrlOverrideAllowsPendingRequestWithAbsoluteEndpoint(): void
+ {
+ $connector = new AllowsBaseUrlOverrideConnector('https://api.trusted.com');
+ $request = new AbsoluteEndpointRequest('https://other-api.example.com/v1');
+
+ $pending = $connector->createPendingRequest($request);
+
+ $this->assertSame('https://other-api.example.com/v1', (string) $pending->uri());
+ }
+
+ public function testRequestAllowBaseUrlOverrideFalseDeniesAbsoluteEndpointEvenWhenConnectorAllows(): void
+ {
+ $connector = new AllowsBaseUrlOverrideConnector('https://api.trusted.com');
+ $request = new AbsoluteEndpointRequest('https://other.example.com/x', false);
+
+ $this->expectException(PendingRequestException::class);
+
+ $connector->createPendingRequest($request)->uri();
+ }
+
+ // Upstream parses the existing URL query into an array and rebuilds it. Hypervel keeps the existing pairs as
+ // written and only replaces the names being merged, so this dataset checks what is kept.
+ #[DataProvider('existingQueries')]
+ public function testTheUrlHelperCanParseAVarietyOfQueryParameters(string $query, string $expected): void
+ {
+ $uri = UrlResolver::withQuery(new Uri('https://api.example.com/users?' . $query), ['page' => 2]);
+
+ $this->assertSame($expected, $uri->getQuery());
+ }
+
+ /**
+ * Get existing URL queries and the result of merging a parameter into them.
+ *
+ * @return array
+ */
+ public static function existingQueries(): array
+ {
+ return [
+ ['foo=bar', 'foo=bar&page=2'],
+ ['foo=bar&name=sam', 'foo=bar&name=sam&page=2'],
+ ['foo==bar&name=sam', 'foo==bar&name=sam&page=2'],
+ ['=abc&name=sam', '=abc&name=sam&page=2'],
+ ['foo&name=sam', 'foo&name=sam&page=2'],
+ ['account.id=1', 'account.id=1&page=2'],
+ ['name=cowboy%20sam', 'name=cowboy%20sam&page=2'],
+ ['name=sam&', 'name=sam&page=2'],
+ ['&name=sam&&page=1', 'name=sam&page=2'],
+ ];
+ }
+
+ #[DataProvider('relativeEndpoints')]
+ public function testEndpointsWithoutASchemeStayOnTheConnectorHost(string $endpoint, string $expected): void
+ {
+ $this->assertSame($expected, (string) UrlResolver::resolve('https://api.example.com/v1', $endpoint, false));
+ }
+
+ /**
+ * Get endpoints that URI parsing would otherwise read as a scheme or a host.
+ *
+ * @return array
+ */
+ public static function relativeEndpoints(): array
+ {
+ return [
+ 'colon in the first segment' => ['documents:batchGet', 'https://api.example.com/v1/documents:batchGet'],
+ 'colon with a query' => ['users:search?q=sam', 'https://api.example.com/v1/users:search?q=sam'],
+ 'host-looking path' => ['//evil.example.com/steal', 'https://api.example.com/v1/evil.example.com/steal'],
+ 'scheme without slashes' => ['mailto:sam@example.com', 'https://api.example.com/v1/mailto:sam@example.com'],
+ ];
+ }
+
+ public function testRelativeEndpointIsJoinedToTheConnectorBaseUrl(): void
+ {
+ $uri = UrlResolver::resolve('https://api.example.com/v1/', '/users?active=1', false);
+
+ $this->assertSame('https://api.example.com/v1/users?active=1', (string) $uri);
+ }
+
+ public function testEmptyEndpointPreservesTheConnectorBasePath(): void
+ {
+ $this->assertSame(
+ 'https://api.example.com/v1',
+ (string) UrlResolver::resolve('https://api.example.com/v1', '', false),
+ );
+ $this->assertSame(
+ 'https://api.example.com/v1/',
+ (string) UrlResolver::resolve('https://api.example.com/v1', '/', false),
+ );
+ }
+
+ public function testBaseAndEndpointQueriesAreRetainedBeforeRepositoryOverrides(): void
+ {
+ $this->assertSame(
+ 'https://api.example.com/v1/users?key=base&keep=base',
+ (string) UrlResolver::resolve('https://api.example.com/v1?key=base&keep=base', '/users', false),
+ );
+
+ $uri = UrlResolver::resolve(
+ 'https://api.example.com/v1?key=base&keep=base',
+ '/users?endpoint=value',
+ false,
+ );
+
+ $this->assertSame('key=base&keep=base&endpoint=value', $uri->getQuery());
+ $this->assertSame(
+ 'keep=base&endpoint=value&key=repository',
+ UrlResolver::withQuery($uri, ['key' => 'repository'])->getQuery(),
+ );
+ }
+
+ public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void
+ {
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
+
+ UrlResolver::resolve('', 'google.com/search', true);
+ }
+
+ #[DataProvider('alternateSchemeBaseUrls')]
+ public function testAlternateSchemesAreRejected(string $baseUrl): void
+ {
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('The request endpoint must be an absolute HTTP or HTTPS URI.');
+
+ UrlResolver::resolve($baseUrl, 'file:///etc/passwd', true);
+ }
+
+ /**
+ * Get the base URLs an alternate scheme endpoint cannot replace.
+ *
+ * @return array
+ */
+ public static function alternateSchemeBaseUrls(): array
+ {
+ return [
+ 'without a base URL' => [''],
+ 'with a base URL' => ['https://api.example.com'],
+ ];
+ }
+
+ public function testRepositoryQueryReplacesExactAndNestedRawFamilies(): void
+ {
+ $uri = UrlResolver::resolve(
+ 'https://api.example.com',
+ '/users?filter=old&filter%5Bname%5D=Taylor&keep=one&keep=two&flag',
+ false,
+ );
+
+ $resolved = UrlResolver::withQuery($uri, [
+ 'filter' => ['name' => 'Abigail'],
+ 'page' => 2,
+ ]);
+
+ $this->assertSame(
+ 'keep=one&keep=two&flag&filter%5Bname%5D=Abigail&page=2',
+ $resolved->getQuery(),
+ );
+ }
+
+ public function testNullAndEmptyArrayValuesRemoveMatchingRawFamilies(): void
+ {
+ $uri = UrlResolver::resolve(
+ 'https://api.example.com',
+ '/users?remove=one&remove%5Bnested%5D=two&empty%5B0%5D=value&keep=yes',
+ false,
+ );
+
+ $resolved = UrlResolver::withQuery($uri, ['remove' => null, 'empty' => []]);
+
+ $this->assertSame('keep=yes', $resolved->getQuery());
+ }
+
+ public function testDecodedNamesUseQueryStringSemanticsWithoutNormalizingRetainedPairs(): void
+ {
+ $uri = UrlResolver::resolve(
+ 'https://api.example.com',
+ '/users?first+name=old&retained=a+b&literal=%7Bvalue%7D',
+ false,
+ );
+
+ $resolved = UrlResolver::withQuery($uri, ['first name' => 'Taylor']);
+
+ $this->assertSame('retained=a+b&literal=%7Bvalue%7D&first%20name=Taylor', $resolved->getQuery());
+ }
+
+ public function testStructuredValuesAreNormalized(): void
+ {
+ $uri = UrlResolver::resolve('https://api.example.com', '/users', false);
+
+ $resolved = UrlResolver::withQuery($uri, [
+ 'collection' => new Collection(['one', 'two']),
+ 'stringable' => new Stringable('value'),
+ 'not-a-number' => NAN,
+ ]);
+
+ $this->assertSame(
+ 'collection%5B0%5D=one&collection%5B1%5D=two&stringable=value¬-a-number=NAN',
+ $resolved->getQuery(),
+ );
+ }
+}
+
+// Upstream sets the public $allowBaseUrlOverride property on a connector instance. Hypervel connectors are
+// read-only and declare it by overriding allowsBaseUrlOverride().
+class AllowsBaseUrlOverrideConnector extends TestConnector
+{
+ /**
+ * Resolve whether requests may replace this connector's base URL.
+ */
+ public function allowsBaseUrlOverride(): bool
+ {
+ return true;
+ }
+}
diff --git a/tests/Support/SupportMacroableTest.php b/tests/Support/SupportMacroableTest.php
index d372ba0c4c..5526fa9abe 100644
--- a/tests/Support/SupportMacroableTest.php
+++ b/tests/Support/SupportMacroableTest.php
@@ -76,6 +76,16 @@ public function testWhenCallingMacroClosureIsBoundToObject(): void
$result = TestMacroable::tryStatic();
$this->assertSame('static', $result);
+
+ TestMacroable::macro('tryPrivateInstance', function (): string {
+ return $this->privateVariable;
+ });
+ TestMacroable::macro('tryPrivateStatic', function (): string {
+ return $this::getPrivateStatic();
+ });
+
+ $this->assertSame('private instance', $instance->tryPrivateInstance());
+ $this->assertSame('private static', $instance->tryPrivateStatic());
}
public function testClassBasedMacros(): void
@@ -263,10 +273,20 @@ class TestMacroable
protected string $protectedVariable = 'instance';
+ private string $privateVariable = 'private instance';
+
protected static function getProtectedStatic(): string
{
return 'static';
}
+
+ /**
+ * Get a value only visible inside the class.
+ */
+ private static function getPrivateStatic(): string
+ {
+ return 'private static';
+ }
}
class TestMixin
From 61cf25e60966e35d7c20398de4d8719a14fb4029 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 18:53:40 +0000
Subject: [PATCH 04/26] Reconcile Saloon transport, response and exception
tests with upstream
Ports Saloon's config, sender, Guzzle sender, response, request
exception, PSR, AlwaysThrowOnErrors, CastsToDto, data object wrapper
and debug tests (saloonphp/saloon v4 at 2f45e526f9d1) with their
fixtures, merging the existing Hypervel response, sender and debug
tests into the upstream-named files. Cases that call upstream's live
test API run against the engine test server under
tests/Integration/Saloon. This completes saloonphp/saloon#522, #525,
#534, #535, #537, #552 and #554, plus the sender test changes of #561
and the config parts of #518 and #557. #475 (a default handler stack)
is excluded, since HTTP connections own the transport handler.
New APIs:
- Upstream's 14 status exceptions, such as NotFoundException and
TooManyRequestsException, chosen by status in
Response::newRequestException() after getRequestException(). They
extend ClientException or ServerException, so existing catches still
apply.
- Response::isJson(), isXml() and array().
Defects fixed:
- A transfer that failed after the response headers arrived escaped
Saloon's error handling for a 4xx or 5xx status, as the HTTP client's
RequestException. Sender now reports every incomplete transfer as a
connection failure, so it throws FatalRequestException, runs the
fatal middleware and is retried.
- dtoOrFail() uses upstream's message.
Adaptations, recorded in the README:
- Upstream's Config maps to Saloon::middleware(), the
saloon.connection settings, the framework Date, Sleep::fake() and
Http::preventStrayRequests(). Sender, clock and sleep resolver cases
are removed.
- Responses keep the HTTP client's header(), headers(), json() and
object(), and their getters drop upstream's get prefix.
- Request exceptions extend the HTTP client's, so
getRequestException() receives no sender exception and custom
messages override prepareMessage().
- Connector debuggers register from boot(). die: true throws Swoole's
ExitException inside a coroutine, so there is no die handler.
- xmlReader() is excluded: it only wraps XML Wrangler, whose
dependency tree and Laravel collection helpers do not fit Hypervel.
The docs show XmlReader::fromPsrResponse($response->toPsrResponse()).
Also adds the missing setup and teardown docblocks in the Saloon tests.
Validation: Saloon suite (665 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the engine
test servers, tests/Data/Saloon; composer analyse; php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 27 +-
src/saloon/README.md | 8 +-
.../Request/FatalRequestException.php | 3 +-
.../Request/Statuses/BadGatewayException.php | 11 +
.../Request/Statuses/BadRequestException.php | 11 +
.../Request/Statuses/ConflictException.php | 11 +
.../Request/Statuses/ForbiddenException.php | 11 +
.../Statuses/GatewayTimeoutException.php | 11 +
.../Statuses/InternalServerErrorException.php | 11 +
.../Statuses/MethodNotAllowedException.php | 11 +
.../Request/Statuses/NotFoundException.php | 11 +
.../Statuses/PaymentRequiredException.php | 11 +
.../Statuses/RequestTimeOutException.php | 11 +
.../Statuses/ServiceUnavailableException.php | 11 +
.../Statuses/TooManyRequestsException.php | 11 +
.../Statuses/UnauthorizedException.php | 11 +
.../Statuses/UnprocessableEntityException.php | 11 +
src/saloon/src/Http/Debugger.php | 2 +-
src/saloon/src/Http/Response.php | 70 +-
src/saloon/src/Http/Sender.php | 17 +-
.../Saloon/Feature/RequestExceptionTest.php | 72 ++
tests/Integration/Saloon/SaloonClientTest.php | 3 +
tests/Integration/Saloon/Unit/ConfigTest.php | 66 ++
.../Integration/Saloon/Unit/ResponseTest.php | 57 ++
.../Saloon/Console/GeneratorCommandsTest.php | 3 +
tests/Saloon/Console/ListCommandTest.php | 6 +
.../Saloon/Feature/DataObjectWrapperTest.php | 57 ++
tests/Saloon/Feature/DebugTest.php | 430 +++++++++
tests/Saloon/Feature/GuzzleSenderTest.php | 96 ++
tests/Saloon/Feature/PsrTest.php | 64 ++
tests/Saloon/Feature/RequestExceptionTest.php | 349 +++++++
.../Connectors/BadResponseConnector.php | 30 +
.../Connectors/CustomExceptionConnector.php | 32 +
.../Connectors/CustomFailHandlerConnector.php | 30 +
.../Fixtures/Connectors/DtoConnector.php | 41 +
.../ModifiedPsrRequestConnector.php | 20 +
tests/Saloon/Fixtures/Data/ApiResponse.php | 26 +
tests/Saloon/Fixtures/Data/User.php | 30 +
.../Saloon/Fixtures/Data/UserWithResponse.php | 34 +
.../Exceptions/ConnectorRequestException.php | 19 +
.../Exceptions/CustomRequestException.php | 19 +
.../Mocking/UnseekableBodyMockResponse.php | 27 +
.../Requests/AlwaysHasFailureRequest.php | 33 +
.../Fixtures/Requests/AlwaysThrowRequest.php | 29 +
.../Fixtures/Requests/BadResponseRequest.php | 33 +
.../Requests/CustomExceptionUserRequest.php | 35 +
.../Requests/CustomFailHandlerRequest.php | 33 +
tests/Saloon/Fixtures/Requests/DTORequest.php | 43 +
.../Requests/DTOWithResponseRequest.php | 43 +
.../Requests/ModifiedPsrUserRequest.php | 19 +
.../Requests/NotFoundFailedRequest.php | 40 +
.../Fixtures/Requests/NullHeaderRequest.php | 34 +
tests/Saloon/Http/DebugTest.php | 141 ---
tests/Saloon/Http/FixtureTest.php | 6 +
tests/Saloon/Http/MockClientTest.php | 6 +
tests/Saloon/Http/ResponseTest.php | 398 --------
tests/Saloon/SaloonManagerTest.php | 3 +
tests/Saloon/Unit/ConfigTest.php | 96 ++
tests/Saloon/Unit/PendingRequestTest.php | 3 +
.../Plugins/AlwaysThrowOnErrorsPluginTest.php | 35 +
.../Unit/Plugins/CastsToDtoPluginTest.php | 98 ++
tests/Saloon/Unit/PsrTest.php | 93 ++
tests/Saloon/Unit/RequestExceptionTest.php | 125 +++
tests/Saloon/Unit/ResponseTest.php | 872 ++++++++++++++++++
tests/Saloon/{Http => Unit}/SenderTest.php | 85 +-
66 files changed, 3536 insertions(+), 561 deletions(-)
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/BadRequestException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/ConflictException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/ForbiddenException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/GatewayTimeoutException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/InternalServerErrorException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/MethodNotAllowedException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/NotFoundException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/PaymentRequiredException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/RequestTimeOutException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/ServiceUnavailableException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/TooManyRequestsException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/UnauthorizedException.php
create mode 100644 src/saloon/src/Exceptions/Request/Statuses/UnprocessableEntityException.php
create mode 100644 tests/Integration/Saloon/Feature/RequestExceptionTest.php
create mode 100644 tests/Integration/Saloon/Unit/ConfigTest.php
create mode 100644 tests/Integration/Saloon/Unit/ResponseTest.php
create mode 100644 tests/Saloon/Feature/DataObjectWrapperTest.php
create mode 100644 tests/Saloon/Feature/DebugTest.php
create mode 100644 tests/Saloon/Feature/GuzzleSenderTest.php
create mode 100644 tests/Saloon/Feature/PsrTest.php
create mode 100644 tests/Saloon/Feature/RequestExceptionTest.php
create mode 100644 tests/Saloon/Fixtures/Connectors/BadResponseConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomFailHandlerConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/DtoConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/ModifiedPsrRequestConnector.php
create mode 100644 tests/Saloon/Fixtures/Data/ApiResponse.php
create mode 100644 tests/Saloon/Fixtures/Data/User.php
create mode 100644 tests/Saloon/Fixtures/Data/UserWithResponse.php
create mode 100644 tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php
create mode 100644 tests/Saloon/Fixtures/Exceptions/CustomRequestException.php
create mode 100644 tests/Saloon/Fixtures/Mocking/UnseekableBodyMockResponse.php
create mode 100644 tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/AlwaysThrowRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/BadResponseRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/CustomFailHandlerRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DTORequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DTOWithResponseRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/ModifiedPsrUserRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/NullHeaderRequest.php
delete mode 100644 tests/Saloon/Http/DebugTest.php
delete mode 100644 tests/Saloon/Http/ResponseTest.php
create mode 100644 tests/Saloon/Unit/ConfigTest.php
create mode 100644 tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php
create mode 100644 tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php
create mode 100644 tests/Saloon/Unit/PsrTest.php
create mode 100644 tests/Saloon/Unit/RequestExceptionTest.php
create mode 100644 tests/Saloon/Unit/ResponseTest.php
rename tests/Saloon/{Http => Unit}/SenderTest.php (67%)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index f0182b75ad..967914cc3b 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,7 +182,7 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 4cc1a8e937..6338d34467 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1096,7 +1096,7 @@ $response->toPsrResponse();
$response->dataUrl();
```
-The `dataUrl` method returns the response body as a base64 data URL using its `Content-Type` header.
+The `dataUrl` method returns the response body as a base64 data URL using its `Content-Type` header. The `isJson` and `isXml` methods check whether that header names a JSON or XML type, and `array` is an alias of `json`.
You may use [`lines` and `jsonLines`](/docs/{{version}}/http-client#streaming-responses) to process a response as it arrives. Enable the `stream` request option and leave response caching and fixture recording disabled, since both read the body before returning the response.
@@ -1236,7 +1236,9 @@ if ($response->failed()) {
$response->throw();
```
-By default, 4xx responses throw `ClientException`, 5xx responses throw `ServerException`, and other integration-defined failures throw `RequestException`. Transport failures throw `FatalRequestException`.
+Common statuses throw an exception named after them from the `Hypervel\Saloon\Exceptions\Request\Statuses` namespace, such as `NotFoundException` or `TooManyRequestsException`. These extend `ClientException` for 4xx responses and `ServerException` for 5xx responses, which other 4xx and 5xx responses throw directly. Other integration-defined failures throw `RequestException`.
+
+A request that could not complete throws `FatalRequestException`. This includes failed connections and transfers that break while the response is being received, whatever status the partial response has.
You may define provider-specific failure behavior on a request or connector:
@@ -1254,7 +1256,22 @@ public function shouldThrowRequestException(Response $response): bool
In this example, the integration treats a 404 response as an empty result instead of an exception.
-A non-null request failure decision takes precedence over the connector decision, which takes precedence over the status-code fallback. You may return a custom Saloon request exception from `getRequestException`. The `AlwaysThrowOnErrors` plugin calls `throw` automatically after each response.
+A non-null request failure decision takes precedence over the connector decision, which takes precedence over the status-code fallback. You may return a custom Saloon request exception from `getRequestException`, and a request's exception takes precedence over its connector's. To give a custom exception its own message, override its `prepareMessage` method:
+
+```php
+use Hypervel\Http\Client\Response;
+use Hypervel\Saloon\Exceptions\Request\RequestException;
+
+class GitHubException extends RequestException
+{
+ protected function prepareMessage(Response $response): string
+ {
+ return 'GitHub: ' . $response->json('message');
+ }
+}
+```
+
+The `AlwaysThrowOnErrors` plugin calls `throw` automatically after each response.
### Retries
@@ -1291,7 +1308,7 @@ $request->debugRequest();
$request->debugResponse();
```
-The `debugRequest` and `debugResponse` methods accept a custom callback. All three methods accept `die: true`, which terminates the current worker process and is intended only for local debugging.
+To debug every request sent through a connector, call the same methods on the pending request from the connector's `boot` method. The `debugRequest` and `debugResponse` methods accept a custom callback. All three methods accept `die: true`, which stops the current request or command after the output and is intended only for local debugging.
> [!WARNING]
> Debug output contains raw headers and bodies and may expose credentials or other sensitive values. Custom callbacks are also responsible for any stream reads they perform.
@@ -2301,7 +2318,7 @@ Hypervel Saloon keeps the connector, request, middleware, authentication, respon
- Saloon responses extend Hypervel HTTP responses rather than forwarding a selected subset of methods.
- Test fixture settings are configured through the `Saloon` facade instead of a process-global mock configuration object.
- Application-wide stray-request protection uses `Http::preventStrayRequests()`. Saloon mock clients separately control unmatched requests while they are active.
-- The optional `xmlReader` response extension is not included. Use the built-in `xml` or `dom` methods instead.
+- The `xmlReader` response method is not included. Use the `xml` or `dom` methods, or install [XML Wrangler](https://github.com/saloonphp/xml-wrangler) and pass `$response->toPsrResponse()` to `XmlReader::fromPsrResponse()`.
These differences remove framework-neutral adapter layers while retaining the public concepts needed to build complete integrations and reusable SDKs for Hypervel.
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 34810c7cac..14ab6c8d04 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -6,12 +6,18 @@ Documentation: https://hypervel.org/docs/saloon
Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, and `uri()` replaces `getUrl()`, including the query string.
-Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
+Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
+
+There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `defaultSender()`, sender resolvers and handler stacks are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Prevent stray requests with `Http::preventStrayRequests()`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
+Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
+
+Request exceptions extend the HTTP client's `RequestException`, so they use its message format and constructor, and expose `response()`, `pendingRequest()`, `status()` and `body()` instead of upstream's getters. `getRequestException(Response $response)` must return a Saloon request exception and receives no sender exception; override an exception's `prepareMessage()` to change its message. A transfer that fails after the response headers arrive throws `FatalRequestException` whatever its status, rather than returning the partial response. Debugging with `die: true` exits, which throws Swoole's `ExitException` inside a coroutine, so there is no `Debugger::$dieHandler`.
+
Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own.
The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options).
diff --git a/src/saloon/src/Exceptions/Request/FatalRequestException.php b/src/saloon/src/Exceptions/Request/FatalRequestException.php
index bf6c414cba..f74c20e048 100644
--- a/src/saloon/src/Exceptions/Request/FatalRequestException.php
+++ b/src/saloon/src/Exceptions/Request/FatalRequestException.php
@@ -9,7 +9,8 @@
use Throwable;
/**
- * Report a transport failure that occurred before an API returned a response.
+ * Report a request exchange that could not complete, such as a failed connection or a transfer that broke while
+ * receiving the response.
*/
class FatalRequestException extends SaloonException
{
diff --git a/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php b/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php
new file mode 100644
index 0000000000..a9e4593d86
--- /dev/null
+++ b/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php
@@ -0,0 +1,11 @@
+clientError() => new ClientException($this, $this->truncateExceptionsAt),
- $this->serverError() => new ServerException($this, $this->truncateExceptionsAt),
- default => new RequestException($this, $this->truncateExceptionsAt),
+ $status = $this->status();
+
+ $exception = match (true) {
+ $status === 400 => BadRequestException::class,
+ $status === 401 => UnauthorizedException::class,
+ $status === 402 => PaymentRequiredException::class,
+ $status === 403 => ForbiddenException::class,
+ $status === 404 => NotFoundException::class,
+ $status === 405 => MethodNotAllowedException::class,
+ $status === 408 => RequestTimeOutException::class,
+ $status === 409 => ConflictException::class,
+ $status === 422 => UnprocessableEntityException::class,
+ $status === 429 => TooManyRequestsException::class,
+ $status === 500 => InternalServerErrorException::class,
+ $status === 502 => BadGatewayException::class,
+ $status === 503 => ServiceUnavailableException::class,
+ $status === 504 => GatewayTimeoutException::class,
+ $this->serverError() => ServerException::class,
+ $this->clientError() => ClientException::class,
+ default => RequestException::class,
};
+
+ return new $exception($this, $this->truncateExceptionsAt);
+ }
+
+ /**
+ * Get the JSON decoded body as an array. Provide a key to find a specific item in the JSON.
+ *
+ * Alias of json()
+ */
+ public function array(int|string|null $key = null, mixed $default = null): mixed
+ {
+ return $this->json(is_int($key) ? (string) $key : $key, $default);
}
/**
@@ -237,7 +279,7 @@ public function dtoOrFail(): mixed
{
if ($this->failed()) {
throw new LogicException(
- 'Unable to create a data transfer object because the response failed.',
+ 'Unable to create data transfer object as the response has failed.',
0,
$this->toException(),
);
@@ -256,6 +298,8 @@ public function xml(mixed ...$arguments): SimpleXMLElement|false
return simplexml_load_string($this->body(), ...$arguments);
}
+ // xmlReader() is not included; see the package README.
+
/**
* Parse the HTML or XML response into a DOM crawler.
*/
@@ -272,6 +316,22 @@ public function dataUrl(): string
return 'data:' . $this->header('Content-Type') . ';base64,' . base64_encode($this->body());
}
+ /**
+ * Determine if the response is in JSON format.
+ */
+ public function isJson(): bool
+ {
+ return str_contains(mb_strtolower($this->header('Content-Type')), 'json');
+ }
+
+ /**
+ * Determine if the response is in XML format.
+ */
+ public function isXml(): bool
+ {
+ return str_contains(mb_strtolower($this->header('Content-Type')), 'xml');
+ }
+
/**
* Create a temporary resource containing the response body.
*
diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php
index 1c9945498e..02161f3b7c 100644
--- a/src/saloon/src/Http/Sender.php
+++ b/src/saloon/src/Http/Sender.php
@@ -7,7 +7,9 @@
use GuzzleHttp\Cookie\SetCookie;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Telescope\TelescopeTag;
+use Hypervel\Http\Client\ConnectionException;
use Hypervel\Http\Client\Factory;
+use Hypervel\Http\Client\RequestException as HttpRequestException;
use Psr\Http\Message\RequestInterface;
class Sender
@@ -74,10 +76,17 @@ public function send(PendingRequest $pendingRequest, array $transport): Response
return $request;
});
- $httpResponse = $httpRequest->send(
- $pendingRequest->method()->value,
- (string) $pendingRequest->uri(),
- );
+ try {
+ $httpResponse = $httpRequest->send(
+ $pendingRequest->method()->value,
+ (string) $pendingRequest->uri(),
+ );
+ } catch (HttpRequestException $exception) {
+ // A transfer that fails after the response headers arrive keeps its partial response. The HTTP client
+ // throws that as a request exception for a 4xx or 5xx status and as a connection exception otherwise, so
+ // both reach Saloon as an exchange that could not complete.
+ throw new ConnectionException($exception->getMessage(), 0, $exception);
+ }
return $pendingRequest->createResponse(
$httpResponse,
diff --git a/tests/Integration/Saloon/Feature/RequestExceptionTest.php b/tests/Integration/Saloon/Feature/RequestExceptionTest.php
new file mode 100644
index 0000000000..20701c6fa9
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/RequestExceptionTest.php
@@ -0,0 +1,72 @@
+setUpInteractsWithServer();
+ }
+
+ // Upstream's GuzzleSender case also expects Guzzle's ServerException as the previous exception. The HTTP client
+ // does not throw for an error status, so there is none.
+ public function testYouCanUseTheToExceptionMethodToGetTheDefaultRequestExceptionExceptionWithARealResponse(): void
+ {
+ $response = (new TestConnector($this->serverUrl()))->send(new ErrorRequest);
+
+ $this->assertInstanceOf(Response::class, $response);
+
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(InternalServerErrorException::class, $exception);
+ $this->assertInstanceOf(ServerException::class, $exception);
+ $this->assertSame(
+ sprintf("HTTP request returned status code 500:\n%s\n", $response->body()),
+ $exception->getMessage(),
+ );
+ $this->assertNull($exception->getPrevious());
+
+ $this->expectExceptionObject($exception);
+
+ $response->throw();
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/SaloonClientTest.php b/tests/Integration/Saloon/SaloonClientTest.php
index 043385a741..9c95876bd8 100644
--- a/tests/Integration/Saloon/SaloonClientTest.php
+++ b/tests/Integration/Saloon/SaloonClientTest.php
@@ -24,6 +24,9 @@ protected function getPackageProviders(ApplicationContract $app): array
return [SaloonServiceProvider::class];
}
+ /**
+ * Set up the test server connection.
+ */
protected function setUp(): void
{
parent::setUp();
diff --git a/tests/Integration/Saloon/Unit/ConfigTest.php b/tests/Integration/Saloon/Unit/ConfigTest.php
new file mode 100644
index 0000000000..0b4878586f
--- /dev/null
+++ b/tests/Integration/Saloon/Unit/ConfigTest.php
@@ -0,0 +1,66 @@
+setUpInteractsWithServer();
+ }
+
+ public function testYouCanPreventAndThenAllowStrayApiRequests(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+
+ Http::preventStrayRequests();
+
+ try {
+ $connector->send(new UserRequest);
+
+ $this->fail('A stray request was sent.');
+ } catch (StrayRequestException) {
+ }
+
+ Http::allowStrayRequests();
+
+ $this->assertSame(200, $connector->send(new UserRequest)->status());
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/Unit/ResponseTest.php b/tests/Integration/Saloon/Unit/ResponseTest.php
new file mode 100644
index 0000000000..ad48af4079
--- /dev/null
+++ b/tests/Integration/Saloon/Unit/ResponseTest.php
@@ -0,0 +1,57 @@
+setUpInteractsWithServer();
+ }
+
+ public function testYouCanGetTheResponseStreamAsARawResource(): void
+ {
+ $response = (new TestConnector($this->serverUrl()))->send(new UserRequest);
+
+ $resource = $response->getRawStream();
+
+ $this->assertIsResource($resource);
+
+ $this->assertSame('{"name":"Sammyjo20","actual_name":"Sam","twitter":"@carre_sam"}', stream_get_contents($resource));
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Saloon/Console/GeneratorCommandsTest.php b/tests/Saloon/Console/GeneratorCommandsTest.php
index 434c1c0934..a1c457ae0d 100644
--- a/tests/Saloon/Console/GeneratorCommandsTest.php
+++ b/tests/Saloon/Console/GeneratorCommandsTest.php
@@ -25,6 +25,9 @@ protected function getPackageProviders(ApplicationContract $app): array
return [SaloonServiceProvider::class];
}
+ /**
+ * Clean up the test environment.
+ */
protected function tearDown(): void
{
$files = new Filesystem;
diff --git a/tests/Saloon/Console/ListCommandTest.php b/tests/Saloon/Console/ListCommandTest.php
index 1d4758d203..1cea10c79e 100644
--- a/tests/Saloon/Console/ListCommandTest.php
+++ b/tests/Saloon/Console/ListCommandTest.php
@@ -20,6 +20,9 @@ protected function getPackageProviders(ApplicationContract $app): array
return [SaloonServiceProvider::class];
}
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
@@ -28,6 +31,9 @@ protected function setUp(): void
(new Filesystem)->deleteDirectory($this->integrationsPath);
}
+ /**
+ * Clean up the test environment.
+ */
protected function tearDown(): void
{
(new Filesystem)->deleteDirectory($this->integrationsPath);
diff --git a/tests/Saloon/Feature/DataObjectWrapperTest.php b/tests/Saloon/Feature/DataObjectWrapperTest.php
new file mode 100644
index 0000000000..6b22ca35ef
--- /dev/null
+++ b/tests/Saloon/Feature/DataObjectWrapperTest.php
@@ -0,0 +1,57 @@
+ 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam']),
+ ]);
+
+ $response = (new TestConnector)->send(new DTORequest, $mockClient);
+ $dto = $response->dto();
+
+ $this->assertInstanceOf(User::class, $dto);
+ $this->assertNotInstanceOf(WithResponse::class, $dto);
+ }
+
+ public function testIfADtoImplementsTheWithResponseInterfaceAndHasResponseTraitSaloonWillAddTheOriginalResponse(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam']),
+ ]);
+
+ $request = new DTOWithResponseRequest;
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $dto = $response->dto();
+
+ $this->assertInstanceOf(UserWithResponse::class, $dto);
+ $this->assertInstanceOf(WithResponse::class, $dto);
+ $this->assertSame($response, $dto->getResponse());
+ }
+}
diff --git a/tests/Saloon/Feature/DebugTest.php b/tests/Saloon/Feature/DebugTest.php
new file mode 100644
index 0000000000..f4d97dd588
--- /dev/null
+++ b/tests/Saloon/Feature/DebugTest.php
@@ -0,0 +1,430 @@
+ 'Sam']),
+ ]);
+
+ $connectorRequestDebuggerValid = false;
+ $connectorResponseDebuggerValid = false;
+
+ $requestClassRequestDebuggerValid = false;
+ $requestClassResponseDebuggerValid = false;
+
+ // The connector can register callbacks to debug the request and the response
+
+ $connector = new DebuggingConnectorStub(
+ onRequest: function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$connectorRequestDebuggerValid): void {
+ $connectorRequestDebuggerValid = true;
+ },
+ onResponse: function (Response $response, ResponseInterface $psrResponse) use (&$connectorResponseDebuggerValid): void {
+ $connectorResponseDebuggerValid = true;
+ },
+ );
+
+ $request = new UserRequest;
+
+ // The request can register a callback to debug the request
+
+ $request->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$requestClassRequestDebuggerValid): void {
+ $requestClassRequestDebuggerValid = true;
+ });
+
+ // The request can register a callback to debug the response
+
+ $request->debugResponse(function (Response $response, ResponseInterface $psrResponse) use (&$requestClassResponseDebuggerValid): void {
+ $requestClassResponseDebuggerValid = true;
+ });
+
+ $connector->send($request, $mockClient);
+
+ // Check these are all true
+
+ $this->assertTrue($connectorRequestDebuggerValid);
+ $this->assertTrue($connectorResponseDebuggerValid);
+ $this->assertTrue($requestClassRequestDebuggerValid);
+ $this->assertTrue($requestClassResponseDebuggerValid);
+ }
+
+ public function testTheResponseDebuggerIsAlwaysExecutedBeforeUserMiddleware(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sam']),
+ ]);
+
+ $middlewareOrder = [];
+
+ $connector = new DebuggingConnectorStub(
+ onResponse: function () use (&$middlewareOrder): void {
+ $middlewareOrder[] = 'C';
+ },
+ responseMiddleware: function () use (&$middlewareOrder): void {
+ $middlewareOrder[] = 'A';
+ },
+ );
+ $request = new UserRequest;
+
+ $request->middleware()->onResponse(function () use (&$middlewareOrder): void {
+ $middlewareOrder[] = 'B';
+ });
+
+ $request->debugResponse(function () use (&$middlewareOrder): void {
+ $middlewareOrder[] = 'D';
+ });
+
+ $connector->send($request, $mockClient);
+
+ // Even though the user has registered response middleware, the debugger should always come first. Upstream
+ // also expects connector entries before request entries ([C, D, A, B]); only the debugger priority is
+ // asserted here.
+ $this->assertEqualsCanonicalizing(['C', 'D'], array_slice($middlewareOrder, 0, 2));
+ $this->assertEqualsCanonicalizing(['A', 'B'], array_slice($middlewareOrder, 2));
+ }
+
+ public function testTheResponseDebuggerIsAlwaysExecutedBeforeTheAlwaysThrowOnErrorsTrait(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sam'], 500),
+ ]);
+
+ $middlewareCount = 0;
+
+ $connector = new DebuggingConnectorStub(onResponse: function () use (&$middlewareCount): void {
+ ++$middlewareCount;
+ });
+ $request = new AlwaysThrowRequest;
+
+ $request->debugResponse(function () use (&$middlewareCount): void {
+ ++$middlewareCount;
+ });
+
+ try {
+ $connector->send($request, $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (RequestException) {
+ $this->assertSame(2, $middlewareCount);
+ }
+ }
+
+ public function testTheDefaultDebugRequestDriverWillDumpAnOutputUsingSymfonyVarDumper(): void
+ {
+ $output = $this->dump(fn (): Response => (new TestConnector)->send(
+ (new UserRequest)->debugRequest(),
+ new MockClient([new MockResponse(['name' => 'Sam'], 500)]),
+ ));
+
+ $expected = <<<'END'
+ Saloon Request (UserRequest) -> array:6 [
+ "connector" => "Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector"
+ "request" => "Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest"
+ "method" => "GET"
+ "uri" => "https://tests.saloon.dev/api/user"
+ "headers" => array:2 [
+ "Host" => "tests.saloon.dev"
+ "Accept" => "application/json"
+ ]
+ "body" => ""
+ ]
+ END;
+
+ $this->assertSame($expected . "\n", $output);
+ }
+
+ public function testTheDefaultDebugResponseDriverWillDumpAnOutputUsingSymfonyVarDumper(): void
+ {
+ $output = $this->dump(fn (): Response => (new TestConnector)->send(
+ (new UserRequest)->debugResponse(),
+ new MockClient([new MockResponse(['name' => 'Sam'], 500)]),
+ ));
+
+ $expected = <<<'END'
+ Saloon Response (UserRequest) -> array:3 [
+ "status" => 500
+ "headers" => []
+ "body" => "{"name":"Sam"}"
+ ]
+ END;
+
+ $this->assertSame($expected . "\n", $output);
+ }
+
+ public function testTheDebugMethodWillOutputBothRequestAndResponseAtTheSameTime(): void
+ {
+ $output = $this->dump(fn (): Response => (new TestConnector)->send(
+ (new UserRequest)->debug(),
+ new MockClient([new MockResponse(['name' => 'Sam'], 500)]),
+ ));
+
+ $expected = <<<'END'
+ Saloon Request (UserRequest) -> array:6 [
+ "connector" => "Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector"
+ "request" => "Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest"
+ "method" => "GET"
+ "uri" => "https://tests.saloon.dev/api/user"
+ "headers" => array:2 [
+ "Host" => "tests.saloon.dev"
+ "Accept" => "application/json"
+ ]
+ "body" => ""
+ ]
+ Saloon Response (UserRequest) -> array:3 [
+ "status" => 500
+ "headers" => []
+ "body" => "{"name":"Sam"}"
+ ]
+ END;
+
+ $this->assertSame($expected . "\n", $output);
+ }
+
+ // Upstream replaces its die handler. Saloon calls exit, which throws Swoole's ExitException inside a coroutine.
+ public function testTheDebugMethodCanKillTheApplication(): void
+ {
+ try {
+ $this->dump(fn (): Response => (new TestConnector)->send(
+ (new UserRequest)->debug(die: true),
+ new MockClient([new MockResponse(['name' => 'Sam'], 500)]),
+ ));
+
+ $this->fail('The application was not killed.');
+ } catch (ExitException $exception) {
+ $this->assertSame(1, $exception->getStatus());
+ }
+ }
+
+ public function testTheResponseDebuggerReceivesAResponseWithFullBodyWhenTheStreamIsUnseekable(): void
+ {
+ $expectedBody = '{"name":"Jon"}';
+ $mockClient = new MockClient([
+ new UnseekableBodyMockResponse(['name' => 'Jon'], 200),
+ ]);
+
+ $debuggerReceivedBody = null;
+ $debuggerBodyIsSeekable = null;
+
+ $connector = new DebuggingConnectorStub(
+ onResponse: function (Response $response, ResponseInterface $psrResponse) use (&$debuggerReceivedBody, &$debuggerBodyIsSeekable): void {
+ $debuggerReceivedBody = $response->body();
+ $debuggerBodyIsSeekable = $psrResponse->getBody()->isSeekable();
+ },
+ );
+
+ $response = $connector->send(new UserRequest, $mockClient);
+
+ $this->assertSame($expectedBody, $debuggerReceivedBody);
+ $this->assertTrue($debuggerBodyIsSeekable);
+ $this->assertSame($expectedBody, $response->body());
+ }
+
+ public function testRequestDebuggerObservesTheFinalPsrRequestExactlyOnce(): void
+ {
+ $capturedRequest = null;
+ $request = (new DebugRequestStub)
+ ->withBody('request-body', null)
+ ->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$capturedRequest): void {
+ $capturedRequest = $psrRequest;
+ });
+ $pendingRequest = $this->pendingRequest($request)
+ ->executeRequestPipeline()
+ ->finalizeUri()
+ ->prepareBody();
+
+ $pendingRequest->createPsrRequest();
+
+ $this->assertSame(1, DebugRequestStub::$psrHookCalls);
+ $this->assertInstanceOf(RequestInterface::class, $capturedRequest);
+ $this->assertSame('handled', $capturedRequest->getHeaderLine('X-Psr-Hook'));
+ $this->assertSame('request-body', (string) $capturedRequest->getBody());
+ }
+
+ public function testDefaultRequestDebuggerRestoresTheSeekableBodyPosition(): void
+ {
+ $body = Utils::streamFor('request-body');
+ $body->seek(4);
+
+ $this->assertSame('request-body', DebuggerReaderStub::readBody($body));
+
+ $this->assertSame(4, $body->tell());
+ $this->assertSame('est-body', $body->getContents());
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ DebugRequestStub::$psrHookCalls = 0;
+ }
+
+ /**
+ * Create a pending request with isolated framework dependencies.
+ */
+ protected function pendingRequest(Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ new DebugConnectorStub,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+
+ /**
+ * Run the callback and return what it dumps.
+ */
+ protected function dump(Closure $callback): string
+ {
+ $output = fopen('php://memory', 'rwb+');
+
+ VarDumper::setHandler(static function (mixed $var, ?string $label = null) use ($output): void {
+ $var = (new VarCloner)->cloneVar($var);
+
+ if ($label !== null) {
+ $var = $var->withContext(['label' => $label]);
+ }
+
+ (new CliDumper)->dump($var, $output);
+ });
+
+ try {
+ $callback();
+ } finally {
+ VarDumper::setHandler(null);
+ }
+
+ rewind($output);
+
+ return stream_get_contents($output);
+ }
+
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+}
+
+class DebuggingConnectorStub extends TestConnector
+{
+ /**
+ * Create a connector that registers debuggers for each operation.
+ */
+ public function __construct(
+ protected readonly ?Closure $onRequest = null,
+ protected readonly ?Closure $onResponse = null,
+ protected readonly ?Closure $responseMiddleware = null,
+ ) {
+ parent::__construct();
+ }
+
+ /**
+ * Configure a pending request for this resource.
+ */
+ public function boot(PendingRequest $pendingRequest): void
+ {
+ if ($this->onRequest !== null) {
+ $pendingRequest->debugRequest($this->onRequest);
+ }
+
+ if ($this->onResponse !== null) {
+ $pendingRequest->debugResponse($this->onResponse);
+ }
+
+ if ($this->responseMiddleware !== null) {
+ $pendingRequest->middleware()->onResponse($this->responseMiddleware);
+ }
+ }
+}
+
+class DebugConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+}
+
+class DebugRequestStub extends Request
+{
+ use HasStringBody;
+
+ public static int $psrHookCalls = 0;
+
+ protected Method $method = Method::POST;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Modify the final PSR request.
+ */
+ public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface
+ {
+ ++static::$psrHookCalls;
+
+ return $request->withHeader('X-Psr-Hook', 'handled');
+ }
+}
+
+class DebuggerReaderStub extends Debugger
+{
+ /**
+ * Read a request body through the debugger.
+ */
+ public static function readBody(StreamInterface $body): string
+ {
+ return static::requestBody($body);
+ }
+}
diff --git a/tests/Saloon/Feature/GuzzleSenderTest.php b/tests/Saloon/Feature/GuzzleSenderTest.php
new file mode 100644
index 0000000000..eac5c1a569
--- /dev/null
+++ b/tests/Saloon/Feature/GuzzleSenderTest.php
@@ -0,0 +1,96 @@
+createPendingRequest($request);
+
+ $sent = null;
+ Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface {
+ $sent = $request->toPsrRequest();
+
+ return Http::response();
+ });
+
+ $connector->send($request);
+
+ $this->assertSame($pendingRequest->method()->value, $sent->getMethod());
+ $this->assertSame((string) $pendingRequest->uri(), (string) $sent->getUri());
+ }
+
+ public function testTheGuzzleSenderWillSendAllHeadersQueryParametersAndConfig(): void
+ {
+ $request = (new UserRequest)
+ ->withOptions(['timeout' => 120, 'debug' => true])
+ ->withQueryParameters(['shanty' => 'yes', 'sing' => 'yes'])
+ ->withHeader('X-Bound-For', 'South-Australia')
+ ->withHeader('X-Fancy', ['keyOne' => 'valOne', 'keyTwo' => 'valTwo']);
+
+ $sent = null;
+ $sentOptions = null;
+ Http::fake(function (HttpRequest $request, array $options) use (&$sent, &$sentOptions): PromiseInterface {
+ $sent = $request->toPsrRequest();
+ $sentOptions = $options;
+
+ return Http::response();
+ });
+
+ (new TestConnector)->send($request);
+
+ $this->assertSame(120, $sentOptions['timeout']);
+ $this->assertTrue($sentOptions['debug']);
+ $this->assertSame('shanty=yes&sing=yes', $sent->getUri()->getQuery());
+ $this->assertSame('South-Australia', $sent->getHeaderLine('X-Bound-For'));
+ $this->assertSame('valOne, valTwo', $sent->getHeaderLine('X-Fancy'));
+ }
+
+ // REMOVED: the default handler stack cases. Registered HTTP connections own the transport handler; Guzzle-level
+ // hooks are Http::globalMiddleware() and Saloon's PSR request hooks and middleware.
+
+ // Saloon decides when a response has failed, so the HTTP client never throws for an error status.
+ public function testTheGuzzleSenderHasDefaultOptionsConfigured(): void
+ {
+ $sentOptions = null;
+ Http::fake(function (HttpRequest $request, array $options) use (&$sentOptions): PromiseInterface {
+ $sentOptions = $options;
+
+ return Http::response();
+ });
+
+ (new TestConnector)->send(new UserRequest);
+
+ $this->assertSame(10, $sentOptions['connect_timeout']);
+ $this->assertSame(30, $sentOptions['timeout']);
+ $this->assertFalse($sentOptions['http_errors']);
+ $this->assertSame(STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, $sentOptions['crypto_method']);
+ }
+
+ // REMOVED: the custom handler stack case; see above.
+}
diff --git a/tests/Saloon/Feature/PsrTest.php b/tests/Saloon/Feature/PsrTest.php
new file mode 100644
index 0000000000..faec161a56
--- /dev/null
+++ b/tests/Saloon/Feature/PsrTest.php
@@ -0,0 +1,64 @@
+ 'Sam']),
+ ]);
+
+ $connector = new ModifiedPsrRequestConnector;
+
+ $response = $connector->send(new ModifiedPsrUserRequest, $mockClient);
+
+ // The connector will change the URI to https://google.com
+
+ $this->assertSame('https://google.com', (string) $response->toPsrRequest()->getUri());
+
+ // The request will add the X-Howdy header
+
+ $this->assertSame(['Yeehaw'], $response->toPsrRequest()->getHeader('X-Howdy'));
+ }
+
+ public function testThePsrRequestHeadersMustBeConvertedToEmptyString(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(headers: ['X-Null-Header' => null]),
+ ]);
+
+ $connector = new TestConnector;
+
+ $response = $connector->send(new NullHeaderRequest, $mockClient);
+
+ // The request will convert null to empty string
+ $this->assertSame('', $response->toPsrRequest()->getHeader('X-Null-Header')[0]);
+
+ // The response will convert null header to empty string
+ $this->assertSame('', $response->header('X-Null-Header'));
+ $this->assertSame([''], $response->headers()['X-Null-Header']);
+ }
+}
diff --git a/tests/Saloon/Feature/RequestExceptionTest.php b/tests/Saloon/Feature/RequestExceptionTest.php
new file mode 100644
index 0000000000..e230913179
--- /dev/null
+++ b/tests/Saloon/Feature/RequestExceptionTest.php
@@ -0,0 +1,349 @@
+ 'Server Error'], 500),
+ ]);
+
+ $response = TestConnector::make()->send(new UserRequest, $mockClient);
+
+ $this->assertInstanceOf(Response::class, $response);
+
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(InternalServerErrorException::class, $exception);
+ $this->assertInstanceOf(ServerException::class, $exception);
+ $this->assertSame(static::message($response), $exception->getMessage());
+ $this->assertNull($exception->getPrevious());
+
+ $this->expectExceptionObject($exception);
+
+ $response->throw();
+ }
+
+ // REMOVED: the two sendAsync() promise cases. Coroutine pools replace promises.
+
+ public function testYouCanCustomiseTheExceptionHandlerOnAConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Server Error'], 500),
+ ]);
+
+ $response = CustomExceptionConnector::make()->send(new UserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(ConnectorRequestException::class, $exception);
+ $this->assertSame('Oh yee-naw.', $exception->getMessage());
+ }
+
+ public function testYouCanCustomiseTheExceptionHandlerOnARequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Server Error'], 500),
+ ]);
+
+ $response = TestConnector::make()->send(new CustomExceptionUserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(CustomRequestException::class, $exception);
+ $this->assertSame('Oh yee-naw.', $exception->getMessage());
+ }
+
+ public function testTheRequestExceptionHandlerWillAlwaysTakePriority(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Server Error'], 500),
+ ]);
+
+ $response = CustomExceptionConnector::make()->send(new CustomExceptionUserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(CustomRequestException::class, $exception);
+ $this->assertSame('Oh yee-naw.', $exception->getMessage());
+ }
+
+ public function testYouCanCustomiseIfSaloonShouldThrowAnExceptionOnAConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Success']),
+ MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']),
+ ]);
+
+ $responseA = BadResponseConnector::make()->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->shouldThrowRequestException());
+ $this->assertNull($responseA->toException());
+
+ $responseB = BadResponseConnector::make()->send(new UserRequest, $mockClient);
+ $this->assertTrue($responseB->shouldThrowRequestException());
+ $exceptionB = $responseB->toException();
+
+ $this->assertInstanceOf(RequestException::class, $exceptionB);
+ $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest());
+ $this->assertInstanceOf(Response::class, $exceptionB->response());
+ $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage());
+ $this->assertNull($exceptionB->getPrevious());
+ }
+
+ public function testYouCanCustomiseIfSaloonShouldThrowAnExceptionOnARequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Success']),
+ MockResponse::make(['message' => 'Yee-naw: Horse Not Found']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new BadResponseRequest, $mockClient);
+
+ $this->assertFalse($responseA->shouldThrowRequestException());
+ $this->assertNull($responseA->toException());
+
+ $responseB = TestConnector::make()->send(new BadResponseRequest, $mockClient);
+ $this->assertTrue($responseB->shouldThrowRequestException());
+ $exceptionB = $responseB->toException();
+
+ $this->assertInstanceOf(RequestException::class, $exceptionB);
+ $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest());
+ $this->assertInstanceOf(Response::class, $exceptionB->response());
+ $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage());
+ $this->assertNull($exceptionB->getPrevious());
+ }
+
+ public function testWhenBothTheConnectorAndRequestHaveCustomLogicToDetermineDifferentFailuresTheyWorkTogether(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Success']),
+ MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']),
+ MockResponse::make(['message' => 'Yee-naw: Horse Not Found']),
+ ]);
+
+ $responseA = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient);
+
+ $this->assertFalse($responseA->shouldThrowRequestException());
+ $this->assertNull($responseA->toException());
+
+ $responseB = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient);
+ $this->assertTrue($responseB->shouldThrowRequestException());
+ $exceptionB = $responseB->toException();
+
+ $this->assertInstanceOf(RequestException::class, $exceptionB);
+ $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest());
+ $this->assertInstanceOf(Response::class, $exceptionB->response());
+ $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage());
+ $this->assertNull($exceptionB->getPrevious());
+
+ $responseC = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient);
+ $this->assertTrue($responseC->shouldThrowRequestException());
+ $exceptionC = $responseC->toException();
+
+ $this->assertInstanceOf(RequestException::class, $exceptionC);
+ $this->assertInstanceOf(Response::class, $exceptionC->response());
+ $this->assertSame(static::message($exceptionC->response()), $exceptionC->getMessage());
+ $this->assertNull($exceptionC->getPrevious());
+ }
+
+ public function testYouCanCustomiseIfSaloonDeterminesIfARequestHasFailedOnAConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Success']),
+ MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']),
+ ]);
+
+ $responseA = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->failed());
+
+ $responseB = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->failed());
+ }
+
+ public function testYouCanCustomiseIfSaloonDeterminesIfARequestHasFailedOnARequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Success']),
+ MockResponse::make(['message' => 'Yee-naw: Horse Not Found']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient);
+
+ $this->assertFalse($responseA->failed());
+
+ $responseB = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient);
+
+ $this->assertTrue($responseB->failed());
+ }
+
+ // Upstream calls its live not-found endpoint; a mocked 404 response proves the same behavior.
+ public function testARequestCanMarkARequestAsNotFailed(): void
+ {
+ $response = TestConnector::make()->send(new NotFoundFailedRequest, new MockClient([
+ MockResponse::make(['message' => 'Not Found'], 404),
+ ]));
+
+ $this->assertFalse($response->failed());
+ }
+
+ // REMOVED: the sendAsync() case. Coroutine pools replace promises; see the pool case below.
+
+ public function testARequestCanMarkARequestAsNotFailedWithPools(): void
+ {
+ $responseCount = 0;
+ $exceptionCount = 0;
+
+ $request = (new NotFoundFailedRequest)->withMockClient(new MockClient([
+ MockResponse::make(['message' => 'Not Found'], 404),
+ ]));
+
+ $pool = TestConnector::make()->pool([$request]);
+
+ $pool->withResponseHandler(function (Response $response) use (&$responseCount): void {
+ $this->assertSame(404, $response->status());
+
+ ++$responseCount;
+ })->withExceptionHandler(function (Throwable $exception) use (&$exceptionCount): void {
+ ++$exceptionCount;
+ });
+
+ $pool->send();
+
+ $this->assertSame(1, $responseCount);
+ $this->assertSame(0, $exceptionCount);
+ }
+
+ // Upstream connects to hostnames that do not resolve; a refused loopback port fails the same way without DNS.
+ public function testTheSenderWillThrowAFatalRequestExceptionIfItCannotConnectToASite(): void
+ {
+ $connector = new TestConnector('http://127.0.0.1:1');
+ $request = new UserRequest;
+
+ $this->expectException(FatalRequestException::class);
+
+ $connector->send($request);
+ }
+
+ // REMOVED: the sendAsync() case. Coroutine pools replace promises.
+
+ #[DataProvider('incompleteTransfers')]
+ public function testATransferThatFailsWhileReceivingTheResponseIsFatal(int $status, string $transportException): void
+ {
+ Http::fake(fn (HttpRequest $request): PromiseInterface => Create::rejectionFor(static::incompleteTransfer($request, $status)));
+
+ $request = new UserRequest;
+ $fatalExceptions = [];
+ $request->middleware()->onFatalException(function (FatalRequestException $exception) use (&$fatalExceptions): void {
+ $fatalExceptions[] = $exception;
+ });
+
+ try {
+ TestConnector::make()->send($request);
+
+ $this->fail('The incomplete transfer did not throw.');
+ } catch (FatalRequestException $exception) {
+ $this->assertInstanceOf(ConnectionException::class, $exception->getPrevious());
+ $this->assertInstanceOf($transportException, $exception->getPrevious()->getPrevious());
+ $this->assertSame([$exception], $fatalExceptions);
+ }
+ }
+
+ /**
+ * Get the statuses of incomplete transfers and the exceptions the HTTP client reports for them.
+ *
+ * @return array}>
+ */
+ public static function incompleteTransfers(): array
+ {
+ return [
+ 'successful status' => [200, GuzzleRequestException::class],
+ 'error status' => [502, HttpRequestException::class],
+ ];
+ }
+
+ public function testAnIncompleteTransferIsRetried(): void
+ {
+ $attempts = 0;
+ Http::fake(function (HttpRequest $request) use (&$attempts): PromiseInterface {
+ return ++$attempts === 1
+ ? Create::rejectionFor(static::incompleteTransfer($request, 502))
+ : Http::response(['name' => 'Sam']);
+ });
+
+ $response = TestConnector::make()->send((new UserRequest)->retry(2));
+
+ $this->assertSame(2, $attempts);
+ $this->assertSame(['name' => 'Sam'], $response->json());
+ }
+
+ /**
+ * Get the message the HTTP client's request exception gives a response.
+ */
+ protected static function message(Response $response): string
+ {
+ return sprintf("HTTP request returned status code %d:\n%s\n", $response->status(), $response->body());
+ }
+
+ /**
+ * Create the exception cURL reports when a transfer fails after the response headers arrive.
+ */
+ protected static function incompleteTransfer(HttpRequest $request, int $status): GuzzleRequestException
+ {
+ $response = new PsrResponse($status, [], 'partial');
+
+ return class_exists(ResponseException::class)
+ ? new ResponseException('cURL error 56: Recv failure', $request->toPsrRequest(), $response)
+ : new GuzzleRequestException('cURL error 56: Recv failure', $request->toPsrRequest(), $response);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/BadResponseConnector.php b/tests/Saloon/Fixtures/Connectors/BadResponseConnector.php
new file mode 100644
index 0000000000..1f6498894a
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/BadResponseConnector.php
@@ -0,0 +1,30 @@
+body(), 'Error:');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php b/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php
new file mode 100644
index 0000000000..2ec49dc6ec
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php
@@ -0,0 +1,32 @@
+body(), 'Error:');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/DtoConnector.php b/tests/Saloon/Fixtures/Connectors/DtoConnector.php
new file mode 100644
index 0000000000..5b177a50c2
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/DtoConnector.php
@@ -0,0 +1,41 @@
+withUri(new Uri('https://google.com'));
+ }
+}
diff --git a/tests/Saloon/Fixtures/Data/ApiResponse.php b/tests/Saloon/Fixtures/Data/ApiResponse.php
new file mode 100644
index 0000000000..f787d182ad
--- /dev/null
+++ b/tests/Saloon/Fixtures/Data/ApiResponse.php
@@ -0,0 +1,26 @@
+json());
+ }
+}
diff --git a/tests/Saloon/Fixtures/Data/User.php b/tests/Saloon/Fixtures/Data/User.php
new file mode 100644
index 0000000000..b02276d899
--- /dev/null
+++ b/tests/Saloon/Fixtures/Data/User.php
@@ -0,0 +1,30 @@
+json();
+
+ return new static($data['name'], $data['actual_name'], $data['twitter']);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Data/UserWithResponse.php b/tests/Saloon/Fixtures/Data/UserWithResponse.php
new file mode 100644
index 0000000000..790aba5159
--- /dev/null
+++ b/tests/Saloon/Fixtures/Data/UserWithResponse.php
@@ -0,0 +1,34 @@
+json();
+
+ return new static($data['name'], $data['actual_name'], $data['twitter']);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php b/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php
new file mode 100644
index 0000000000..f64e50e62d
--- /dev/null
+++ b/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php
@@ -0,0 +1,19 @@
+withBody(new NoSeekStream($response->getBody()));
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php b/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php
new file mode 100644
index 0000000000..5af9ac8f6e
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php
@@ -0,0 +1,33 @@
+body(), 'Yee-naw:');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php b/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php
new file mode 100644
index 0000000000..ba94b855f8
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php
@@ -0,0 +1,35 @@
+body(), 'Yee-naw:');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/DTORequest.php b/tests/Saloon/Fixtures/Requests/DTORequest.php
new file mode 100644
index 0000000000..10c7ac15b1
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/DTORequest.php
@@ -0,0 +1,43 @@
+withHeader('X-Howdy', 'Yeehaw');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php b/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php
new file mode 100644
index 0000000000..d9a67bbe36
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php
@@ -0,0 +1,40 @@
+status() === 404) {
+ return false;
+ }
+
+ return $response->serverError() || $response->clientError();
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php b/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php
new file mode 100644
index 0000000000..4cfe37ca7d
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php
@@ -0,0 +1,34 @@
+ null,
+ ];
+ }
+}
diff --git a/tests/Saloon/Http/DebugTest.php b/tests/Saloon/Http/DebugTest.php
deleted file mode 100644
index afa4073e55..0000000000
--- a/tests/Saloon/Http/DebugTest.php
+++ /dev/null
@@ -1,141 +0,0 @@
-withBody('request-body', null)
- ->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$capturedRequest): void {
- $capturedRequest = $psrRequest;
- });
- $pendingRequest = $this->pendingRequest($request)
- ->executeRequestPipeline()
- ->finalizeUri()
- ->prepareBody();
-
- $pendingRequest->createPsrRequest();
-
- $this->assertSame(1, DebugRequestStub::$psrHookCalls);
- $this->assertInstanceOf(RequestInterface::class, $capturedRequest);
- $this->assertSame('handled', $capturedRequest->getHeaderLine('X-Psr-Hook'));
- $this->assertSame('request-body', (string) $capturedRequest->getBody());
- }
-
- public function testResponseDebuggerBuffersANonSeekableBodyBeforeTheCallback(): void
- {
- $capturedBody = null;
- $request = (new DebugRequestStub)->debugResponse(
- function (Response $response, ResponseInterface $psrResponse) use (&$capturedBody): void {
- $capturedBody = $response->body();
- $this->assertTrue($psrResponse->getBody()->isSeekable());
- },
- );
- $pendingRequest = $this->pendingRequest($request);
- $psrRequest = new PsrRequest('GET', 'https://api.example.com/users');
- $response = $pendingRequest->createResponse(
- new HttpResponse(new PsrResponse(body: new NoSeekStream(Utils::streamFor('response-body')))),
- $psrRequest,
- );
-
- $result = $pendingRequest->executeResponsePipeline($response);
-
- $this->assertSame('response-body', $capturedBody);
- $this->assertSame('response-body', $result->body());
- }
-
- public function testDefaultRequestDebuggerRestoresTheSeekableBodyPosition(): void
- {
- $pendingRequest = $this->pendingRequest(new DebugRequestStub);
- $body = Utils::streamFor('request-body');
- $body->seek(4);
- $request = new PsrRequest('POST', 'https://api.example.com/users', body: $body);
-
- $this->assertSame('request-body', DebuggerReaderStub::readBody($body));
-
- $this->assertSame(4, $body->tell());
- $this->assertSame('est-body', $body->getContents());
- }
-
- protected function setUp(): void
- {
- parent::setUp();
-
- DebugRequestStub::$psrHookCalls = 0;
- }
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Request $request): PendingRequest
- {
- return new PendingRequest(
- new DebugConnectorStub,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
-}
-
-class DebugConnectorStub extends Connector
-{
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-}
-
-class DebugRequestStub extends Request
-{
- use HasStringBody;
-
- public static int $psrHookCalls = 0;
-
- protected Method $method = Method::POST;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface
- {
- ++static::$psrHookCalls;
-
- return $request->withHeader('X-Psr-Hook', 'handled');
- }
-}
-
-class DebuggerReaderStub extends Debugger
-{
- public static function readBody(StreamInterface $body): string
- {
- return static::requestBody($body);
- }
-}
diff --git a/tests/Saloon/Http/FixtureTest.php b/tests/Saloon/Http/FixtureTest.php
index 8cb26b74b2..5508439ae4 100644
--- a/tests/Saloon/Http/FixtureTest.php
+++ b/tests/Saloon/Http/FixtureTest.php
@@ -177,6 +177,9 @@ public function testRedactionExecutionFailurePreventsFixturePublication(): void
}
}
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
@@ -188,6 +191,9 @@ protected function setUp(): void
$this->bindManager(false);
}
+ /**
+ * Clean up the test environment.
+ */
protected function tearDown(): void
{
$this->files->deleteDirectory($this->fixturePath);
diff --git a/tests/Saloon/Http/MockClientTest.php b/tests/Saloon/Http/MockClientTest.php
index 28639e7222..d8ec2dcbf3 100644
--- a/tests/Saloon/Http/MockClientTest.php
+++ b/tests/Saloon/Http/MockClientTest.php
@@ -161,6 +161,9 @@ public function testGlobalClientDelegatesToTheContainerManagerWithoutStaticState
$this->assertNull(MockClient::getGlobal());
}
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
@@ -171,6 +174,9 @@ protected function setUp(): void
$this->files->ensureDirectoryExists($this->fixturePath);
}
+ /**
+ * Clean up the test environment.
+ */
protected function tearDown(): void
{
$this->files->deleteDirectory($this->fixturePath);
diff --git a/tests/Saloon/Http/ResponseTest.php b/tests/Saloon/Http/ResponseTest.php
deleted file mode 100644
index afca8dbca8..0000000000
--- a/tests/Saloon/Http/ResponseTest.php
+++ /dev/null
@@ -1,398 +0,0 @@
-response(422)->truncateExceptionsAt(37);
- $serverResponse = $this->response(500)->truncateExceptionsAt(38);
- $request = new ResponseRequestStub;
- $request->throwVerdict = true;
- $generalResponse = $this->response(200, request: $request)->truncateExceptionsAt(39);
-
- $clientException = $clientResponse->toException();
- $serverException = $serverResponse->toException();
- $generalException = $generalResponse->toException();
-
- $this->assertInstanceOf(ClientException::class, $clientException);
- $this->assertSame(37, $clientException->truncateExceptionsAt);
- $this->assertInstanceOf(ServerException::class, $serverException);
- $this->assertSame(38, $serverException->truncateExceptionsAt);
- $this->assertInstanceOf(RequestException::class, $generalException);
- $this->assertSame(39, $generalException->truncateExceptionsAt);
- }
-
- public function testRequestFailureAndExceptionVerdictsTakePriorityOverConnectorVerdicts(): void
- {
- $connector = new ResponseConnectorStub;
- $connector->failureVerdict = true;
- $connector->customException = true;
-
- $request = new ResponseRequestStub;
- $request->failureVerdict = false;
- $request->throwVerdict = true;
- $request->customException = true;
-
- $response = $this->response(500, connector: $connector, request: $request);
-
- $this->assertFalse($response->failed());
- $this->assertInstanceOf(ResponseRequestExceptionStub::class, $response->toException());
- }
-
- public function testFailurePoliciesDriveOnErrorAndFailureNamedThrowingHelpers(): void
- {
- $connector = new ResponseConnectorStub;
- $connector->failureVerdict = true;
- $response = $this->response(200, connector: $connector);
- $called = false;
-
- $response->onError(function () use (&$called): void {
- $called = true;
- });
-
- $this->assertTrue($response->successful());
- $this->assertTrue($response->failed());
- $this->assertTrue($called);
-
- $request = new ResponseRequestStub;
- $request->failureVerdict = false;
- $suppressed = $this->response(404, connector: $connector, request: $request);
-
- $this->assertSame($suppressed, $suppressed->throwIfClientError());
-
- $this->expectException(ClientException::class);
- $suppressed->throwIfStatus(404);
- }
-
- public function testThrowPolicyCanSuppressAResponseThatStillReportsFailed(): void
- {
- $connector = new ResponseConnectorStub;
- $connector->throwVerdict = false;
- $request = new ResponseRequestStub;
- $request->throwVerdict = false;
- $response = $this->response(500, connector: $connector, request: $request);
-
- $this->assertTrue($response->failed());
- $this->assertNull($response->toException());
- $this->assertSame($response, $response->throw());
- }
-
- public function testAlwaysThrowOnErrorsUsesTheIntegrationThrowPolicy(): void
- {
- $request = new ResponseAlwaysThrowRequestStub;
- $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, $request);
- $request->bootAlwaysThrowOnErrors($pendingRequest);
- $response = $this->response(500, request: $request, pendingRequest: $pendingRequest);
-
- $this->expectException(ServerException::class);
- $pendingRequest->executeResponsePipeline($response);
- }
-
- public function testResponseCanBeConvertedToADataUrl(): void
- {
- $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub);
- $psrRequest = new PsrRequest('GET', 'https://api.example.com/users');
- $response = Response::fromResponse(
- new HttpResponse(new PsrResponse(200, ['Content-Type' => 'text/plain'], 'hello')),
- $pendingRequest,
- $psrRequest,
- );
-
- $this->assertSame('data:text/plain;base64,aGVsbG8=', $response->dataUrl());
- }
-
- public function testNonSeekableBodyIsBufferedOnce(): void
- {
- $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub);
- $psrRequest = new PsrRequest('GET', 'https://api.example.com/users');
- $httpResponse = new HttpResponse(new PsrResponse(
- body: new NoSeekStream(Utils::streamFor('response body')),
- ));
- $response = Response::fromResponse($httpResponse, $pendingRequest, $psrRequest);
-
- $this->assertSame('response body', $response->body());
- $this->assertSame('response body', $response->body());
- $this->assertTrue($response->stream()->isSeekable());
- $this->assertSame($psrRequest, $response->toPsrRequest());
- }
-
- public function testNonSeekableBodyConsumptionLeavesLinesAtTheEnd(): void
- {
- $response = $this->response(200, body: new NoSeekStream(Utils::streamFor("one\ntwo\n")));
-
- $this->assertSame("one\ntwo\n", $response->body());
- $this->assertSame(8, $response->stream()->tell());
- $this->assertSame([], iterator_to_array($response->lines()));
-
- $response->stream()->rewind();
-
- $this->assertSame(['one', 'two'], iterator_to_array($response->lines()));
- }
-
- public function testJsonLinesReadANonSeekableResponseFromItsCurrentPosition(): void
- {
- $stream = new NoSeekStream(Utils::streamFor("skip\n{\"id\":1}\n{\"id\":2}\n"));
- $stream->read(5);
- $response = $this->response(200, body: $stream);
-
- $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($response->jsonLines()));
- $this->assertSame($stream, $response->stream());
- }
-
- public function testBodyExportsPreservePositionsAndCallerOwnedResources(): void
- {
- $response = $this->response(200, body: 'response body');
- $response->stream()->seek(4);
- $resource = fopen('php://temp', 'wb+');
- $this->assertIsResource($resource);
- fwrite($resource, 'stale contents');
-
- $response->saveBodyToFile($resource, false);
-
- $this->assertIsResource($resource);
- $this->assertSame(4, $response->stream()->tell());
- $this->assertSame('response body', stream_get_contents($resource));
- fclose($resource);
-
- $rawStream = $response->getRawStream();
- $this->assertIsResource($rawStream);
- $this->assertSame('response body', stream_get_contents($rawStream));
- fclose($rawStream);
- }
-
- public function testFailedBodyExportDoesNotCloseACallerOwnedResource(): void
- {
- $source = m::mock(StreamInterface::class);
- $source->shouldReceive('isSeekable')->once()->andReturn(false);
- $source->shouldReceive('eof')->once()->andReturn(false);
- $source->shouldReceive('read')->once()->andThrow(new RuntimeException('read failed'));
- $response = $this->response(200, body: $source);
- $resource = fopen('php://temp', 'wb+');
- $this->assertIsResource($resource);
-
- try {
- $response->saveBodyToFile($resource, false);
- $this->fail('The response source should fail while being copied.');
- } catch (RuntimeException $exception) {
- $this->assertSame('read failed', $exception->getMessage());
- $this->assertIsResource($resource);
- } finally {
- if (is_resource($resource)) {
- fclose($resource);
- }
- }
- }
-
- public function testRequestDataObjectTakesPriorityOverConnectorDataObject(): void
- {
- $response = $this->response(
- 200,
- connector: new ResponseDtoConnectorStub,
- request: new ResponseDtoRequestStub,
- );
-
- $this->assertInstanceOf(ResponseRequestDtoStub::class, $response->dto());
- }
-
- public function testConnectorCreatesTheDataObjectWhenTheRequestDoesNot(): void
- {
- $response = $this->response(200, connector: new ResponseDtoConnectorStub);
-
- $this->assertInstanceOf(ResponseConnectorDtoStub::class, $response->dto());
- }
-
- public function testDataObjectReceivesItsOriginalResponse(): void
- {
- $response = $this->response(200, request: new ResponseWithResponseDtoRequestStub);
- $dataObject = $response->dto();
-
- $this->assertInstanceOf(ResponseWithResponseDtoStub::class, $dataObject);
- $this->assertSame($response, $dataObject->getResponse());
- }
-
- public function testDtoOrFailPreservesTheRequestException(): void
- {
- $response = $this->response(500, request: new ResponseDtoRequestStub);
-
- try {
- $response->dtoOrFail();
- $this->fail('A failed response should not be converted into a data object.');
- } catch (LogicException $exception) {
- $this->assertSame('Unable to create a data transfer object because the response failed.', $exception->getMessage());
- $this->assertInstanceOf(ServerException::class, $exception->getPrevious());
- }
- }
-
- /**
- * Create a Saloon response for the given operation.
- */
- protected function response(
- int $status,
- StreamInterface|string $body = 'response body',
- ?Connector $connector = null,
- ?Request $request = null,
- ?PendingRequest $pendingRequest = null,
- ): Response {
- $connector ??= new ResponseConnectorStub;
- $request ??= new ResponseRequestStub;
- $pendingRequest ??= $this->pendingRequest($connector, $request);
- $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users');
-
- return Response::fromResponse(
- new HttpResponse(new PsrResponse($status, body: $body)),
- $pendingRequest,
- $psrRequest,
- );
- }
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
-}
-
-class ResponseConnectorStub extends Connector
-{
- public ?bool $failureVerdict = null;
-
- public ?bool $throwVerdict = null;
-
- public bool $customException = false;
-
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-
- public function hasRequestFailed(Response $response): ?bool
- {
- return $this->failureVerdict;
- }
-
- public function shouldThrowRequestException(Response $response): bool
- {
- return $this->throwVerdict ?? $response->failed();
- }
-
- public function getRequestException(Response $response): ?RequestException
- {
- return $this->customException ? new ResponseConnectorExceptionStub($response) : null;
- }
-}
-
-class ResponseRequestStub extends Request
-{
- public ?bool $failureVerdict = null;
-
- public ?bool $throwVerdict = null;
-
- public bool $customException = false;
-
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- public function hasRequestFailed(Response $response): ?bool
- {
- return $this->failureVerdict;
- }
-
- public function shouldThrowRequestException(Response $response): bool
- {
- return $this->throwVerdict ?? $response->failed();
- }
-
- public function getRequestException(Response $response): ?RequestException
- {
- return $this->customException ? new ResponseRequestExceptionStub($response) : null;
- }
-}
-
-class ResponseAlwaysThrowRequestStub extends ResponseRequestStub
-{
- use AlwaysThrowOnErrors;
-}
-
-class ResponseDtoConnectorStub extends ResponseConnectorStub
-{
- public function createDtoFromResponse(Response $response): ResponseConnectorDtoStub
- {
- return new ResponseConnectorDtoStub;
- }
-}
-
-class ResponseDtoRequestStub extends ResponseRequestStub
-{
- public function createDtoFromResponse(Response $response): ResponseRequestDtoStub
- {
- return new ResponseRequestDtoStub;
- }
-}
-
-class ResponseWithResponseDtoRequestStub extends ResponseRequestStub
-{
- public function createDtoFromResponse(Response $response): ResponseWithResponseDtoStub
- {
- return new ResponseWithResponseDtoStub;
- }
-}
-
-class ResponseConnectorDtoStub
-{
-}
-
-class ResponseRequestDtoStub
-{
-}
-
-class ResponseWithResponseDtoStub implements WithResponse
-{
- use HasResponse;
-}
-
-class ResponseConnectorExceptionStub extends RequestException
-{
-}
-
-class ResponseRequestExceptionStub extends RequestException
-{
-}
diff --git a/tests/Saloon/SaloonManagerTest.php b/tests/Saloon/SaloonManagerTest.php
index 29e4f8cf92..796ab581b3 100644
--- a/tests/Saloon/SaloonManagerTest.php
+++ b/tests/Saloon/SaloonManagerTest.php
@@ -354,6 +354,9 @@ public function testSendingListenerMutationsAreFinalAndPsrInspectionHasNoSideEff
$this->assertSame($response->toPsrRequest(), $response->pendingRequest()->toPsrRequest());
}
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
diff --git a/tests/Saloon/Unit/ConfigTest.php b/tests/Saloon/Unit/ConfigTest.php
new file mode 100644
index 0000000000..81271e53da
--- /dev/null
+++ b/tests/Saloon/Unit/ConfigTest.php
@@ -0,0 +1,96 @@
+ 'Jake Owen - Beachin']),
+ ]);
+
+ $count = 0;
+
+ Saloon::middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$count): void {
+ ++$count;
+ });
+
+ Saloon::middleware()->onResponse(function (Response $response) use (&$count): void {
+ ++$count;
+ });
+
+ TestConnector::make()->send(new UserRequest, $mockClient);
+
+ $this->assertSame(2, $count);
+ }
+
+ // Upstream swaps the default sender class. Saloon sends through the configured HTTP connection instead.
+ public function testYouCanChangeTheGlobalDefaultConnectionUsed(): void
+ {
+ Http::registerConnection('secondary', ['timeout' => 12]);
+ $timeouts = [];
+ Http::fake(function (HttpRequest $request, array $options) use (&$timeouts): PromiseInterface {
+ $timeouts[] = $options['timeout'];
+
+ return Http::response();
+ });
+
+ config(['saloon.connection.name' => 'secondary']);
+ TestConnector::make()->send(new UserRequest);
+
+ config(['saloon.connection.name' => 'saloon']);
+ TestConnector::make()->send(new UserRequest);
+
+ $this->assertSame([12, 30], $timeouts);
+ }
+
+ // REMOVED: Config::setSenderResolver(). A connector selects another registered HTTP connection through
+ // resolveHttpConnection(); see Unit/SenderTest.
+
+ // REMOVED: Config::setClock(), getClock() and now(). Saloon reads the framework clock, which tests control with
+ // Date::setTestNow().
+
+ public function testYouCanPreventStrayApiRequests(): void
+ {
+ Http::preventStrayRequests();
+
+ $this->expectException(StrayRequestException::class);
+ $this->expectExceptionMessage('Attempted request to [https://tests.saloon.dev/api/user] without a matching fake.');
+
+ TestConnector::make()->send(new UserRequest);
+ }
+
+ // Allowing stray requests again sends a real request, so that case runs against the engine test server in
+ // tests/Integration/Saloon/Unit/ConfigTest.
+
+ // REMOVED: Config::sleepUsing() and sleep(). Saloon sleeps through the framework Sleep class, which tests fake with
+ // Sleep::fake(); see the delay and retry tests.
+}
diff --git a/tests/Saloon/Unit/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
index b11de35687..f4f5e527eb 100644
--- a/tests/Saloon/Unit/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -376,6 +376,9 @@ protected function getPackageProviders(ApplicationContract $app): array
return [SaloonServiceProvider::class];
}
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
diff --git a/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php b/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php
new file mode 100644
index 0000000000..8dd9c81fc2
--- /dev/null
+++ b/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php
@@ -0,0 +1,35 @@
+expectException(ServerException::class);
+
+ (new TestConnector)->send(new AlwaysThrowRequest, new MockClient([
+ MockResponse::make(['message' => 'Server Error'], 500),
+ ]));
+ }
+}
diff --git a/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php b/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php
new file mode 100644
index 0000000000..4d562b0d35
--- /dev/null
+++ b/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php
@@ -0,0 +1,98 @@
+ 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']),
+ ]);
+
+ $response = (new TestConnector)->send(new DTORequest, $mockClient);
+ $dto = $response->dto();
+ $json = $response->json();
+
+ $this->assertTrue($response->isMocked());
+ $this->assertInstanceOf(User::class, $dto);
+ $this->assertSame($json['name'], $dto->name);
+ $this->assertSame($json['actual_name'], $dto->actualName);
+ $this->assertSame($json['twitter'], $dto->twitter);
+ }
+
+ public function testItCanCastToADtoThatIsDefinedOnAConnector(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']),
+ ]);
+
+ $connector = new DtoConnector;
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $dto = $response->dto();
+
+ $this->assertInstanceOf(ApiResponse::class, $dto);
+ $this->assertSame($response->json(), $dto->data);
+ }
+
+ public function testTheRequestDtoWillBeReturnedAsAHigherPriorityThanTheConnectorDto(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']),
+ ]);
+
+ $connector = new DtoConnector;
+
+ $response = $connector->send(new DTORequest, $mockClient);
+ $dto = $response->dto();
+ $json = $response->json();
+
+ $this->assertInstanceOf(User::class, $dto);
+ $this->assertSame($json['name'], $dto->name);
+ $this->assertSame($json['actual_name'], $dto->actualName);
+ $this->assertSame($json['twitter'], $dto->twitter);
+ }
+
+ public function testYouCanUseTheDtoOrFailMethodToThrowAnExceptionIfTheResponseHasFailed(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse(['message' => 'Server Error'], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new DTORequest, $mockClient);
+
+ try {
+ $response->dtoOrFail();
+
+ $this->fail('A failed response was converted into a data transfer object.');
+ } catch (LogicException $exception) {
+ $this->assertSame('Unable to create data transfer object as the response has failed.', $exception->getMessage());
+ $this->assertInstanceOf(InternalServerErrorException::class, $exception->getPrevious());
+ }
+ }
+}
diff --git a/tests/Saloon/Unit/PsrTest.php b/tests/Saloon/Unit/PsrTest.php
new file mode 100644
index 0000000000..3bd10b94ae
--- /dev/null
+++ b/tests/Saloon/Unit/PsrTest.php
@@ -0,0 +1,93 @@
+createPendingRequest($request);
+ $request = $pendingRequest->createPsrRequest();
+
+ $this->assertInstanceOf(RequestInterface::class, $request);
+ $this->assertInstanceOf(UriInterface::class, $request->getUri());
+ $this->assertSame('https://tests.saloon.dev/api/user', (string) $request->getUri());
+ $this->assertSame('GET', $request->getMethod());
+ $this->assertSame([
+ 'Host' => ['tests.saloon.dev'],
+ 'Accept' => ['application/json'],
+ ], $request->getHeaders());
+
+ $this->assertSame('1.1', $request->getProtocolVersion());
+ }
+
+ public function testIfRequestBodyIsPresentThenItWillBeOnThePsr7Request(): void
+ {
+ $connector = new TestConnector;
+ $request = new HasJsonBodyRequest;
+
+ $pendingRequest = $connector->createPendingRequest($request);
+ $request = $pendingRequest->createPsrRequest();
+
+ $body = $request->getBody();
+
+ $this->assertInstanceOf(StreamInterface::class, $body);
+ $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!"}', $body->getContents());
+ }
+
+ public function testYouCanGenerateAUriFromThePendingRequest(): void
+ {
+ $connector = new QueryParameterConnector;
+ $request = new QueryParameterRequest('/user?include=hats#fragment-123');
+
+ $pendingRequest = $connector->createPendingRequest($request);
+ $uri = $pendingRequest->uri();
+
+ $this->assertInstanceOf(UriInterface::class, $uri);
+
+ $this->assertSame('https://tests.saloon.dev/api/user?include=hats&sort=first_name&per_page=100#fragment-123', (string) $uri);
+ $this->assertSame('https', $uri->getScheme());
+ $this->assertSame('tests.saloon.dev', $uri->getHost());
+ $this->assertSame('/api/user', $uri->getPath());
+ $this->assertSame('include=hats&sort=first_name&per_page=100', $uri->getQuery());
+ $this->assertSame('fragment-123', $uri->getFragment());
+ }
+
+ public function testWhenUsingTheUrlForQueryParametersYouCanUseDotsAndValueLessParameters(): void
+ {
+ $connector = new TestConnector;
+ $request = new QueryParameterRequest('/user?account.id=1&checked&name=sam');
+
+ $pendingRequest = $connector->createPendingRequest($request);
+ $uri = $pendingRequest->uri();
+
+ // Query pairs in the endpoint are kept as written, so the value-less parameter is not rewritten as
+ // upstream's "checked=".
+ $this->assertSame('account.id=1&checked&name=sam&per_page=100', $uri->getQuery());
+ }
+}
diff --git a/tests/Saloon/Unit/RequestExceptionTest.php b/tests/Saloon/Unit/RequestExceptionTest.php
new file mode 100644
index 0000000000..ade6345f18
--- /dev/null
+++ b/tests/Saloon/Unit/RequestExceptionTest.php
@@ -0,0 +1,125 @@
+ 'Oh yee-naw!'], $status),
+ ]);
+
+ $response = TestConnector::make()->send(new UserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertInstanceOf($expectedException, $exception);
+ $this->assertSame(static::message($response), $exception->getMessage());
+ }
+
+ /**
+ * Get the statuses and the exceptions they produce.
+ *
+ * @return array}>
+ */
+ public static function statusExceptions(): array
+ {
+ return [
+ [400, BadRequestException::class],
+ [401, UnauthorizedException::class],
+ [402, PaymentRequiredException::class],
+ [403, ForbiddenException::class],
+ [404, NotFoundException::class],
+ [405, MethodNotAllowedException::class],
+ [408, RequestTimeOutException::class],
+ [409, ConflictException::class],
+ [422, UnprocessableEntityException::class],
+ [429, TooManyRequestsException::class],
+ [500, InternalServerErrorException::class],
+ [502, BadGatewayException::class],
+ [503, ServiceUnavailableException::class],
+ [504, GatewayTimeoutException::class],
+ [418, ClientException::class],
+ [411, ClientException::class],
+ [501, ServerException::class],
+ ];
+ }
+
+ #[DataProvider('customFailureExceptions')]
+ public function testWhenTheFailedMethodIsCustomisedTheResponseWillReturnOkRequestExceptions(int $status, string $expectedException): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['message' => 'Oh yee-naw!'], $status),
+ ]);
+
+ $response = TestConnector::make()->send(new AlwaysHasFailureRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertSame($expectedException, $exception::class);
+ $this->assertSame(static::message($response), $exception->getMessage());
+ }
+
+ /**
+ * Get the non-error statuses that a custom failure check turns into exceptions.
+ *
+ * @return array}>
+ */
+ public static function customFailureExceptions(): array
+ {
+ return [
+ [302, RequestException::class],
+ [200, RequestException::class],
+ [201, RequestException::class],
+ [100, RequestException::class],
+ ];
+ }
+
+ /**
+ * Get the message the HTTP client's request exception gives a response.
+ */
+ protected static function message(Response $response): string
+ {
+ return sprintf("HTTP request returned status code %d:\n%s\n", $response->status(), $response->body());
+ }
+}
diff --git a/tests/Saloon/Unit/ResponseTest.php b/tests/Saloon/Unit/ResponseTest.php
new file mode 100644
index 0000000000..0df61b4588
--- /dev/null
+++ b/tests/Saloon/Unit/ResponseTest.php
@@ -0,0 +1,872 @@
+ 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $pendingRequest = $response->pendingRequest();
+
+ $this->assertInstanceOf(PendingRequest::class, $pendingRequest);
+ $this->assertInstanceOf(UserRequest::class, $pendingRequest->request());
+ }
+
+ public function testYouCanGetTheConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $request = new UserRequest;
+ $connector = new TestConnector;
+ $response = $connector->send($request, $mockClient);
+
+ $this->assertSame($connector, $response->connector());
+ }
+
+ public function testYouCanGetTheOriginalRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $request = new UserRequest;
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertSame($request, $response->request());
+ }
+
+ public function testYouCanGetThePsr7Request(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $request = new UserRequest;
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertInstanceOf(RequestInterface::class, $response->toPsrRequest());
+ }
+
+ public function testItWillThrowAnExceptionWhenYouUseTheThrowMethod(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->expectException(RequestException::class);
+
+ $response->throw();
+ }
+
+ public function testItWontThrowAnExceptionIfTheRequestDidNotFail(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 200),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame($response, $response->throw());
+ }
+
+ public function testToExceptionWillReturnASaloonRequestException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertInstanceOf(RequestException::class, $exception);
+ }
+
+ public function testToExceptionWontReturnAnythingIfTheRequestDidNotFail(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 200),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $exception = $response->toException();
+
+ $this->assertNull($exception);
+ }
+
+ public function testTheOnErrorMethodWillRunACustomClosure(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $count = 0;
+
+ $response->onError(function () use (&$count): void {
+ ++$count;
+ });
+
+ $this->assertSame(1, $count);
+ }
+
+ public function testTheObjectMethodWillReturnAnObject(): void
+ {
+ $data = ['name' => 'Sam', 'work' => 'Codepotato'];
+
+ $mockClient = new MockClient([
+ MockResponse::make($data, 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $dataAsObject = (object) $data;
+
+ $this->assertEquals($dataAsObject, $response->object());
+ }
+
+ // REMOVED: object() with a dot-notation key. Saloon responses keep the HTTP client's object() method, which takes
+ // decoding flags; use data_get($response->object(), 'contacts.1.name') for keyed access.
+
+ public function testTheCollectMethodWillReturnACollection(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $collection = $response->collect();
+
+ $this->assertInstanceOf(Collection::class, $collection);
+ $this->assertCount(2, $collection);
+ $this->assertSame('Sam', $collection['name']);
+ $this->assertSame('Codepotato', $collection['work']);
+
+ $this->assertSame(['Sam'], $response->collect('name')->all());
+ $this->assertEmpty($response->collect('age'));
+ }
+
+ // Upstream returns an empty array. Saloon responses keep the HTTP client's json() method, which returns null.
+ public function testTheJsonMethodWillReturnNullIfBodyIsEmpty(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make('', 404),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertNull($response->json());
+ }
+
+ public function testTheToPsrResponseMethodWillReturnAGuzzleResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertInstanceOf(PsrResponse::class, $response->toPsrResponse());
+ }
+
+ public function testYouCanGetAnIndividualHeaderFromTheResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame('Howdy', $response->header('X-Greeting'));
+ $this->assertSame('', $response->header('X-Missing'));
+ }
+
+ public function testItWillConvertTheBodyToStringIfTheCastIsUsed(): void
+ {
+ $data = ['name' => 'Sam', 'work' => 'Codepotato'];
+
+ $mockClient = new MockClient([
+ MockResponse::make($data, 200, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(json_encode($data), (string) $response);
+ }
+
+ public function testItChecksStatusesCorrectly(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']),
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500, ['X-Greeting' => 'Howdy']),
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 302, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseA->successful());
+ $this->assertTrue($responseA->ok());
+ $this->assertFalse($responseA->redirect());
+ $this->assertFalse($responseA->failed());
+ $this->assertFalse($responseA->serverError());
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseB->successful());
+ $this->assertFalse($responseB->ok());
+ $this->assertFalse($responseB->redirect());
+ $this->assertTrue($responseB->failed());
+ $this->assertTrue($responseB->serverError());
+
+ $responseC = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseC->successful());
+ $this->assertFalse($responseC->ok());
+ $this->assertTrue($responseC->redirect());
+ $this->assertFalse($responseC->failed());
+ $this->assertFalse($responseC->serverError());
+ }
+
+ public function testTheXmlMethodWillReturnXmlAsAnArray(): void
+ {
+ $mockClient = new MockClient([
+ new MockResponse('1168255false', 200),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $simpleXml = $response->xml();
+
+ $this->assertInstanceOf(SimpleXMLElement::class, $simpleXml);
+ }
+
+ // REMOVED: xmlReader(). It only wraps XML Wrangler, which does not fit Hypervel; see the package README.
+
+ // Upstream returns an ArrayStore. Saloon responses keep the HTTP client's headers() method.
+ public function testTheHeadersMethodReturnsAnArray(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['X-Greeting' => ['Howdy']], $response->headers());
+ }
+
+ // Upstream returns a single value as a string and multiple values as an array. Saloon responses keep the HTTP
+ // client's methods: headers() lists every value and header() joins them.
+ public function testHeadersWithMultipleValuesAreListedAndJoined(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy', 'X-Farewell' => ['Goodbye', 'Sam']]),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['Howdy'], $response->headers()['X-Greeting']);
+ $this->assertSame(['Goodbye', 'Sam'], $response->headers()['X-Farewell']);
+
+ $this->assertSame('Howdy', $response->header('X-Greeting'));
+ $this->assertSame('Goodbye, Sam', $response->header('X-Farewell'));
+ }
+
+ public function testTheDomMethodWillReturnACrawlerInstance(): void
+ {
+ $dom = 'Howdy Partner
';
+
+ $mockClient = new MockClient([
+ new MockResponse($dom),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertInstanceOf(Crawler::class, $response->dom());
+ $this->assertEquals(new Crawler($dom), $response->dom());
+ }
+
+ public function testWhenUsingTheBodyMethodsTheStreamIsRewoundBackToTheStart(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['foo' => 'bar'], $response->json());
+ $this->assertSame(['foo' => 'bar'], $response->array());
+ $this->assertSame('{"foo":"bar"}', $response->body());
+ $this->assertSame('{"foo":"bar"}', stream_get_contents($response->getRawStream()));
+ $this->assertEquals((object) ['foo' => 'bar'], $response->object());
+ }
+
+ public function testItCanConvertTheResponseToADataUrl(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json;encoding=utf-8']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame('data:application/json;encoding=utf-8;base64,eyJmb28iOiJiYXIifQ==', $response->dataUrl());
+ }
+
+ public function testIfAResponseIsChangedThroughMiddlewareTheNewInstanceIsUsed(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ // Connectors are read-only, so the middleware is registered on the request instead of the connector.
+ $request = new UserRequest;
+
+ $request->middleware()->onResponse(function (Response $response): Response {
+ // Let's modify the body while sending!
+ $psrResponse = $response->toPsrResponse();
+ $newPsrResponse = $psrResponse->withBody(Utils::streamFor('Hello World!'));
+
+ return $response::fromResponse(new HttpResponse($newPsrResponse), $response->pendingRequest(), $response->toPsrRequest());
+ });
+
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertSame('Hello World!', $response->body());
+ $this->assertSame(['X-Custom-Header' => ['Howdy']], $response->headers());
+ }
+
+ // The case that reads a real response as a raw resource runs against the engine test server in
+ // tests/Integration/Saloon/Unit/ResponseTest.
+
+ public function testYouCanGetTheResponseStreamAsARawResourceWithAMockResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $resource = $response->getRawStream();
+
+ $this->assertIsResource($resource);
+
+ $this->assertSame('{"foo":"bar"}', stream_get_contents($resource));
+ }
+
+ #[DataProvider('saveDestinations')]
+ public function testYouCanGetSaveTheResponseToAFile(bool $usePath): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $path = $this->tempDir . '/streamToFile.json';
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+ $response->saveBodyToFile($usePath ? $path : fopen($path, 'wb+'));
+
+ $this->assertSame('{"foo":"bar"}', file_get_contents($path));
+ }
+
+ /**
+ * Get whether the body is saved to a path or a resource.
+ *
+ * @return array
+ */
+ public static function saveDestinations(): array
+ {
+ return [
+ 'path' => [true],
+ 'resource' => [false],
+ ];
+ }
+
+ public function testTheResponseIsMacroable(): void
+ {
+ Response::macro('yee', fn (): string => 'haw');
+
+ $mockClient = new MockClient([
+ MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame('haw', $response->yee());
+ }
+
+ public function testCanDetermineIfResponseIsJson(): void
+ {
+ $mockClient = new MockClient([
+ // JSON content type
+ MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json']),
+ // JSON with charset
+ MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json; charset=utf-8']),
+ // JSON with lowercase header (e.g. FastAPI, HTTP/2)
+ MockResponse::make(['foo' => 'bar'], 200, ['content-type' => 'application/json']),
+ // Non-JSON content type
+ MockResponse::make('plain text', 200, ['Content-Type' => 'text/plain']),
+ // No content type
+ MockResponse::make('no content type', 200, []),
+ ]);
+
+ $connector = new TestConnector;
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isJson());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isJson());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isJson());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertFalse($response->isJson());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertFalse($response->isJson());
+ }
+
+ public function testCanDetermineIfResponseIsXml(): void
+ {
+ $mockClient = new MockClient([
+ // XML content type
+ MockResponse::make('', 200, ['Content-Type' => 'application/xml']),
+ // XML with charset
+ MockResponse::make('', 200, ['Content-Type' => 'text/xml; charset=utf-8']),
+ // XML with lowercase header (e.g. FastAPI, HTTP/2)
+ MockResponse::make('', 200, ['content-type' => 'application/xml']),
+ // Non-XML content type
+ MockResponse::make('plain text', 200, ['Content-Type' => 'text/plain']),
+ // No content type
+ MockResponse::make('no content type', 200, []),
+ ]);
+
+ $connector = new TestConnector;
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isXml());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isXml());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertTrue($response->isXml());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertFalse($response->isXml());
+
+ $response = $connector->send(new UserRequest, $mockClient);
+ $this->assertFalse($response->isXml());
+ }
+
+ public function testHeaderLookupIsCaseInsensitivePerHttpRfc(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 200, ['x-my-custom-header' => 'custom-value']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame('custom-value', $response->header('X-My-Custom-Header'));
+ $this->assertSame('custom-value', $response->header('x-my-custom-header'));
+ }
+
+ public function testIsJsonLookupCanUseCaseInsensitiveHeaders(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 200, ['content-type' => 'application/JSON']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($response->isJson());
+ $this->assertFalse($response->isXml());
+ }
+
+ public function testIsXmlLookupCanUseCaseInsensitiveHeaders(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([], 200, ['content-type' => 'text/xml']),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($response->isXml());
+ $this->assertFalse($response->isJson());
+ }
+
+ public function testArrayAcceptsAnIntegerKey(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make([['name' => 'Sam'], ['name' => 'Taylor']]),
+ ]);
+
+ $response = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['name' => 'Taylor'], $response->array(1));
+ $this->assertSame('Taylor', $response->array('1.name'));
+ $this->assertSame('missing', $response->array(2, 'missing'));
+ }
+
+ public function testBuiltInExceptionTypesAndTruncationArePreserved(): void
+ {
+ $clientResponse = $this->response(422)->truncateExceptionsAt(37);
+ $serverResponse = $this->response(500)->truncateExceptionsAt(38);
+ $request = new ResponseRequestStub;
+ $request->throwVerdict = true;
+ $generalResponse = $this->response(200, request: $request)->truncateExceptionsAt(39);
+
+ $clientException = $clientResponse->toException();
+ $serverException = $serverResponse->toException();
+ $generalException = $generalResponse->toException();
+
+ $this->assertInstanceOf(ClientException::class, $clientException);
+ $this->assertSame(37, $clientException->truncateExceptionsAt);
+ $this->assertInstanceOf(ServerException::class, $serverException);
+ $this->assertSame(38, $serverException->truncateExceptionsAt);
+ $this->assertInstanceOf(RequestException::class, $generalException);
+ $this->assertSame(39, $generalException->truncateExceptionsAt);
+ }
+
+ public function testRequestFailureAndExceptionVerdictsTakePriorityOverConnectorVerdicts(): void
+ {
+ $connector = new ResponseConnectorStub;
+ $connector->failureVerdict = true;
+ $connector->customException = true;
+
+ $request = new ResponseRequestStub;
+ $request->failureVerdict = false;
+ $request->throwVerdict = true;
+ $request->customException = true;
+
+ $response = $this->response(500, connector: $connector, request: $request);
+
+ $this->assertFalse($response->failed());
+ $this->assertInstanceOf(ResponseRequestExceptionStub::class, $response->toException());
+ }
+
+ public function testFailurePoliciesDriveOnErrorAndFailureNamedThrowingHelpers(): void
+ {
+ $connector = new ResponseConnectorStub;
+ $connector->failureVerdict = true;
+ $response = $this->response(200, connector: $connector);
+ $called = false;
+
+ $response->onError(function () use (&$called): void {
+ $called = true;
+ });
+
+ $this->assertTrue($response->successful());
+ $this->assertTrue($response->failed());
+ $this->assertTrue($called);
+
+ $request = new ResponseRequestStub;
+ $request->failureVerdict = false;
+ $suppressed = $this->response(404, connector: $connector, request: $request);
+
+ $this->assertSame($suppressed, $suppressed->throwIfClientError());
+
+ $this->expectException(ClientException::class);
+ $suppressed->throwIfStatus(404);
+ }
+
+ public function testThrowPolicyCanSuppressAResponseThatStillReportsFailed(): void
+ {
+ $connector = new ResponseConnectorStub;
+ $connector->throwVerdict = false;
+ $request = new ResponseRequestStub;
+ $request->throwVerdict = false;
+ $response = $this->response(500, connector: $connector, request: $request);
+
+ $this->assertTrue($response->failed());
+ $this->assertNull($response->toException());
+ $this->assertSame($response, $response->throw());
+ }
+
+ public function testNonSeekableBodyIsBufferedOnce(): void
+ {
+ $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub);
+ $psrRequest = new PsrRequest('GET', 'https://api.example.com/users');
+ $httpResponse = new HttpResponse(new PsrResponse(
+ body: new NoSeekStream(Utils::streamFor('response body')),
+ ));
+ $response = Response::fromResponse($httpResponse, $pendingRequest, $psrRequest);
+
+ $this->assertSame('response body', $response->body());
+ $this->assertSame('response body', $response->body());
+ $this->assertTrue($response->stream()->isSeekable());
+ $this->assertSame($psrRequest, $response->toPsrRequest());
+ }
+
+ public function testNonSeekableBodyConsumptionLeavesLinesAtTheEnd(): void
+ {
+ $response = $this->response(200, body: new NoSeekStream(Utils::streamFor("one\ntwo\n")));
+
+ $this->assertSame("one\ntwo\n", $response->body());
+ $this->assertSame(8, $response->stream()->tell());
+ $this->assertSame([], iterator_to_array($response->lines()));
+
+ $response->stream()->rewind();
+
+ $this->assertSame(['one', 'two'], iterator_to_array($response->lines()));
+ }
+
+ public function testJsonLinesReadANonSeekableResponseFromItsCurrentPosition(): void
+ {
+ $stream = new NoSeekStream(Utils::streamFor("skip\n{\"id\":1}\n{\"id\":2}\n"));
+ $stream->read(5);
+ $response = $this->response(200, body: $stream);
+
+ $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($response->jsonLines()));
+ $this->assertSame($stream, $response->stream());
+ }
+
+ public function testBodyExportsPreservePositionsAndCallerOwnedResources(): void
+ {
+ $response = $this->response(200, body: 'response body');
+ $response->stream()->seek(4);
+ $resource = fopen('php://temp', 'wb+');
+ $this->assertIsResource($resource);
+ fwrite($resource, 'stale contents');
+
+ $response->saveBodyToFile($resource, false);
+
+ $this->assertIsResource($resource);
+ $this->assertSame(4, $response->stream()->tell());
+ $this->assertSame('response body', stream_get_contents($resource));
+ fclose($resource);
+
+ $rawStream = $response->getRawStream();
+ $this->assertIsResource($rawStream);
+ $this->assertSame('response body', stream_get_contents($rawStream));
+ fclose($rawStream);
+ }
+
+ public function testFailedBodyExportDoesNotCloseACallerOwnedResource(): void
+ {
+ $source = m::mock(StreamInterface::class);
+ $source->shouldReceive('isSeekable')->once()->andReturn(false);
+ $source->shouldReceive('eof')->once()->andReturn(false);
+ $source->shouldReceive('read')->once()->andThrow(new RuntimeException('read failed'));
+ $response = $this->response(200, body: $source);
+ $resource = fopen('php://temp', 'wb+');
+ $this->assertIsResource($resource);
+
+ try {
+ $response->saveBodyToFile($resource, false);
+ $this->fail('The response source should fail while being copied.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('read failed', $exception->getMessage());
+ $this->assertIsResource($resource);
+ } finally {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ }
+ }
+
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->tempDir = ParallelTesting::tempDir('SaloonResponseTest');
+ (new Filesystem)->deleteDirectory($this->tempDir);
+ mkdir($this->tempDir, 0777, true);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->tempDir);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Create a Saloon response for the given operation.
+ */
+ protected function response(
+ int $status,
+ StreamInterface|string $body = 'response body',
+ ?Connector $connector = null,
+ ?Request $request = null,
+ ?PendingRequest $pendingRequest = null,
+ ): Response {
+ $connector ??= new ResponseConnectorStub;
+ $request ??= new ResponseRequestStub;
+ $pendingRequest ??= $this->pendingRequest($connector, $request);
+ $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users');
+
+ return Response::fromResponse(
+ new HttpResponse(new PsrResponse($status, body: $body)),
+ $pendingRequest,
+ $psrRequest,
+ );
+ }
+
+ /**
+ * Create a pending request with isolated framework dependencies.
+ */
+ protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ $connector,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
+
+class ResponseConnectorStub extends Connector
+{
+ public ?bool $failureVerdict = null;
+
+ public ?bool $throwVerdict = null;
+
+ public bool $customException = false;
+
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+
+ /**
+ * Determine if the request has failed.
+ */
+ public function hasRequestFailed(Response $response): ?bool
+ {
+ return $this->failureVerdict;
+ }
+
+ /**
+ * Determine if the response should throw a request exception.
+ */
+ public function shouldThrowRequestException(Response $response): bool
+ {
+ return $this->throwVerdict ?? $response->failed();
+ }
+
+ /**
+ * Get the custom request exception.
+ */
+ public function getRequestException(Response $response): ?RequestException
+ {
+ return $this->customException ? new ResponseConnectorExceptionStub($response) : null;
+ }
+}
+
+class ResponseRequestStub extends Request
+{
+ public ?bool $failureVerdict = null;
+
+ public ?bool $throwVerdict = null;
+
+ public bool $customException = false;
+
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Determine if the request has failed.
+ */
+ public function hasRequestFailed(Response $response): ?bool
+ {
+ return $this->failureVerdict;
+ }
+
+ /**
+ * Determine if the response should throw a request exception.
+ */
+ public function shouldThrowRequestException(Response $response): bool
+ {
+ return $this->throwVerdict ?? $response->failed();
+ }
+
+ /**
+ * Get the custom request exception.
+ */
+ public function getRequestException(Response $response): ?RequestException
+ {
+ return $this->customException ? new ResponseRequestExceptionStub($response) : null;
+ }
+}
+
+class ResponseConnectorExceptionStub extends RequestException
+{
+}
+
+class ResponseRequestExceptionStub extends RequestException
+{
+}
diff --git a/tests/Saloon/Http/SenderTest.php b/tests/Saloon/Unit/SenderTest.php
similarity index 67%
rename from tests/Saloon/Http/SenderTest.php
rename to tests/Saloon/Unit/SenderTest.php
index fe5527b04a..4b5eb7f525 100644
--- a/tests/Saloon/Http/SenderTest.php
+++ b/tests/Saloon/Unit/SenderTest.php
@@ -2,29 +2,72 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use GuzzleHttp\Cookie\CookieJarInterface;
+use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Contracts\Telescope\TelescopeTag;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Client\Request as HttpRequest;
use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
+use Hypervel\Saloon\Facades\Saloon;
use Hypervel\Saloon\Http\Auth\BasicAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Http\Response;
use Hypervel\Saloon\Http\Sender;
+use Hypervel\Saloon\SaloonServiceProvider;
use Hypervel\Saloon\Traits\Body\HasJsonBody;
-use Hypervel\Tests\TestCase;
+use Hypervel\Support\Facades\Http;
+use Hypervel\Testbench\TestCase;
+use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
+use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use Mockery as m;
use Psr\Http\Message\RequestInterface;
+// Hypervel HTTP is Saloon's only transport. Connectors choose a named HTTP connection instead of a sender.
class SenderTest extends TestCase
{
+ public function testTheDefaultConnectionOnAllConnectorsIsTheSaloonConnection(): void
+ {
+ $connector = new TestConnector;
+ $sender = Saloon::sender();
+
+ $pendingRequest = $connector->createPendingRequest(new UserRequest);
+
+ $this->assertSame('saloon', $sender->resolveTransport($pendingRequest)['connection']);
+
+ // Test the same instance is re-used
+
+ $this->assertSame($sender, Saloon::sender());
+ }
+
+ // Upstream also overrides the sender through a connector property; connectors select their connection only
+ // through resolveHttpConnection().
+ public function testYouCanOverwriteTheConnectionOnAConnectorUsingTheResolveHttpConnectionMethod(): void
+ {
+ Http::registerConnection('secondary', ['timeout' => 12]);
+ $timeout = null;
+ Http::fake(function (HttpRequest $request, array $options) use (&$timeout): PromiseInterface {
+ $timeout = $options['timeout'];
+
+ return Http::response('Default', 200, ['X-Fake' => 'true']);
+ });
+
+ $response = (new SecondaryConnectionConnectorStub)->send(new UserRequest);
+
+ $this->assertSame(12, $timeout);
+ $this->assertSame('true', $response->header('X-Fake'));
+ $this->assertSame('Default', $response->body());
+ }
+
+ // REMOVED: the case that sets a class which does not implement the sender contract. There is no sender contract.
+
public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection(): void
{
$http = new Factory;
@@ -34,7 +77,7 @@ public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection():
]);
$capturedRequest = null;
$capturedOptions = null;
- $http->fake(function (HttpRequest $request, array $options) use (&$capturedRequest, &$capturedOptions) {
+ $http->fake(function (HttpRequest $request, array $options) use (&$capturedRequest, &$capturedOptions): PromiseInterface {
$capturedRequest = $request;
$capturedOptions = $options;
@@ -80,7 +123,7 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag():
$http = new Factory;
$http->registerConnection('saloon');
$capturedOptions = null;
- $http->fake(function (HttpRequest $request, array $options) use (&$capturedOptions) {
+ $http->fake(function (HttpRequest $request, array $options) use (&$capturedOptions): PromiseInterface {
$capturedOptions = $options;
return Factory::response();
@@ -97,6 +140,17 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag():
$this->assertSame([TelescopeTag::Saloon], $capturedOptions['telescope_tags']);
}
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
@@ -131,13 +185,30 @@ protected function config(): ConfigRepository
}
}
+class SecondaryConnectionConnectorStub extends TestConnector
+{
+ /**
+ * Resolve the HTTP connection used by this connector.
+ */
+ public function resolveHttpConnection(): ?string
+ {
+ return 'secondary';
+ }
+}
+
class SenderConnectorStub extends Connector
{
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Resolve the default query parameters.
+ */
protected function defaultQuery(): array
{
return ['version' => 1];
@@ -154,11 +225,17 @@ class SenderRequestStub extends Request
protected ?string $response = SenderResponseStub::class;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
}
+ /**
+ * Modify the final PSR request.
+ */
public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface
{
++static::$psrHookCalls;
From 16a4bff8377daac15334f413270bd10df21ed588 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 19:34:28 +0000
Subject: [PATCH 05/26] Reconcile Saloon middleware, retry and delay tests with
upstream
Ports Saloon's middleware pipeline, pipeline, retry connector, retry
request and delay request tests (saloonphp/saloon v4 at 2f45e526f9d1),
merging the existing Hypervel pipeline and delay tests into the
upstream-named files. Live retry cases run against a new /header-error
route on the engine test server. This completes saloonphp/saloon#557
and the retry test changes of #561.
New APIs:
- defaultRetryPolicy() on connectors and requests, like defaultDelay().
An explicit request policy wins, then the request default, then the
connector default. A request's policy replaces the connector's
entirely, so retry(1) turns off connector retries.
Defects fixed:
- Middleware pipelines formed a reference cycle through their wrapper
closures, so they stayed alive until garbage collection. The
wrappers are now static.
- A mock response that threw FatalRequestException skipped the fatal
middleware and retries. It now runs the fatal pipeline once and
follows the retry policy, keeping the thrown exception.
- The request's middleware ran first and global middleware last. Each
send now builds its own pipeline in upstream's order: global,
plugins, connector boot(), request middleware, request boot(). The
global and request pipelines are never changed, so repeated sends
and retries do not accumulate middleware.
- Connectors could only set retries from boot(), which overrode a
request's explicit retry(). Connector defaults now come from
defaultRetryPolicy().
- Mocked responses skipped the request delay. The delay now runs before
every attempt, real or fake, but not for cache hits.
Adaptations, recorded in the README:
- Middleware contracts are typed, a returned pending request is
ignored, and the pipeline getters drop the get prefix.
- Upstream's retry properties and handleRetry() map to retry() and
defaultRetryPolicy(). Request changes move to the when callback
through $pendingRequest->request(), and exponential backoff to a
delay closure.
- Deprecated sendAndRetry() is not ported; its exponential backoff case
moves to RetryRequestTest.
- Fewer than one attempt or a negative interval throws.
Also adds the missing method titles and closure return types in
SaloonManagerTest.
Validation: Saloon suite (728 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the
engine test servers, tests/Data/Saloon; composer analyse;
php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 40 +-
src/saloon/README.md | 4 +
src/saloon/src/Http/Connector.php | 17 +
src/saloon/src/Http/MiddlewarePipeline.php | 10 +-
src/saloon/src/Http/PendingRequest.php | 5 +-
src/saloon/src/SaloonManager.php | 30 +-
.../RequestProperties/HasRetryPolicy.php | 12 +-
.../Engine/Fixtures/http_server_v2.php | 12 +
.../Saloon/Feature/RetryConnectorTest.php | 77 +++
.../Saloon/Feature/RetryRequestTest.php | 76 +++
tests/Saloon/Feature/DebugTest.php | 26 +-
tests/Saloon/Feature/DelayRequestTest.php | 272 +++++++++
tests/Saloon/Feature/RetryConnectorTest.php | 297 ++++++++++
tests/Saloon/Feature/RetryRequestTest.php | 365 ++++++++++++
.../Fixtures/Connectors/RetryConnector.php | 34 ++
.../Fixtures/Requests/HeaderErrorRequest.php | 24 +
.../Requests/HeaderErrorRetryRequest.php | 16 +
.../Fixtures/Requests/RetryUserRequest.php | 47 ++
tests/Saloon/Http/RequestDelayTest.php | 129 -----
tests/Saloon/SaloonManagerTest.php | 127 ++++-
tests/Saloon/Unit/MiddlewarePipelineTest.php | 531 ++++++++++++++++++
tests/Saloon/Unit/PendingRequestTest.php | 6 +-
tests/Saloon/{Http => Unit}/PipelineTest.php | 7 +-
24 files changed, 1973 insertions(+), 193 deletions(-)
create mode 100644 tests/Integration/Saloon/Feature/RetryConnectorTest.php
create mode 100644 tests/Integration/Saloon/Feature/RetryRequestTest.php
create mode 100644 tests/Saloon/Feature/DelayRequestTest.php
create mode 100644 tests/Saloon/Feature/RetryConnectorTest.php
create mode 100644 tests/Saloon/Feature/RetryRequestTest.php
create mode 100644 tests/Saloon/Fixtures/Connectors/RetryConnector.php
create mode 100644 tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/HeaderErrorRetryRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/RetryUserRequest.php
delete mode 100644 tests/Saloon/Http/RequestDelayTest.php
create mode 100644 tests/Saloon/Unit/MiddlewarePipelineTest.php
rename tests/Saloon/{Http => Unit}/PipelineTest.php (90%)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 967914cc3b..6af4724e0a 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,7 +182,7 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 6338d34467..2f34a1812d 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -205,7 +205,7 @@ Do not change a reused connector while handling a request. If a credential or en
### Connector Defaults
-You may define default headers, query parameters, Guzzle options, authentication, delays, and request bodies using protected connector methods:
+You may define default headers, query parameters, Guzzle options, authentication, delays, [retry policies](#retries), and request bodies using protected connector methods:
```php
use Hypervel\Saloon\Contracts\Authenticator;
@@ -864,7 +864,7 @@ $request
->withOptions(['proxy' => $proxy]);
```
-The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds.
+The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds. Saloon waits for the delay before every attempt, including attempts answered by a mock or fake response, so `Sleep::fake()` can assert it in your tests. Responses served from the cache are not delayed.
The `withoutOptions` method removes options from the request, so the connector's value or the HTTP connection's configured value applies again. To remove a connector default, call `withoutOptions` on the pending request:
@@ -933,7 +933,7 @@ public function boot(PendingRequest $pendingRequest): void
}
```
-Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request.
+Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request. Other return values are ignored, so change the pending request it receives rather than returning a new one.
For reusable middleware, create an invokable class that implements `RequestMiddleware`:
@@ -956,7 +956,7 @@ class AddRequestId implements RequestMiddleware
Register the class in the same way as a closure: `$pendingRequest->middleware()->onRequest(new AddRequestId)`.
-Middleware runs in registration order. You may pass `PipeOrder::First` or `PipeOrder::Last` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline.
+Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::First` or `PipeOrder::Last` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline.
### Response Middleware
@@ -997,7 +997,7 @@ class RecordResponse implements ResponseMiddleware
### Fatal Exception Middleware
-Fatal exception middleware receives connection and transport failures after they have been wrapped in a `FatalRequestException`:
+Fatal exception middleware receives connection and transport failures after they have been wrapped in a `FatalRequestException`, as well as a `FatalRequestException` thrown by a fake response to simulate one:
```php
use Hypervel\Saloon\Exceptions\Request\FatalRequestException;
@@ -1297,6 +1297,36 @@ $request->retry(
Each attempt receives a fresh pending request, so middleware and authentication run again. Seekable request bodies are restored before another attempt. Saloon throws a `BodyException` rather than retrying a consumed non-seekable body.
+The `when` callback receives the exception and the failed attempt's pending request. Changes made to the original request through `$pendingRequest->request()` apply to the next attempt, such as replacing an expired token:
+
+```php
+use Hypervel\Saloon\Exceptions\Request\RequestException;
+use Hypervel\Saloon\Http\PendingRequest;
+
+$request->retry(2, when: function ($exception, PendingRequest $pendingRequest) {
+ if (! $exception instanceof RequestException || $exception->status() !== 401) {
+ return false;
+ }
+
+ $pendingRequest->request()->withToken($this->refreshToken());
+
+ return true;
+});
+```
+
+To retry every request sent through a connector, return a policy from the connector's `defaultRetryPolicy` method. A request may define the same method to set its own default:
+
+```php
+use Hypervel\Saloon\Data\RetryPolicy;
+
+protected function defaultRetryPolicy(): ?RetryPolicy
+{
+ return new RetryPolicy(3, 100);
+}
+```
+
+Saloon uses the policy passed to the request's `retry` method, then the request's default, then the connector's default. A request's policy replaces the connector's entirely, including its `when` condition, so `$request->retry(1)` turns off the connector's retries. Middleware and `boot` methods may also call `retry` on the pending request to change the policy for a single operation.
+
### Debugging
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 14ab6c8d04..77652671ae 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -12,6 +12,10 @@ There is no process-global `Config` class and no pluggable sender. Global middle
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
+Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeResponse` and `ResponseMiddleware::__invoke()` returns `?Response`, so a ported middleware class declared `: void` returns `null` instead. A pending request returned from request middleware is ignored; change the one it receives. Pipelines expose `pipes()`, `requestPipeline()`, `responsePipeline()` and `fatalPipeline()` instead of upstream's `get` methods. Connector middleware is registered from `boot()` and runs after global and plugin middleware but before the request's own middleware.
+
+Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties and `handleRetry()` methods; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries).
+
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index 5f6c165fa4..a5ea19f827 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -8,6 +8,7 @@
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\Saloon\Contracts\Authenticator;
use Hypervel\Saloon\Contracts\Body\BodyRepository;
+use Hypervel\Saloon\Data\RetryPolicy;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Faking\MockClient;
use Hypervel\Saloon\SaloonManager;
@@ -151,6 +152,14 @@ final public function delayMilliseconds(): ?int
return $this->defaultDelay();
}
+ /**
+ * Get the default retry policy for requests without their own.
+ */
+ final public function retryPolicy(): ?RetryPolicy
+ {
+ return $this->defaultRetryPolicy();
+ }
+
/**
* Copy the default body repository for a pending request.
*
@@ -209,6 +218,14 @@ protected function defaultDelay(): ?int
return null;
}
+ /**
+ * Resolve the default retry policy.
+ */
+ protected function defaultRetryPolicy(): ?RetryPolicy
+ {
+ return null;
+ }
+
/**
* Resolve the default body repository.
*/
diff --git a/src/saloon/src/Http/MiddlewarePipeline.php b/src/saloon/src/Http/MiddlewarePipeline.php
index ba28fb6999..f5985898bb 100644
--- a/src/saloon/src/Http/MiddlewarePipeline.php
+++ b/src/saloon/src/Http/MiddlewarePipeline.php
@@ -49,7 +49,9 @@ public function __construct()
*/
public function onRequest(callable $callable, ?string $name = null, ?PipeOrder $order = null): static
{
- $this->requestPipeline->pipe(function (PendingRequest $pendingRequest) use ($callable): PendingRequest {
+ // The wrapper is static so it does not capture the pipeline, which would form a reference cycle that keeps
+ // the pipeline alive until garbage collection.
+ $this->requestPipeline->pipe(static function (PendingRequest $pendingRequest) use ($callable): PendingRequest {
$result = $callable($pendingRequest);
if ($result instanceof FakeResponse) {
@@ -70,7 +72,8 @@ public function onRequest(callable $callable, ?string $name = null, ?PipeOrder $
*/
public function onResponse(callable $callable, ?string $name = null, ?PipeOrder $order = null): static
{
- $this->responsePipeline->pipe(function (Response $response) use ($callable): Response {
+ // Static for the same reason as the request wrapper.
+ $this->responsePipeline->pipe(static function (Response $response) use ($callable): Response {
$result = $callable($response);
return $result instanceof Response ? $result : $response;
@@ -87,7 +90,8 @@ public function onResponse(callable $callable, ?string $name = null, ?PipeOrder
*/
public function onFatalException(callable $callable, ?string $name = null, ?PipeOrder $order = null): static
{
- $this->fatalPipeline->pipe(function (FatalRequestException $throwable) use ($callable): FatalRequestException {
+ // Static for the same reason as the request wrapper.
+ $this->fatalPipeline->pipe(static function (FatalRequestException $throwable) use ($callable): FatalRequestException {
$callable($throwable);
return $throwable;
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index e681d136e2..7f390bd37b 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -149,7 +149,6 @@ public function __construct(
$request->options(),
));
$this->delay = $request->delayMilliseconds() ?? $connector->delayMilliseconds();
- $this->middlewarePipeline = clone $request->middleware();
$connectorBody = $connector->copyDefaultBodyRepository();
$requestBody = $request->copyBodyRepository();
@@ -165,7 +164,7 @@ public function __construct(
}
$this->cookies = $request->cookies();
- $this->retryPolicy = $request->retryPolicy();
+ $this->retryPolicy = $request->retryPolicy() ?? $connector->retryPolicy();
$this->authenticator = $request->authenticator() ?? $connector->authenticator();
}
@@ -365,7 +364,7 @@ public function bootPlugins(): static
}
/**
- * Merge worker-global middleware into this operation.
+ * Merge global or request middleware into this operation's own pipeline.
*/
public function mergeMiddleware(MiddlewarePipeline $middleware): static
{
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index ae5898fd96..27d7244e14 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -19,6 +19,7 @@
use Hypervel\Saloon\Cache\CacheKey;
use Hypervel\Saloon\Cache\Data\CachedResponse;
use Hypervel\Saloon\Data\RecordedResponse;
+use Hypervel\Saloon\Data\RetryPolicy;
use Hypervel\Saloon\Events\SendingSaloonRequest;
use Hypervel\Saloon\Events\SentSaloonRequest;
use Hypervel\Saloon\Exceptions\BodyException;
@@ -154,6 +155,9 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
try {
if ($response === null) {
+ // Fake responses stand in for a send, so they wait for the delay too. Cache hits send nothing.
+ $this->sleepMilliseconds($pendingRequest->delayMilliseconds() ?? 0);
+
if (($fakeResponse = $pendingRequest->fakeResponse()) !== null) {
if (($exception = $fakeResponse->getException($pendingRequest)) !== null) {
throw $exception;
@@ -165,7 +169,6 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
->setMocked($fakeResponse instanceof MockResponse)
->setFakeResponse($fakeResponse);
} else {
- $this->sleepMilliseconds($pendingRequest->delayMilliseconds() ?? 0);
$response = $this->sender->send($pendingRequest, $transport);
$responseFromSender = true;
}
@@ -202,15 +205,19 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
if ($exception === null) {
return $response;
}
- } catch (ConnectionException $exception) {
- $exception = new FatalRequestException($exception, $pendingRequest);
+ } catch (ConnectionException|FatalRequestException $exception) {
+ // A fake response may throw the fatal exception itself to simulate a failed connection.
+ if ($exception instanceof ConnectionException) {
+ $exception = new FatalRequestException($exception, $pendingRequest);
+ }
+
$pendingRequest->executeFatalPipeline($exception);
} catch (RequestException $exception) {
$response = $exception->response();
$exceptionWasThrown = true;
}
- $retryPolicy = $pendingRequest->retryPolicy();
+ $retryPolicy = $pendingRequest->retryPolicy() ?? new RetryPolicy;
$maximumAttempts = $retryPolicy->maximumAttempts();
$shouldRetry = $attempt < $maximumAttempts
&& ($retryPolicy->when === null || ($retryPolicy->when)($exception, $pendingRequest));
@@ -239,8 +246,13 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
/**
* Prepare a request for sending through a connector.
*
- * The pending request has run its plugins, authenticator, boot hooks, and global and request middleware, in that
- * order. It has not been finalized, matched against mock responses or sent.
+ * The pending request has run its plugins, authenticator, boot hooks and request middleware, in that order. It
+ * has not been finalized, matched against mock responses or sent.
+ *
+ * Middleware is registered on the operation's own pipeline in this order: global middleware, plugin middleware,
+ * middleware added by the connector's boot method, the request's middleware, then middleware added by the
+ * request's boot method. Connectors are read-only, so connector middleware is registered from the boot method and
+ * still runs before the request's.
*
* @template TDto
* @param Request $request
@@ -250,14 +262,14 @@ public function createPendingRequest(Connector $connector, Request $request): Pe
{
$pendingRequest = new PendingRequest($connector, $request, $this->cache, $this->rateLimiter);
$pendingRequest
+ ->mergeMiddleware($this->middleware)
->bootPlugins()
->applyAuthentication();
$connector->boot($pendingRequest);
+ $pendingRequest->mergeMiddleware($request->middleware());
$request->boot($pendingRequest);
- return $pendingRequest
- ->mergeMiddleware($this->middleware)
- ->executeRequestPipeline();
+ return $pendingRequest->executeRequestPipeline();
}
/**
diff --git a/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php b/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php
index d1d454526a..7a1d185b73 100644
--- a/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php
+++ b/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php
@@ -34,8 +34,16 @@ public function retry(
/**
* Get the request retry policy.
*/
- public function retryPolicy(): RetryPolicy
+ public function retryPolicy(): ?RetryPolicy
{
- return $this->retryPolicy ??= new RetryPolicy;
+ return $this->retryPolicy ?? $this->defaultRetryPolicy();
+ }
+
+ /**
+ * Resolve the default retry policy.
+ */
+ protected function defaultRetryPolicy(): ?RetryPolicy
+ {
+ return null;
}
}
diff --git a/tests/Integration/Engine/Fixtures/http_server_v2.php b/tests/Integration/Engine/Fixtures/http_server_v2.php
index 90d7b78a9f..d1be3760b5 100644
--- a/tests/Integration/Engine/Fixtures/http_server_v2.php
+++ b/tests/Integration/Engine/Fixtures/http_server_v2.php
@@ -70,6 +70,18 @@
$response->setHeader('Content-Type', 'application/json');
$response->end(json_encode(['message' => 'Server Error']));
})(),
+ '/header-error' => (function () use ($request, $response): void {
+ // Fail until Saloon's retry tests send the X-Yee-Haw header with the value 3
+ if (($request->header['x-yee-haw'] ?? null) === '3') {
+ $response->end('Success!');
+
+ return;
+ }
+
+ $response->status(500);
+ $response->setHeader('Content-Type', 'application/json');
+ $response->end(json_encode(['message' => 'Server Error']));
+ })(),
default => (function () use ($request, $response) {
$body = $request->rawContent();
$ret = 'Hello World.';
diff --git a/tests/Integration/Saloon/Feature/RetryConnectorTest.php b/tests/Integration/Saloon/Feature/RetryConnectorTest.php
new file mode 100644
index 0000000000..caa7b2b4de
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/RetryConnectorTest.php
@@ -0,0 +1,77 @@
+setUpInteractsWithServer();
+ }
+
+ public function testRetryAgainstALiveEndpointToTestTheTransport(): void
+ {
+ $requestCount = 0;
+ $index = 0;
+ $connector = new RetryConnector(6, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ) use (&$index): bool {
+ $pendingRequest->request()->replaceHeaders(['X-Yee-Haw' => (string) $index++]);
+
+ return true;
+ }, url: $this->serverUrl());
+ $request = new HeaderErrorRequest;
+ $request->middleware()->onRequest(function () use (&$requestCount): void {
+ ++$requestCount;
+ });
+
+ $response = $connector->send($request);
+
+ // Request count is five because:
+ // Request 1 - no header
+ // Request 2 - header but 0
+ // Request 3 - header but 1
+ // Request 4 - header but 2
+ // Request 5 - header but 3
+ $this->assertSame(5, $requestCount);
+ $this->assertSame('Success!', $response->body());
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/Feature/RetryRequestTest.php b/tests/Integration/Saloon/Feature/RetryRequestTest.php
new file mode 100644
index 0000000000..0178e5fcb7
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/RetryRequestTest.php
@@ -0,0 +1,76 @@
+setUpInteractsWithServer();
+ }
+
+ public function testRetryAgainstALiveEndpointToTestTheTransport(): void
+ {
+ $requestCount = 0;
+ $index = 0;
+ $request = new HeaderErrorRetryRequest(6, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ) use (&$index): bool {
+ $pendingRequest->request()->replaceHeaders(['X-Yee-Haw' => (string) $index++]);
+
+ return true;
+ });
+ $request->middleware()->onRequest(function () use (&$requestCount): void {
+ ++$requestCount;
+ });
+
+ $response = (new TestConnector($this->serverUrl()))->send($request);
+
+ // Request count is five because:
+ // Request 1 - no header
+ // Request 2 - header but 0
+ // Request 3 - header but 1
+ // Request 4 - header but 2
+ // Request 5 - header but 3
+ $this->assertSame(5, $requestCount);
+ $this->assertSame('Success!', $response->body());
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Saloon/Feature/DebugTest.php b/tests/Saloon/Feature/DebugTest.php
index f4d97dd588..5e0c164d1a 100644
--- a/tests/Saloon/Feature/DebugTest.php
+++ b/tests/Saloon/Feature/DebugTest.php
@@ -6,9 +6,7 @@
use Closure;
use GuzzleHttp\Psr7\Utils;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Exceptions\Request\RequestException;
use Hypervel\Saloon\Http\Connector;
@@ -25,7 +23,6 @@
use Hypervel\Tests\Saloon\Fixtures\Mocking\UnseekableBodyMockResponse;
use Hypervel\Tests\Saloon\Fixtures\Requests\AlwaysThrowRequest;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
-use Mockery as m;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
@@ -113,11 +110,8 @@ public function testTheResponseDebuggerIsAlwaysExecutedBeforeUserMiddleware(): v
$connector->send($request, $mockClient);
- // Even though the user has registered response middleware, the debugger should always come first. Upstream
- // also expects connector entries before request entries ([C, D, A, B]); only the debugger priority is
- // asserted here.
- $this->assertEqualsCanonicalizing(['C', 'D'], array_slice($middlewareOrder, 0, 2));
- $this->assertEqualsCanonicalizing(['A', 'B'], array_slice($middlewareOrder, 2));
+ // Even though the user has registered response middleware, the debugger should always come first.
+ $this->assertSame(['C', 'D', 'A', 'B'], $middlewareOrder);
}
public function testTheResponseDebuggerIsAlwaysExecutedBeforeTheAlwaysThrowOnErrorsTrait(): void
@@ -264,8 +258,7 @@ public function testRequestDebuggerObservesTheFinalPsrRequestExactlyOnce(): void
->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$capturedRequest): void {
$capturedRequest = $psrRequest;
});
- $pendingRequest = $this->pendingRequest($request)
- ->executeRequestPipeline()
+ $pendingRequest = (new DebugConnectorStub)->createPendingRequest($request)
->finalizeUri()
->prepareBody();
@@ -298,19 +291,6 @@ protected function setUp(): void
DebugRequestStub::$psrHookCalls = 0;
}
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Request $request): PendingRequest
- {
- return new PendingRequest(
- new DebugConnectorStub,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
-
/**
* Run the callback and return what it dumps.
*/
diff --git a/tests/Saloon/Feature/DelayRequestTest.php b/tests/Saloon/Feature/DelayRequestTest.php
new file mode 100644
index 0000000000..d08909bdb7
--- /dev/null
+++ b/tests/Saloon/Feature/DelayRequestTest.php
@@ -0,0 +1,272 @@
+delay(100);
+
+ $this->assertSame(100, $request->delayMilliseconds());
+
+ (new TestConnector)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+ (new DelayConnectorStub)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+
+ Sleep::assertSequence([
+ Sleep::for(100)->milliseconds(),
+ Sleep::for(100)->milliseconds(),
+ ]);
+ }
+
+ // Upstream's custom sleep handler case is the same assertion: Sleep::fake() records the delay instead of waiting.
+ public function testRequestDelayWorks(): void
+ {
+ Sleep::fake();
+ Http::fake(['*' => Factory::response(['name' => 'Sam'])]);
+
+ $request = (new UserRequest)->delay(1000);
+
+ $this->assertSame(1000, $request->delayMilliseconds());
+
+ (new TestConnector)->send($request);
+ (new TestConnector)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+
+ Sleep::assertSequence([
+ Sleep::for(1000)->milliseconds(),
+ Sleep::for(1000)->milliseconds(),
+ ]);
+ }
+
+ public function testConnectorDelayWorks(): void
+ {
+ Sleep::fake();
+ Http::fake(['*' => Factory::response(['name' => 'Sam'])]);
+
+ $request = new UserRequest;
+ $connector = new DelayConnectorStub;
+
+ $this->assertNull($request->delayMilliseconds());
+ $this->assertSame(500, $connector->delayMilliseconds());
+
+ $connector->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+ $connector->send($request);
+
+ Sleep::assertSequence([
+ Sleep::for(500)->milliseconds(),
+ Sleep::for(500)->milliseconds(),
+ ]);
+ }
+
+ public function testTheDelayRunsBeforeAFakeResponseThatThrows(): void
+ {
+ Sleep::fake();
+ $exception = new Exception('The fake failed.');
+
+ try {
+ (new TestConnector)->send(
+ (new UserRequest)->delay(100),
+ new MockClient([MockResponse::make()->throw($exception)]),
+ );
+
+ $this->fail('The request did not throw.');
+ } catch (Exception $caught) {
+ $this->assertSame($exception, $caught);
+ }
+
+ Sleep::assertSequence([Sleep::for(100)->milliseconds()]);
+ }
+
+ public function testTheDelayRunsBeforeEveryAttemptSeparatelyFromTheRetryInterval(): void
+ {
+ Sleep::fake();
+
+ (new TestConnector)->send((new UserRequest)->delay(100)->retry(2, 300), new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth']),
+ ]));
+
+ Sleep::assertSequence([
+ Sleep::for(100)->milliseconds(),
+ Sleep::for(300)->milliseconds(),
+ Sleep::for(100)->milliseconds(),
+ ]);
+ }
+
+ public function testCacheHitsAreNotDelayed(): void
+ {
+ Sleep::fake();
+ Http::fake(['*' => Factory::response(['name' => 'Sam'])]);
+
+ $first = (new TestConnector)->send((new CachedDelayRequestStub)->delay(100));
+ $second = (new TestConnector)->send((new CachedDelayRequestStub)->delay(100));
+
+ $this->assertFalse($first->isCached());
+ $this->assertTrue($second->isCached());
+ Sleep::assertSequence([Sleep::for(100)->milliseconds()]);
+ }
+
+ public function testTheDelayIsEmptyByDefault(): void
+ {
+ $this->assertNull($this->request()->delayMilliseconds());
+ }
+
+ public function testItStoresARepresentableDelay(): void
+ {
+ $request = $this->request()->delay(250);
+
+ $this->assertSame(250, $request->delayMilliseconds());
+ }
+
+ public function testTheDefaultDelayIsUsedUntilADelayIsSet(): void
+ {
+ $request = new DefaultDelayRequestStub;
+
+ $this->assertSame(100, $request->delayMilliseconds());
+ $this->assertSame(0, $request->delay(0)->delayMilliseconds());
+ }
+
+ public function testAnExplicitZeroDelayOverridesTheConnectorDelay(): void
+ {
+ $this->assertSame(500, $this->pendingRequest(new DelayRequestStub)->delayMilliseconds());
+ $this->assertSame(0, $this->pendingRequest((new DelayRequestStub)->delay(0))->delayMilliseconds());
+ }
+
+ public function testItRejectsNegativeDelays(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('representable non-negative');
+
+ $this->request()->delay(-1);
+ }
+
+ public function testItRejectsDelaysThatOverflowMicroseconds(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('representable non-negative');
+
+ $this->request()->delay(intdiv(PHP_INT_MAX, 1000) + 1);
+ }
+
+ /**
+ * Create an object with an operation-owned request delay.
+ */
+ protected function request(): object
+ {
+ return new class {
+ use HasDelay;
+ };
+ }
+
+ /**
+ * Create a pending request through a connector with a default delay.
+ */
+ protected function pendingRequest(Request $request): PendingRequest
+ {
+ return new PendingRequest(
+ new DelayConnectorStub,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+ }
+}
+
+class DelayConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+
+ /**
+ * Resolve the default request delay in milliseconds.
+ */
+ protected function defaultDelay(): ?int
+ {
+ return 500;
+ }
+}
+
+class DelayRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+}
+
+class DefaultDelayRequestStub extends DelayRequestStub
+{
+ /**
+ * Resolve the default request delay in milliseconds.
+ */
+ protected function defaultDelay(): ?int
+ {
+ return 100;
+ }
+}
+
+class CachedDelayRequestStub extends UserRequest implements Cacheable
+{
+ use HasCaching;
+
+ /**
+ * Get the cache lifetime.
+ */
+ public function cacheFor(): DateInterval|DateTimeInterface|int
+ {
+ return 60;
+ }
+}
diff --git a/tests/Saloon/Feature/RetryConnectorTest.php b/tests/Saloon/Feature/RetryConnectorTest.php
new file mode 100644
index 0000000000..af879065e7
--- /dev/null
+++ b/tests/Saloon/Feature/RetryConnectorTest.php
@@ -0,0 +1,297 @@
+ 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $response = (new RetryConnector(3))->send(new UserRequest, $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfTheAttemptsAreExhaustedItWillThrowAnExceptionFromTheLastRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500),
+ ]);
+
+ try {
+ (new RetryConnector(3))->send(new UserRequest, $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (InternalServerErrorException $exception) {
+ $this->assertSame(['name' => 'Teodor'], $exception->response()->json());
+ }
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfTheAttemptsAreExhaustedItWillReturnTheLastResponseIfThrowingIsDisabled(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500),
+ ]);
+
+ $response = (new RetryConnector(3, throw: false))->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfAFatalRequestExceptionHappensEvenWithThrowDisabledItWillThrowTheFatalRequestException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500)->throw(
+ fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest),
+ ),
+ ]);
+
+ $this->expectException(FatalRequestException::class);
+
+ (new RetryConnector(3, throw: false))->send(new UserRequest, $mockClient);
+ }
+
+ public function testAFailedRequestCanHaveAnIntervalBetweenEachAttempt(): void
+ {
+ Sleep::fake();
+
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ (new RetryConnector(3, 1000))->send(new UserRequest, $mockClient);
+
+ // Upstream waits in real time; the two intervals follow the first and second attempts.
+ Sleep::assertSequence([
+ Sleep::for(1000)->milliseconds(),
+ Sleep::for(1000)->milliseconds(),
+ ]);
+ }
+
+ public function testAnExceptionOtherThanARequestExceptionWillNotBeRetried(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+ $request = new UserRequest;
+ $request->middleware()->onResponse(fn (): never => throw new Exception('Yee-naw!'));
+
+ try {
+ (new RetryConnector(3))->send($request, $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (Exception $exception) {
+ $this->assertSame('Yee-naw!', $exception->getMessage());
+ }
+
+ $mockClient->assertSentCount(1);
+ }
+
+ public function testYouCanCustomiseIfTheMethodShouldRetry(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+ $connector = new RetryConnector(3, when: function (RequestException $exception): bool {
+ return $exception->response()->json() !== ['name' => 'Gareth'];
+ });
+
+ $this->expectException(InternalServerErrorException::class);
+ $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
+
+ $connector->send(new UserRequest, $mockClient);
+ }
+
+ public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $this->expectException(InternalServerErrorException::class);
+ $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
+
+ (new RetryConnector(3, when: fn (): bool => false))->send(new UserRequest, $mockClient);
+ }
+
+ public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledItWillReturnAResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $response = (new RetryConnector(5, when: fn (): bool => false, throw: false))->send(new UserRequest, $mockClient);
+
+ $this->assertSame(500, $response->status());
+ $this->assertSame(['name' => 'Sam'], $response->json());
+ }
+
+ public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledButAFatalRequestExceptionHappensItWillStillThrow(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500)->throw(
+ fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest),
+ ),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $this->expectException(FatalRequestException::class);
+
+ (new RetryConnector(5, when: fn (): bool => false, throw: false))->send(new UserRequest, $mockClient);
+ }
+
+ public function testYouCanModifyTheRequestInsideTheRetryHandler(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+ $index = 0;
+ $connector = new RetryConnector(5, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ) use (&$index): bool {
+ ++$index;
+
+ $pendingRequest->request()->replaceHeaders(['X-Test-Index' => (string) $index]);
+
+ return true;
+ });
+
+ $response = $connector->send(new UserRequest, $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+ $this->assertSame('2', $response->pendingRequest()->headers()['X-Test-Index']);
+ }
+
+ public function testYouCanAuthenticateTheRequestInsideTheRetryHandler(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 401),
+ MockResponse::make(['name' => 'Gareth'], 200),
+ ]);
+ $connector = new RetryConnector(2, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ): bool {
+ $pendingRequest->request()->authenticate(new TokenAuthenticator('newToken'));
+
+ return true;
+ });
+
+ $response = $connector->send(new UserRequest, $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+ $this->assertSame('Bearer newToken', $response->pendingRequest()->headers()['Authorization']);
+ }
+
+ public function testTheResponsePipelineIsOnlyExecutedOnceWhenRetrying(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ ]);
+ $counter = 0;
+ $request = new UserRequest;
+ $request->middleware()->onResponse(function () use (&$counter): void {
+ ++$counter;
+ });
+
+ $response = (new RetryConnector(2, throw: false))->send($request, $mockClient);
+
+ $this->assertSame(500, $response->status());
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+
+ // Counter should be 2 as we have sent two requests.
+ $this->assertSame(2, $counter);
+ }
+
+ public function testTheRequestsDefaultRetryPolicyReplacesTheConnectors(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ ]);
+ $connector = new RetryConnector(3, when: fn (): bool => false);
+
+ $response = $connector->send(new RetryUserRequest(2, throw: false), $mockClient);
+
+ // The connector's condition would stop the retry; the request's policy replaces it entirely.
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+ $mockClient->assertSentCount(2);
+ }
+
+ public function testAnExplicitSingleAttemptDisablesTheConnectorsRetries(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 200),
+ ]);
+
+ $response = (new RetryConnector(3))->send((new UserRequest)->retry(1), $mockClient);
+
+ $this->assertSame(500, $response->status());
+ $mockClient->assertSentCount(1);
+ }
+}
diff --git a/tests/Saloon/Feature/RetryRequestTest.php b/tests/Saloon/Feature/RetryRequestTest.php
new file mode 100644
index 0000000000..bb9dcd4f35
--- /dev/null
+++ b/tests/Saloon/Feature/RetryRequestTest.php
@@ -0,0 +1,365 @@
+ 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $response = (new TestConnector)->send(new RetryUserRequest(3), $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfTheAttemptsAreExhaustedItWillThrowAnExceptionFromTheLastRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500),
+ ]);
+
+ try {
+ (new TestConnector)->send(new RetryUserRequest(3), $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (InternalServerErrorException $exception) {
+ $this->assertSame(['name' => 'Teodor'], $exception->response()->json());
+ }
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfTheAttemptsAreExhaustedItWillReturnTheLastResponseIfThrowingIsDisabled(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500),
+ ]);
+
+ $response = (new TestConnector)->send(new RetryUserRequest(3, throw: false), $mockClient);
+
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testIfAFatalRequestExceptionHappensEvenWithThrowDisabledItWillThrowTheFatalRequestException(): void
+ {
+ $thrown = null;
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 500)->throw(
+ function (PendingRequest $pendingRequest) use (&$thrown): FatalRequestException {
+ return $thrown = new FatalRequestException(new Exception, $pendingRequest);
+ },
+ ),
+ ]);
+
+ try {
+ (new TestConnector)->send(new RetryUserRequest(3, throw: false), $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (FatalRequestException $exception) {
+ $this->assertSame($thrown, $exception);
+ }
+ }
+
+ public function testAFatalRequestExceptionFromAFakeResponseRunsTheFatalMiddlewareAndIsRetried(): void
+ {
+ $thrown = null;
+ $fatalExceptions = [];
+ $mockClient = new MockClient([
+ MockResponse::make()->throw(
+ function (PendingRequest $pendingRequest) use (&$thrown): FatalRequestException {
+ return $thrown = new FatalRequestException(new Exception('Connection refused.'), $pendingRequest);
+ },
+ ),
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+ $request = new RetryUserRequest(2);
+ $request->middleware()->onFatalException(function (FatalRequestException $exception) use (&$fatalExceptions): void {
+ $fatalExceptions[] = $exception;
+ });
+
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertSame(['name' => 'Sam'], $response->json());
+ $this->assertSame([$thrown], $fatalExceptions);
+ }
+
+ // Upstream waits in real time and separately asserts its custom sleep handler; Sleep::fake() covers both.
+ public function testAFailedRequestCanHaveAnIntervalBetweenEachAttempt(): void
+ {
+ Sleep::fake();
+
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ (new TestConnector)->send(new RetryUserRequest(3, 200), $mockClient);
+
+ Sleep::assertSequence([
+ Sleep::for(200)->milliseconds(),
+ Sleep::for(200)->milliseconds(),
+ ]);
+ $mockClient->assertSentCount(3);
+ }
+
+ // REMOVED: SendAndRetryTest. Connector::sendAndRetry() is deprecated upstream and its cases repeat this file's.
+ // Its exponential backoff case is kept here using a delay closure instead of $useExponentialBackoff.
+ public function testAFailedRequestCanHaveAnIntervalWithExponentialBackoffBetweenEachAttempt(): void
+ {
+ Sleep::fake();
+
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Michael'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ (new TestConnector)->send(
+ new RetryUserRequest(4, static fn (int $attempt): int => 1000 * 2 ** ($attempt - 1)),
+ $mockClient,
+ );
+
+ Sleep::assertSequence([
+ Sleep::for(1000)->milliseconds(),
+ Sleep::for(2000)->milliseconds(),
+ Sleep::for(4000)->milliseconds(),
+ ]);
+ }
+
+ public function testAnExceptionOtherThanARequestExceptionWillNotBeRetried(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+ $request = new RetryUserRequest(3);
+ $request->middleware()->onResponse(fn (): never => throw new Exception('Yee-naw!'));
+
+ try {
+ (new TestConnector)->send($request, $mockClient);
+
+ $this->fail('The request did not throw.');
+ } catch (Exception $exception) {
+ $this->assertSame('Yee-naw!', $exception->getMessage());
+ }
+
+ $mockClient->assertSentCount(1);
+ }
+
+ public function testYouCanCustomiseIfTheMethodShouldRetry(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $this->expectException(InternalServerErrorException::class);
+ $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
+
+ (new TestConnector)->send(new RetryUserRequest(3, when: function (RequestException $exception): bool {
+ return $exception->response()->json() !== ['name' => 'Gareth'];
+ }), $mockClient);
+ }
+
+ public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $this->expectException(InternalServerErrorException::class);
+ $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
+
+ (new TestConnector)->send(new RetryUserRequest(3, when: fn (): bool => false), $mockClient);
+ }
+
+ public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledItWillReturnAResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $response = (new TestConnector)->send(
+ new RetryUserRequest(3, when: fn (): bool => false, throw: false),
+ $mockClient,
+ );
+
+ $this->assertSame(500, $response->status());
+ $this->assertSame(['name' => 'Sam'], $response->json());
+ }
+
+ public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledButAFatalRequestExceptionHappensItWillStillThrow(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500)->throw(
+ fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest),
+ ),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+
+ $this->expectException(FatalRequestException::class);
+
+ (new TestConnector)->send(new RetryUserRequest(3, when: fn (): bool => false, throw: false), $mockClient);
+ }
+
+ public function testYouCanModifyTheRequestInsideTheRetryHandler(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ MockResponse::make(['name' => 'Teodor'], 200),
+ ]);
+ $index = 0;
+
+ $response = (new TestConnector)->send(new RetryUserRequest(5, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ) use (&$index): bool {
+ ++$index;
+
+ $pendingRequest->request()->replaceHeaders(['X-Test-Index' => (string) $index]);
+
+ return true;
+ }), $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Teodor'], $response->json());
+ $this->assertSame('2', $response->pendingRequest()->headers()['X-Test-Index']);
+ }
+
+ public function testYouCanAuthenticateTheRequestInsideTheRetryHandler(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 401),
+ MockResponse::make(['name' => 'Gareth'], 200),
+ ]);
+
+ $response = (new TestConnector)->send(new RetryUserRequest(2, when: function (
+ RequestException $exception,
+ PendingRequest $pendingRequest,
+ ): bool {
+ $pendingRequest->request()->authenticate(new TokenAuthenticator('newToken'));
+
+ return true;
+ }), $mockClient);
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+ $this->assertSame('Bearer newToken', $response->pendingRequest()->headers()['Authorization']);
+ }
+
+ public function testTheResponsePipelineIsOnlyExecutedOnceWhenRetrying(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ ]);
+ $counter = 0;
+ $request = new RetryUserRequest(2, throw: false);
+ $request->middleware()->onResponse(function () use (&$counter): void {
+ ++$counter;
+ });
+
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertSame(500, $response->status());
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+
+ // Counter should be 2 as we have sent two requests.
+ $this->assertSame(2, $counter);
+ }
+
+ public function testAnExplicitRetryReplacesTheRequestsDefaultRetryPolicy(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 500),
+ MockResponse::make(['name' => 'Gareth'], 200),
+ ]);
+
+ $response = (new TestConnector)->send((new RetryUserRequest(2))->retry(1), $mockClient);
+
+ $this->assertSame(500, $response->status());
+ $mockClient->assertSentCount(1);
+ }
+
+ // Upstream raises negative attempts and intervals to their minimum. Hypervel rejects them; a zero interval is
+ // valid and is the default.
+ #[DataProvider('invalidRetryPolicies')]
+ public function testInvalidAttemptsOrIntervalsAreRejected(int $times, int $sleepMilliseconds, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ (new UserRequest)->retry($times, $sleepMilliseconds);
+ }
+
+ /**
+ * Get the invalid retry attempts and intervals.
+ *
+ * @return array
+ */
+ public static function invalidRetryPolicies(): array
+ {
+ return [
+ 'zero attempts' => [0, 0, 'Retry attempts must contain at least one attempt.'],
+ 'negative attempts' => [-1, 0, 'Retry attempts must contain at least one attempt.'],
+ 'negative interval' => [2, -1, 'The retry delay must be a non-negative integer.'],
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/RetryConnector.php b/tests/Saloon/Fixtures/Connectors/RetryConnector.php
new file mode 100644
index 0000000000..5d256cfb22
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/RetryConnector.php
@@ -0,0 +1,34 @@
+ $times
+ */
+ public function __construct(
+ protected array|int $times = 1,
+ protected Closure|int $sleepMilliseconds = 0,
+ protected ?Closure $when = null,
+ protected bool $throw = true,
+ ?string $url = null,
+ ) {
+ parent::__construct($url);
+ }
+
+ /**
+ * Resolve the default retry policy.
+ */
+ protected function defaultRetryPolicy(): ?RetryPolicy
+ {
+ return new RetryPolicy($this->times, $this->sleepMilliseconds, $this->when, $this->throw);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php b/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php
new file mode 100644
index 0000000000..4512b9c74f
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php
@@ -0,0 +1,24 @@
+ $times
+ */
+ public function __construct(
+ protected array|int $times = 1,
+ protected Closure|int $sleepMilliseconds = 0,
+ protected ?Closure $when = null,
+ protected bool $throw = true,
+ ) {
+ }
+
+ /**
+ * Resolve the default retry policy.
+ */
+ protected function defaultRetryPolicy(): ?RetryPolicy
+ {
+ return new RetryPolicy($this->times, $this->sleepMilliseconds, $this->when, $this->throw);
+ }
+}
diff --git a/tests/Saloon/Http/RequestDelayTest.php b/tests/Saloon/Http/RequestDelayTest.php
deleted file mode 100644
index ca856a3ce1..0000000000
--- a/tests/Saloon/Http/RequestDelayTest.php
+++ /dev/null
@@ -1,129 +0,0 @@
-assertNull($this->request()->delayMilliseconds());
- }
-
- public function testItStoresARepresentableDelay(): void
- {
- $request = $this->request()->delay(250);
-
- $this->assertSame(250, $request->delayMilliseconds());
- }
-
- public function testTheDefaultDelayIsUsedUntilADelayIsSet(): void
- {
- $request = new DefaultDelayRequestStub;
-
- $this->assertSame(100, $request->delayMilliseconds());
- $this->assertSame(0, $request->delay(0)->delayMilliseconds());
- }
-
- public function testAnExplicitZeroDelayOverridesTheConnectorDelay(): void
- {
- $this->assertSame(500, $this->pendingRequest(new DelayRequestStub)->delayMilliseconds());
- $this->assertSame(0, $this->pendingRequest((new DelayRequestStub)->delay(0))->delayMilliseconds());
- }
-
- public function testItRejectsNegativeDelays(): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('representable non-negative');
-
- $this->request()->delay(-1);
- }
-
- public function testItRejectsDelaysThatOverflowMicroseconds(): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('representable non-negative');
-
- $this->request()->delay(intdiv(PHP_INT_MAX, 1000) + 1);
- }
-
- /**
- * Create an object with an operation-owned request delay.
- */
- protected function request(): object
- {
- return new class {
- use HasDelay;
- };
- }
-
- /**
- * Create a pending request through a connector with a default delay.
- */
- protected function pendingRequest(Request $request): PendingRequest
- {
- return new PendingRequest(
- new DelayConnectorStub,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
-}
-
-class DelayConnectorStub extends Connector
-{
- /**
- * Resolve the integration base URL.
- */
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-
- /**
- * Resolve the default request delay in milliseconds.
- */
- protected function defaultDelay(): ?int
- {
- return 500;
- }
-}
-
-class DelayRequestStub extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-}
-
-class DefaultDelayRequestStub extends DelayRequestStub
-{
- /**
- * Resolve the default request delay in milliseconds.
- */
- protected function defaultDelay(): ?int
- {
- return 100;
- }
-}
diff --git a/tests/Saloon/SaloonManagerTest.php b/tests/Saloon/SaloonManagerTest.php
index 796ab581b3..6b549c3a9c 100644
--- a/tests/Saloon/SaloonManagerTest.php
+++ b/tests/Saloon/SaloonManagerTest.php
@@ -80,28 +80,43 @@ public function testEachRetryUsesFreshOperationStateAndRunsTheCompleteLifecycle(
$http->assertSentCount(2);
}
- public function testFailedResponseIsReturnedWithoutAnExplicitThrowOrRetryPolicy(): void
+ public function testMiddlewareRunsInRegistrationOrderOnEveryAttemptWithoutAccumulating(): void
{
$http = $this->http();
- $http->fake(['*' => Factory::response('failed', 500)]);
+ $http->fake([
+ '*' => $http->sequence()
+ ->pushStatus(500)
+ ->push('complete', 200)
+ ->push('again', 200),
+ ]);
+ $manager = $this->manager($http);
+ $manager->middleware()->onRequest(static function (): void {
+ OrderedManagerRequestStub::$order[] = 'global';
+ });
+ $request = (new OrderedManagerRequestStub)->retry(2);
+ $request->middleware()->onRequest(static function (): void {
+ OrderedManagerRequestStub::$order[] = 'request';
+ });
+ $attempt = ['global', 'plugin', 'connector boot', 'request', 'request boot'];
- $response = $this->manager($http)->send(new ManagerConnectorStub, new ManagerRequestStub);
+ $manager->send(new OrderedManagerConnectorStub, $request);
+ $manager->send(new OrderedManagerConnectorStub, $request);
- $this->assertSame(500, $response->status());
- $this->assertSame('failed', $response->body());
- $http->assertSentCount(1);
+ $this->assertSame([...$attempt, ...$attempt, ...$attempt], OrderedManagerRequestStub::$order);
+ $this->assertCount(1, $manager->middleware()->requestPipeline()->pipes());
+ $this->assertCount(1, $request->middleware()->requestPipeline()->pipes());
}
- public function testRetryCanReturnTheFinalFailedResponseWithoutThrowing(): void
+ public function testFailedResponseIsReturnedWithoutAnExplicitThrowOrRetryPolicy(): void
{
$http = $this->http();
$http->fake(['*' => Factory::response('failed', 500)]);
- $request = (new ManagerRequestStub)->retry(2, throw: false);
- $response = $this->manager($http)->send(new ManagerConnectorStub, $request);
+ $response = $this->manager($http)->send(new ManagerConnectorStub, new ManagerRequestStub);
$this->assertSame(500, $response->status());
- $http->assertSentCount(2);
+ $this->assertSame('failed', $response->body());
+ $http->assertSentCount(1);
}
public function testConnectorBootMayConfigureTheOperationRetryPolicy(): void
@@ -203,7 +218,7 @@ public function testReturningAnotherPendingRequestCannotDivertBodyPreparation():
{
$http = $this->http();
$capturedBody = null;
- $http->fake(function (HttpRequest $request) use (&$capturedBody) {
+ $http->fake(function (HttpRequest $request) use (&$capturedBody): PromiseInterface {
$capturedBody = $request->body();
return Factory::response();
@@ -318,7 +333,7 @@ public function testSendingListenerMutationsAreFinalAndPsrInspectionHasNoSideEff
{
$http = $this->http();
$capturedRequest = null;
- $http->fake(function (HttpRequest $request) use (&$capturedRequest) {
+ $http->fake(function (HttpRequest $request) use (&$capturedRequest): PromiseInterface {
$capturedRequest = $request;
return Factory::response();
@@ -367,6 +382,7 @@ protected function setUp(): void
ManagerRequestStub::$responseMiddlewareCalls = 0;
ManagerRequestStub::$fatalMiddlewareCalls = 0;
PsrHookManagerConnectorStub::$psrHookCalls = 0;
+ OrderedManagerRequestStub::$order = [];
}
/**
@@ -404,11 +420,17 @@ class ManagerConnectorStub extends Connector
{
public int $bootCalls = 0;
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Configure a pending request for this resource.
+ */
public function boot(PendingRequest $pendingRequest): void
{
++$this->bootCalls;
@@ -420,6 +442,9 @@ public function boot(PendingRequest $pendingRequest): void
class RetryingManagerConnectorStub extends ManagerConnectorStub
{
+ /**
+ * Configure a pending request for this resource.
+ */
public function boot(PendingRequest $pendingRequest): void
{
parent::boot($pendingRequest);
@@ -437,6 +462,9 @@ class PsrHookManagerConnectorStub extends ManagerConnectorStub
{
public static int $psrHookCalls = 0;
+ /**
+ * Handle the final PSR request before it is sent.
+ */
public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface
{
++static::$psrHookCalls;
@@ -461,11 +489,17 @@ class ManagerRequestStub extends Request
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users';
}
+ /**
+ * Configure a pending request for this resource.
+ */
public function boot(PendingRequest $pendingRequest): void
{
++$this->bootCalls;
@@ -526,8 +560,77 @@ class PluginAuthenticatedManagerRequestStub extends ManagerRequestStub
use AppliesManagerAuthentication;
}
+class OrderedManagerConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+
+ /**
+ * Configure a pending request for this resource.
+ */
+ public function boot(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->middleware()->onRequest(static function (): void {
+ OrderedManagerRequestStub::$order[] = 'connector boot';
+ });
+ }
+}
+
+trait RecordsManagerPluginMiddleware
+{
+ /**
+ * Register the plugin's request middleware.
+ */
+ public function bootRecordsManagerPluginMiddleware(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->middleware()->onRequest(static function (): void {
+ OrderedManagerRequestStub::$order[] = 'plugin';
+ });
+ }
+}
+
+class OrderedManagerRequestStub extends Request
+{
+ use RecordsManagerPluginMiddleware;
+
+ /**
+ * The request middleware in the order it ran.
+ *
+ * @var list
+ */
+ public static array $order = [];
+
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Configure a pending request for this resource.
+ */
+ public function boot(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->middleware()->onRequest(static function (): void {
+ OrderedManagerRequestStub::$order[] = 'request boot';
+ });
+ }
+}
+
class ManagerFakeRequestMiddleware implements RequestMiddleware
{
+ /**
+ * Handle an outgoing request.
+ */
public function __invoke(PendingRequest $pendingRequest): ?FakeResponse
{
return new MockResponse('class middleware');
diff --git a/tests/Saloon/Unit/MiddlewarePipelineTest.php b/tests/Saloon/Unit/MiddlewarePipelineTest.php
new file mode 100644
index 0000000000..f918d949f1
--- /dev/null
+++ b/tests/Saloon/Unit/MiddlewarePipelineTest.php
@@ -0,0 +1,531 @@
+onRequest(function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+ })
+ ->onRequest(function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-Two', 'Howdy');
+ });
+
+ $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest);
+ $pipeline->executeRequestPipeline($pendingRequest);
+
+ $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']);
+ $this->assertSame('Howdy', $pendingRequest->headers()['X-Pipe-Two']);
+ }
+
+ public function testRequestMiddlewareCanAddANamedPipe(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+
+ $pipeline->onRequest(function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+ }, 'YeeHawPipe');
+
+ $pipe = $pipeline->requestPipeline()->pipes()[0];
+
+ $this->assertInstanceOf(Pipe::class, $pipe);
+ $this->assertSame('YeeHawPipe', $pipe->name);
+ $this->assertNull($pipe->order);
+
+ $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest);
+ $pipeline->executeRequestPipeline($pendingRequest);
+
+ $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']);
+ }
+
+ public function testRequestMiddlewareNamedPipeMustBeUnique(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+
+ $pipeline->onRequest(
+ callable: function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+ },
+ name: 'YeeHawPipe',
+ );
+
+ $this->expectException(DuplicatePipeNameException::class);
+ $this->expectExceptionMessage('The "YeeHawPipe" pipe already exists on the pipeline');
+
+ $pipeline->onRequest(
+ callable: function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+ },
+ name: 'YeeHawPipe',
+ );
+ }
+
+ // Upstream passes a returned pending request to the next pipe. The manager always sends the pending request it
+ // created, as upstream's own send path does, so a returned pending request is ignored.
+ public function testRequestMiddlewareIgnoresAReturnedPendingRequest(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $connector = new TestConnector;
+ $errorRequest = $connector->createPendingRequest(new ErrorRequest);
+ $nextRequest = null;
+
+ $pipeline
+ ->onRequest(function (PendingRequest $request) use ($errorRequest): PendingRequest {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+
+ return $errorRequest;
+ })
+ ->onRequest(function (PendingRequest $request) use (&$nextRequest): void {
+ $nextRequest = $request;
+ });
+
+ $pendingRequest = $connector->createPendingRequest(new UserRequest);
+ $pipeline->executeRequestPipeline($pendingRequest);
+
+ $this->assertSame($pendingRequest, $nextRequest);
+ $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']);
+ }
+
+ public function testRequestMiddlewareRunsInOrderOfPipes(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Taylor';
+ });
+
+ $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest));
+
+ $this->assertSame(['Sam', 'Taylor'], $names);
+ }
+
+ public function testRequestMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::First)
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest));
+
+ $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names);
+ }
+
+ public function testRequestMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::Last)
+ ->onRequest(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest));
+
+ $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names);
+ }
+
+ public function testResponseMiddlewareCanAddANamedPipe(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $count = 0;
+
+ $pipeline->onResponse(function () use (&$count): void {
+ ++$count;
+ }, 'ResponsePipe');
+
+ $pipe = $pipeline->responsePipeline()->pipes()[0];
+
+ $this->assertInstanceOf(Pipe::class, $pipe);
+ $this->assertSame('ResponsePipe', $pipe->name);
+ $this->assertNull($pipe->order);
+
+ $pipeline->executeResponsePipeline($this->response());
+
+ $this->assertSame(1, $count);
+ }
+
+ public function testResponseMiddlewareNamedPipeMustBeUnique(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+
+ $pipeline->onResponse(function (): void {
+ }, 'ResponsePipe');
+
+ $this->expectException(DuplicatePipeNameException::class);
+ $this->expectExceptionMessage('The "ResponsePipe" pipe already exists on the pipeline');
+
+ $pipeline->onResponse(function (): void {
+ }, 'ResponsePipe');
+ }
+
+ public function testResponseMiddlewareCanAddAPipe(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $count = 0;
+
+ $pipeline
+ ->onResponse(function (Response $response) use (&$count): void {
+ ++$count;
+ })
+ ->onResponse(function (Response $response) use (&$count): void {
+ ++$count;
+ });
+
+ $response = $this->response();
+
+ $this->assertSame($response, $pipeline->executeResponsePipeline($response));
+ $this->assertSame(2, $count);
+ }
+
+ public function testResponseMiddlewareUsesAReturnedResponseInTheNextStep(): void
+ {
+ $mockClient = new MockClient([
+ ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500),
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ]);
+ $connector = new TestConnector;
+ $pipeline = new MiddlewarePipeline;
+
+ $errorResponse = $connector->send(new ErrorRequest, $mockClient);
+
+ $pipeline->onResponse(fn (): Response => $errorResponse);
+
+ $response = $pipeline->executeResponsePipeline($connector->send(new UserRequest, $mockClient));
+
+ $this->assertSame($errorResponse, $response);
+ }
+
+ public function testResponseMiddlewareRunsInOrderOfPipes(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Taylor';
+ });
+
+ $pipeline->executeResponsePipeline($this->response());
+
+ $this->assertSame(['Sam', 'Taylor'], $names);
+ }
+
+ public function testResponseMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::First)
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeResponsePipeline($this->response());
+
+ $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names);
+ }
+
+ public function testResponseMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::Last)
+ ->onResponse(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeResponsePipeline($this->response());
+
+ $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names);
+ }
+
+ // Upstream sends to an unresolvable host and asserts inside a catch block, which also passes when nothing is
+ // thrown. The fatal pipeline only needs a fatal exception, so these cases build one directly.
+ public function testFatalMiddlewareCanAddAPipe(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $exception = $this->fatalException();
+ $received = [];
+
+ $pipeline
+ ->onFatalException(function (FatalRequestException $exception) use (&$received): void {
+ $received[] = $exception;
+ })
+ ->onFatalException(function (FatalRequestException $exception) use (&$received): void {
+ $received[] = $exception;
+ });
+
+ $pipeline->executeFatalPipeline($exception);
+
+ $this->assertSame([$exception, $exception], $received);
+ }
+
+ public function testFatalMiddlewareCanAddANamedPipe(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $count = 0;
+
+ $pipeline->onFatalException(function () use (&$count): void {
+ ++$count;
+ }, 'FatalPipe');
+
+ $pipe = $pipeline->fatalPipeline()->pipes()[0];
+
+ $this->assertInstanceOf(Pipe::class, $pipe);
+ $this->assertSame('FatalPipe', $pipe->name);
+ $this->assertNull($pipe->order);
+
+ $pipeline->executeFatalPipeline($this->fatalException());
+
+ $this->assertSame(1, $count);
+ }
+
+ public function testFatalMiddlewareNamedPipeMustBeUnique(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+
+ $pipeline->onFatalException(
+ callable: function (): void {
+ },
+ name: 'YeeHawPipe',
+ );
+
+ $this->expectException(DuplicatePipeNameException::class);
+ $this->expectExceptionMessage('The "YeeHawPipe" pipe already exists on the pipeline');
+
+ $pipeline->onFatalException(
+ callable: function (): void {
+ },
+ name: 'YeeHawPipe',
+ );
+ }
+
+ public function testFatalMiddlewareRunsInOrderOfPipes(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Taylor';
+ });
+
+ $pipeline->executeFatalPipeline($this->fatalException());
+
+ $this->assertSame(['Sam', 'Taylor'], $names);
+ }
+
+ public function testFatalMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::First)
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeFatalPipeline($this->fatalException());
+
+ $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names);
+ }
+
+ public function testFatalMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $names = [];
+
+ $pipeline
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Sam';
+ })
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Taylor';
+ }, order: PipeOrder::Last)
+ ->onFatalException(function () use (&$names): void {
+ $names[] = 'Andrew';
+ });
+
+ $pipeline->executeFatalPipeline($this->fatalException());
+
+ $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names);
+ }
+
+ public function testYouCanMergeAMiddlewarePipelineTogether(): void
+ {
+ $pipelineA = new MiddlewarePipeline;
+ $pipelineB = new MiddlewarePipeline;
+
+ $pipelineA
+ ->onRequest(function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Yee-Haw');
+ })
+ ->onRequest(function (PendingRequest $request): void {
+ $request->withHeader('X-Pipe-One', 'Howdy');
+ })
+ ->onResponse(fn (Response $response): Response => $response->throw(), 'response');
+
+ $this->assertEmpty($pipelineB->requestPipeline()->pipes());
+ $this->assertEmpty($pipelineB->responsePipeline()->pipes());
+
+ $pipelineB->merge($pipelineA);
+
+ $this->assertCount(2, $pipelineB->requestPipeline()->pipes());
+ $this->assertCount(1, $pipelineB->responsePipeline()->pipes());
+
+ // Merging rebuilds the pipes, so compare what each one holds.
+ $describe = static fn (Pipe $pipe): array => [$pipe->callable, $pipe->name, $pipe->order];
+
+ $this->assertSame(
+ array_map($describe, $pipelineA->requestPipeline()->pipes()),
+ array_map($describe, $pipelineB->requestPipeline()->pipes()),
+ );
+ $this->assertSame(
+ array_map($describe, $pipelineA->responsePipeline()->pipes()),
+ array_map($describe, $pipelineB->responsePipeline()->pipes()),
+ );
+ }
+
+ public function testWhenMergingAMiddlewarePipelineTogetherIfTwoPipelinesExistWithTheSamePipeItThrowsAnException(): void
+ {
+ $pipelineA = new MiddlewarePipeline;
+ $pipelineB = new MiddlewarePipeline;
+
+ $pipelineA->onRequest(fn (): null => null, 'howdy');
+ $pipelineB->onRequest(fn (): null => null, 'howdy');
+
+ $this->expectException(DuplicatePipeNameException::class);
+ $this->expectExceptionMessage('The "howdy" pipe already exists on the pipeline');
+
+ $pipelineA->merge($pipelineB);
+ }
+
+ public function testAMiddlewarePipelineIsCorrectlyDestructedWhenFinished(): void
+ {
+ $pipeline = new MiddlewarePipeline;
+ $pipelineReference = WeakReference::create($pipeline);
+
+ $pipeline
+ ->onRequest(function (): void {
+ })
+ ->onResponse(function (): void {
+ }, order: PipeOrder::Last)
+ ->onResponse(function (): void {
+ })
+ ->onFatalException(function (): void {
+ });
+
+ $this->assertInstanceOf(Pipeline::class, $pipeline->requestPipeline());
+ $this->assertCount(1, $pipeline->requestPipeline()->pipes());
+ $this->assertInstanceOf(Pipeline::class, $pipeline->responsePipeline());
+ $this->assertCount(2, $pipeline->responsePipeline()->pipes());
+ $this->assertCount(1, $pipeline->fatalPipeline()->pipes());
+ $this->assertSame($pipeline, $pipelineReference->get());
+
+ unset($pipeline);
+
+ $this->assertNull($pipelineReference->get());
+ }
+
+ /**
+ * Send a mocked request and return its response.
+ */
+ protected function response(): Response
+ {
+ return (new TestConnector)->send(new UserRequest, new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]));
+ }
+
+ /**
+ * Create a fatal request exception for a pending request.
+ */
+ protected function fatalException(): FatalRequestException
+ {
+ return new FatalRequestException(
+ new Exception('Could not resolve host.'),
+ (new TestConnector)->createPendingRequest(new UserRequest),
+ );
+ }
+}
diff --git a/tests/Saloon/Unit/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
index f4f5e527eb..557d62412b 100644
--- a/tests/Saloon/Unit/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -211,9 +211,9 @@ public function testUriAndBodyAreFinalizedAfterRequestMiddleware(): void
->withQueryParameters(['page' => 3])
->withData(['middleware' => true]);
});
- $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request);
-
- $pendingRequest->executeRequestPipeline()->finalizeUri()->prepareBody();
+ $pendingRequest = (new PendingRequestConnectorStub)->createPendingRequest($request)
+ ->finalizeUri()
+ ->prepareBody();
$this->assertSame('https://api.example.com/v1/users?version=1&page=3', (string) $pendingRequest->uri());
$this->assertSame(
diff --git a/tests/Saloon/Http/PipelineTest.php b/tests/Saloon/Unit/PipelineTest.php
similarity index 90%
rename from tests/Saloon/Http/PipelineTest.php
rename to tests/Saloon/Unit/PipelineTest.php
index a5bf181807..431ad3cc6b 100644
--- a/tests/Saloon/Http/PipelineTest.php
+++ b/tests/Saloon/Unit/PipelineTest.php
@@ -2,8 +2,9 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
+use Hypervel\Saloon\Data\Pipe;
use Hypervel\Saloon\Enums\PipeOrder;
use Hypervel\Saloon\Exceptions\DuplicatePipeNameException;
use Hypervel\Saloon\Http\Pipeline;
@@ -11,7 +12,7 @@
class PipelineTest extends TestCase
{
- public function testPipelineCanBeExecuted(): void
+ public function testAPipelineCanBeExecuted(): void
{
$pipeline = (new Pipeline)
->pipe(fn (int $number): int => $number + 5)
@@ -36,7 +37,7 @@ public function testPipesAreStoredInExecutionOrder(): void
);
$this->assertSame(
['first', 'default-one', 'default-two', 'last'],
- array_map(static fn ($pipe): ?string => $pipe->name, $pipeline->pipes()),
+ array_map(static fn (Pipe $pipe): ?string => $pipe->name, $pipeline->pipes()),
);
}
From 4d208bcc41285ecfa55d3bcef854593b6e5297d8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 19:34:33 +0000
Subject: [PATCH 06/26] Add missing method titles to Saloon test helpers
Adds title docblocks to helper methods in Saloon test files changed by
the earlier reconciliation commits, including the fixture name data
provider's return type. Docblock-only; each file's tests pass.
---
tests/Integration/Saloon/SaloonClientTest.php | 9 ++++++
.../Saloon/Console/GeneratorCommandsTest.php | 3 ++
tests/Saloon/Console/ListCommandTest.php | 3 ++
tests/Saloon/Http/FixtureTest.php | 29 +++++++++++++++++++
tests/Saloon/Http/MockClientTest.php | 15 ++++++++++
tests/Saloon/Unit/Body/SerializationTest.php | 3 ++
6 files changed, 62 insertions(+)
diff --git a/tests/Integration/Saloon/SaloonClientTest.php b/tests/Integration/Saloon/SaloonClientTest.php
index 9c95876bd8..a4043200dd 100644
--- a/tests/Integration/Saloon/SaloonClientTest.php
+++ b/tests/Integration/Saloon/SaloonClientTest.php
@@ -19,6 +19,9 @@ class SaloonClientTest extends TestCase
protected int $serverPort = 19505;
+ /**
+ * Get the package providers.
+ */
protected function getPackageProviders(ApplicationContract $app): array
{
return [SaloonServiceProvider::class];
@@ -91,6 +94,9 @@ public function __construct(protected readonly string $baseUrl)
{
}
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return $this->baseUrl;
@@ -101,6 +107,9 @@ class SaloonEngineRequest extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '';
diff --git a/tests/Saloon/Console/GeneratorCommandsTest.php b/tests/Saloon/Console/GeneratorCommandsTest.php
index a1c457ae0d..406a1f9eb3 100644
--- a/tests/Saloon/Console/GeneratorCommandsTest.php
+++ b/tests/Saloon/Console/GeneratorCommandsTest.php
@@ -20,6 +20,9 @@ class GeneratorCommandsTest extends TestCase
*/
protected array $generatedFiles = [];
+ /**
+ * Get the package providers.
+ */
protected function getPackageProviders(ApplicationContract $app): array
{
return [SaloonServiceProvider::class];
diff --git a/tests/Saloon/Console/ListCommandTest.php b/tests/Saloon/Console/ListCommandTest.php
index 1cea10c79e..edab98653d 100644
--- a/tests/Saloon/Console/ListCommandTest.php
+++ b/tests/Saloon/Console/ListCommandTest.php
@@ -15,6 +15,9 @@ class ListCommandTest extends TestCase
{
protected string $integrationsPath;
+ /**
+ * Get the package providers.
+ */
protected function getPackageProviders(ApplicationContract $app): array
{
return [SaloonServiceProvider::class];
diff --git a/tests/Saloon/Http/FixtureTest.php b/tests/Saloon/Http/FixtureTest.php
index 5508439ae4..702d336587 100644
--- a/tests/Saloon/Http/FixtureTest.php
+++ b/tests/Saloon/Http/FixtureTest.php
@@ -71,6 +71,11 @@ public function testItRejectsUnsafeOrNonPortableFixtureNames(string $name): void
(new Fixture($name, $this->files))->getFixturePath();
}
+ /**
+ * Get the fixture names that are unsafe or not portable.
+ *
+ * @return array
+ */
public static function invalidFixtureNames(): array
{
return [
@@ -218,16 +223,25 @@ protected function bindManager(bool $throwOnMissing): void
class RedactingFixtureStub extends Fixture
{
+ /**
+ * Define sensitive response headers.
+ */
protected function defineSensitiveHeaders(): array
{
return ['authorization' => '[redacted]'];
}
+ /**
+ * Define sensitive JSON attributes.
+ */
protected function defineSensitiveJsonParameters(): array
{
return ['token' => '[redacted]'];
}
+ /**
+ * Define sensitive response body patterns.
+ */
protected function defineSensitiveRegexPatterns(): array
{
return ['/key=[^"}]*/' => '[redacted]'];
@@ -236,6 +250,9 @@ protected function defineSensitiveRegexPatterns(): array
class RegexFixtureStub extends Fixture
{
+ /**
+ * Create a fixture that redacts the given pattern.
+ */
public function __construct(
string $name,
Filesystem $files,
@@ -244,6 +261,9 @@ public function __construct(
parent::__construct($name, $files);
}
+ /**
+ * Define sensitive response body patterns.
+ */
protected function defineSensitiveRegexPatterns(): array
{
return [$this->pattern => '[redacted]'];
@@ -252,16 +272,25 @@ protected function defineSensitiveRegexPatterns(): array
class CallableNamedReplacementFixtureStub extends Fixture
{
+ /**
+ * Define sensitive response headers.
+ */
protected function defineSensitiveHeaders(): array
{
return ['authorization' => 'trim'];
}
+ /**
+ * Define sensitive JSON attributes.
+ */
protected function defineSensitiveJsonParameters(): array
{
return ['token' => 'trim'];
}
+ /**
+ * Define sensitive response body patterns.
+ */
protected function defineSensitiveRegexPatterns(): array
{
return ['/key=[^"}]*/' => 'trim'];
diff --git a/tests/Saloon/Http/MockClientTest.php b/tests/Saloon/Http/MockClientTest.php
index d8ec2dcbf3..51c94af0b9 100644
--- a/tests/Saloon/Http/MockClientTest.php
+++ b/tests/Saloon/Http/MockClientTest.php
@@ -229,6 +229,9 @@ protected function manager(Factory $http): SaloonManager
class MockConnectorStub extends Connector
{
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
@@ -243,6 +246,9 @@ class MockRequestA extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users/a';
@@ -253,6 +259,9 @@ class MockRequestB extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users/b';
@@ -263,6 +272,9 @@ class MockRequestC extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/other';
@@ -273,6 +285,9 @@ class AllowedMockRequest extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/allowed/request';
diff --git a/tests/Saloon/Unit/Body/SerializationTest.php b/tests/Saloon/Unit/Body/SerializationTest.php
index 29aee71538..e13172a831 100644
--- a/tests/Saloon/Unit/Body/SerializationTest.php
+++ b/tests/Saloon/Unit/Body/SerializationTest.php
@@ -50,6 +50,9 @@ public function testJsonBodyRepositoryEncodesSupportedNestedValuesOnce(): void
'name' => new Stringable('Sam'),
'metadata' => new Collection([
'sidekick' => new class implements JsonSerializable {
+ /**
+ * Get the JSON representation of the value.
+ */
public function jsonSerialize(): string
{
return 'Mantas';
From cfce4370625cfb713592185c907e392d14090ef3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 20:24:42 +0000
Subject: [PATCH 07/26] Reconcile Saloon authentication and OAuth with upstream
Ports Saloon's authenticator, authenticates-requests, access-token
authenticator, OAuth config, auth-code flow, client-credentials flow
and absolute OAuth endpoint tests (saloonphp/saloon v4 at
2f45e526f9d1) with their fixtures, merging the existing Hypervel
authentication, OAuth config and OAuth grant tests into the
upstream-named files. The OAuth request body tests move to
Unit/Oauth2. This completes saloonphp/saloon#516, #518's OAuth expiry
cases, #539's access-token authenticator and #542's OAuth endpoint
cases.
Defects fixed:
- Token expiries were typed as CarbonInterface, so code passing a
plain DateTimeImmutable, as upstream does, failed, and a mutable
Date configuration produced mutable expiries. The contract,
AccessTokenAuthenticator and the grant factories now take
?DateTimeImmutable, and framework expiries copy the Date clock into
an exact CarbonImmutable.
- The authenticators were readonly classes, so subclasses that add
state failed to compile. They keep read-only properties instead.
AccessTokenAuthenticator now builds its header from getAccessToken(),
so an overridden getter reaches the request.
- The client-credentials grant shared the auth-code factory signature,
so upstream's documented two-argument override lost the expiry or
failed. Each grant now owns upstream's factory signatures, and
ParsesOAuthTokenResponses only parses and validates the response.
- A PKCE verifier was only added by the default token request, so a
custom resolveAccessTokenRequest() dropped it. The verifier is now
added to the resolved request before the modifiers run, and
resolveAccessTokenRequest() takes upstream's two arguments.
- Basic authentication was a transport option, so mocks, middleware
and assertions could not see it, and combining it with a token sent
a different header from upstream. It now sets the Authorization
header.
- Authorization URLs used a different parameter order, dropped falsy
extra parameters such as max_age=0, and could not replace standard
parameters such as response_type. They now follow upstream's order,
keep falsy values and let extra parameters replace everything except
state.
- Null and empty scopes are dropped from authorization URLs and
client-credentials token requests alike, through
OAuthConfig::scopes(), and a "0" scope is kept. Client-credentials
requests previously sent stray separators.
Adaptations, recorded in the README:
- OAuthConfig::validate() returns true and the validation and refresh
messages match upstream; getUser() takes the OAuth authenticator.
- Deprecated withTokenAuth(), withQueryAuth(), withHeaderAuth() and
withCertificateAuth() are not ported, withDigestAuth() takes no
digest type, and authenticator() replaces getAuthenticator().
- Authenticator properties stay read-only because an authenticator may
be shared by concurrent requests.
Validation: Saloon suite (815 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon; composer analyse; php-cs-fixer; the private SDK
generator suite against this branch.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 16 +-
src/saloon/README.md | 4 +
.../src/Contracts/OAuthAuthenticator.php | 4 +-
src/saloon/src/Data/OAuthConfig.php | 30 +-
.../Http/Auth/AccessTokenAuthenticator.php | 14 +-
.../src/Http/Auth/BasicAuthenticator.php | 8 +-
.../Http/Auth/CertificateAuthenticator.php | 6 +-
.../src/Http/Auth/CookieAuthenticator.php | 8 +-
.../src/Http/Auth/DigestAuthenticator.php | 6 +-
.../src/Http/Auth/HeaderAuthenticator.php | 6 +-
.../src/Http/Auth/QueryAuthenticator.php | 6 +-
.../src/Http/Auth/TokenAuthenticator.php | 6 +-
...ClientCredentialsTokenBasicAuthRequest.php | 5 +-
.../GetClientCredentialsTokenRequest.php | 7 +-
.../src/Traits/Auth/AuthenticatesRequests.php | 3 +
.../Traits/OAuth2/AuthorizationCodeGrant.php | 89 ++-
.../ClientCredentialsBasicAuthGrant.php | 2 +-
.../Traits/OAuth2/ClientCredentialsGrant.php | 32 +-
...ator.php => ParsesOAuthTokenResponses.php} | 50 +-
tests/Saloon/Data/OAuthConfigTest.php | 77 --
.../Feature/AuthenticatesRequestsTest.php | 54 ++
.../Oauth2/AbsoluteOAuthEndpointTest.php | 99 +++
.../Oauth2/AuthCodeFlowConnectorTest.php | 737 ++++++++++++++++++
.../ClientCredentialsFlowConnectorTest.php | 380 +++++++++
.../CustomOAuthAuthenticator.php | 30 +
.../Authenticators/PizzaAuthenticator.php | 30 +
.../AbsoluteTokenOAuthConnector.php | 35 +
...bsoluteTokenOAuthConnectorWithoutAllow.php | 34 +
.../Connectors/AuthorizeUrlOAuthConnector.php | 36 +
...rlOAuthConnectorWithConnectorAllowOnly.php | 46 ++
.../ClientCredentialsBasicAuthConnector.php | 30 +
.../Connectors/ClientCredentialsConnector.php | 43 +
...ustomRequestClientCredentialsConnector.php | 22 +
.../CustomRequestOAuth2Connector.php | 62 ++
.../CustomResponseOAuth2Connector.php | 52 ++
.../DefaultAuthenticatorConnector.php | 41 +
.../Connectors/NoConfigAuthCodeConnector.php | 39 +
.../NoConfigClientCredentialsConnector.php | 39 +
.../Fixtures/Connectors/OAuth2Connector.php | 44 ++
.../Fixtures/Plugins/AuthenticatorPlugin.php | 18 +
.../Requests/AuthenticatorPluginRequest.php | 34 +
.../Requests/BootAuthenticatorRequest.php | 36 +
.../Requests/DefaultAuthenticatorRequest.php | 44 ++
.../DefaultPizzaAuthenticatorRequest.php | 44 ++
.../OAuth/CustomAccessTokenRequest.php | 11 +
...tomClientCredentialsAccessTokenRequest.php | 11 +
.../Requests/OAuth/CustomOAuthUserRequest.php | 11 +
.../OAuth/CustomRefreshTokenRequest.php | 11 +
.../Fixtures/Requests/RequiresAuthRequest.php | 34 +
tests/Saloon/Http/AuthenticationTest.php | 141 ----
tests/Saloon/OAuth2/OAuthGrantTest.php | 436 -----------
tests/Saloon/SensitiveParameterTest.php | 14 +-
.../Saloon/Unit/AuthenticatesRequestsTest.php | 243 ++++++
tests/Saloon/Unit/AuthenticatorTest.php | 187 +++++
.../Oauth2/AccessTokenAuthenticatorTest.php | 99 +++
.../Unit/Oauth2/AuthCodeFlowConnectorTest.php | 104 +++
tests/Saloon/Unit/Oauth2/OAuthConfigTest.php | 112 +++
.../Oauth2}/OAuthRequestTest.php | 46 +-
59 files changed, 3058 insertions(+), 812 deletions(-)
rename src/saloon/src/Traits/OAuth2/{CreatesOAuthAuthenticator.php => ParsesOAuthTokenResponses.php} (54%)
delete mode 100644 tests/Saloon/Data/OAuthConfigTest.php
create mode 100644 tests/Saloon/Feature/AuthenticatesRequestsTest.php
create mode 100644 tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php
create mode 100644 tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
create mode 100644 tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
create mode 100644 tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php
create mode 100644 tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php
create mode 100644 tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnectorWithoutAllow.php
create mode 100644 tests/Saloon/Fixtures/Connectors/AuthorizeUrlOAuthConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/AuthorizeUrlOAuthConnectorWithConnectorAllowOnly.php
create mode 100644 tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/ClientCredentialsConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/CustomResponseOAuth2Connector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/NoConfigAuthCodeConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php
create mode 100644 tests/Saloon/Fixtures/Connectors/OAuth2Connector.php
create mode 100644 tests/Saloon/Fixtures/Plugins/AuthenticatorPlugin.php
create mode 100644 tests/Saloon/Fixtures/Requests/AuthenticatorPluginRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/BootAuthenticatorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DefaultAuthenticatorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/DefaultPizzaAuthenticatorRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/OAuth/CustomAccessTokenRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/OAuth/CustomClientCredentialsAccessTokenRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/OAuth/CustomOAuthUserRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/OAuth/CustomRefreshTokenRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/RequiresAuthRequest.php
delete mode 100644 tests/Saloon/Http/AuthenticationTest.php
delete mode 100644 tests/Saloon/OAuth2/OAuthGrantTest.php
create mode 100644 tests/Saloon/Unit/AuthenticatesRequestsTest.php
create mode 100644 tests/Saloon/Unit/AuthenticatorTest.php
create mode 100644 tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php
create mode 100644 tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
create mode 100644 tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
rename tests/Saloon/{Http => Unit/Oauth2}/OAuthRequestTest.php (71%)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 6af4724e0a..5f5aebfe46 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,7 +182,7 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Helpers/OAuth2/OAuthConfig maps to the immutable Data/OAuthConfig, and token-response parsing shared by the OAuth grant traits lives in Traits/OAuth2/ParsesOAuthTokenResponses. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
saloonphp/laravel-plugin:
branch: v4
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 2f34a1812d..9920ddff14 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1391,6 +1391,8 @@ session(['github_oauth_state' => $authorization->state]);
return redirect((string) $authorization);
```
+The `authorizationUrl` method also accepts a `scopeSeparator` and an array of `additionalQueryParameters`. Additional parameters may replace the standard ones, such as `response_type` for an OpenID Connect hybrid flow, but the URL's `state` always matches the returned state.
+
After the provider redirects to your application, exchange the code and validate the returned state:
```php
@@ -1511,7 +1513,19 @@ $authenticator = $connector->getAccessToken(
);
```
-For providers that require a different request body or token response format, override the protected `resolveAccessTokenRequest`, `resolveRefreshTokenRequest`, `resolveUserRequest`, `createOAuthAuthenticatorFromResponse`, or `createOAuthAuthenticator` method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow.
+For providers that require a different request body or token response format, override the protected `resolveAccessTokenRequest`, `resolveRefreshTokenRequest`, `resolveUserRequest`, `createOAuthAuthenticatorFromResponse`, or `createOAuthAuthenticator` method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow. A PKCE verifier is added to the request your `resolveAccessTokenRequest` method returns.
+
+For example, you may return your own authenticator class. Authorization code connectors receive the access token, refresh token, and expiry, while client credentials connectors receive the access token and expiry:
+
+```php
+use DateTimeImmutable;
+use Hypervel\Saloon\Contracts\OAuthAuthenticator;
+
+protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator
+{
+ return new SpotifyAuthenticator($accessToken, $refreshToken, $expiresAt);
+}
+```
## Concurrent Requests
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 77652671ae..bed22f1a77 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -26,6 +26,10 @@ Every request supports a body, so there is no `HasBody` contract to implement. T
The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options).
+Authentication helpers use the HTTP client's names: `withToken()`, `withBasicAuth()` and `withDigestAuth()`. Upstream's deprecated `withTokenAuth()`, `withQueryAuth()`, `withHeaderAuth()` and `withCertificateAuth()` are not included; pass the authenticator to `authenticate()` instead. `authenticator()` replaces `getAuthenticator()`. Authenticator properties are read-only because an authenticator may be shared by concurrent requests; to change credentials, create a new authenticator and pass it to `authenticate()`. `DigestAuthenticator` and `withDigestAuth()` take no digest-type argument, since digest is the only type supported. Digest credentials and client certificates are transport settings read with `transportAuthentication()` and `certificate()`, not request options.
+
The upstream NTLM authenticator is omitted. Integrations requiring NTLM must supply their own authenticator and transport middleware.
+OAuth 2 configuration is an immutable `Data\OAuthConfig` built from constructor arguments and read through public properties, replacing upstream's `Helpers\OAuth2\OAuthConfig` setters and getters, so connectors define `defaultOAuthConfig()` and per-call changes use the grant methods' `requestModifier`. `invokeRequestModifier()` is `modify()`. `authorizationUrl()` returns an `AuthorizationUrl` holding the URL and its state, instead of `getAuthorizationUrl()` storing the state on the shared connector for `getState()`. Additional authorization parameters cannot replace that state, and if `getAccessToken()` receives either state, both must be non-empty and equal. `getUser()` applies the configuration's request modifier before the per-call one, as the token methods do, and its request sends no form `Content-Type`. See [OAuth 2](https://hypervel.org/docs/saloon#oauth2).
+
Ported from: https://github.com/saloonphp/saloon
diff --git a/src/saloon/src/Contracts/OAuthAuthenticator.php b/src/saloon/src/Contracts/OAuthAuthenticator.php
index b08d19b904..7c732e53fa 100644
--- a/src/saloon/src/Contracts/OAuthAuthenticator.php
+++ b/src/saloon/src/Contracts/OAuthAuthenticator.php
@@ -4,7 +4,7 @@
namespace Hypervel\Saloon\Contracts;
-use Carbon\CarbonInterface;
+use DateTimeImmutable;
interface OAuthAuthenticator extends Authenticator
{
@@ -21,7 +21,7 @@ public function getRefreshToken(): ?string;
/**
* Get the expiry.
*/
- public function getExpiresAt(): ?CarbonInterface;
+ public function getExpiresAt(): ?DateTimeImmutable;
/**
* Determine if the authenticator has expired.
diff --git a/src/saloon/src/Data/OAuthConfig.php b/src/saloon/src/Data/OAuthConfig.php
index 8df385189c..dbcb7037fd 100644
--- a/src/saloon/src/Data/OAuthConfig.php
+++ b/src/saloon/src/Data/OAuthConfig.php
@@ -21,7 +21,7 @@
/**
* Create an OAuth 2 configuration.
*
- * @param list $defaultScopes
+ * @param list $defaultScopes
* @param null|callable(Request): void $requestModifier
*/
public function __construct(
@@ -40,6 +40,20 @@ public function __construct(
$this->requestModifier = $requestModifier === null ? null : $requestModifier(...);
}
+ /**
+ * Merge the default scopes with the given scopes, ignoring null and empty scopes.
+ *
+ * @param list $scopes
+ * @return array
+ */
+ public function scopes(array $scopes = []): array
+ {
+ return array_filter(
+ [...$this->defaultScopes, ...$scopes],
+ static fn (?string $scope): bool => $scope !== null && $scope !== '',
+ );
+ }
+
/**
* Apply the configured request modifier.
*
@@ -56,19 +70,23 @@ public function modify(Request $request): Request
/**
* Validate the OAuth 2 configuration.
+ *
+ * @throws OAuthConfigValidationException
*/
- public function validate(bool $withRedirectUri = true): void
+ public function validate(bool $withRedirectUrl = true): bool
{
if ($this->clientId === '') {
- throw new OAuthConfigValidationException('The client ID is empty or has not been provided.');
+ throw new OAuthConfigValidationException('The Client ID is empty or has not been provided.');
}
if ($this->clientSecret === '') {
- throw new OAuthConfigValidationException('The client secret is empty or has not been provided.');
+ throw new OAuthConfigValidationException('The Client Secret is empty or has not been provided.');
}
- if ($withRedirectUri && $this->redirectUri === '') {
- throw new OAuthConfigValidationException('The redirect URI is empty or has not been provided.');
+ if ($withRedirectUrl && $this->redirectUri === '') {
+ throw new OAuthConfigValidationException('The Redirect URI is empty or has not been provided.');
}
+
+ return true;
}
}
diff --git a/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php b/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php
index d858c26ba7..a06bb96a38 100644
--- a/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php
+++ b/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php
@@ -4,23 +4,23 @@
namespace Hypervel\Saloon\Http\Auth;
-use Carbon\CarbonInterface;
+use DateTimeImmutable;
use Hypervel\Saloon\Contracts\OAuthAuthenticator;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Support\Facades\Date;
use SensitiveParameter;
-readonly class AccessTokenAuthenticator implements OAuthAuthenticator
+class AccessTokenAuthenticator implements OAuthAuthenticator
{
/**
* Create an access token authenticator.
*/
public function __construct(
#[SensitiveParameter]
- public string $accessToken,
+ public readonly string $accessToken,
#[SensitiveParameter]
- public ?string $refreshToken = null,
- public ?CarbonInterface $expiresAt = null,
+ public readonly ?string $refreshToken = null,
+ public readonly ?DateTimeImmutable $expiresAt = null,
) {
}
@@ -29,7 +29,7 @@ public function __construct(
*/
public function set(PendingRequest $pendingRequest): void
{
- $pendingRequest->replaceHeaders(['Authorization' => 'Bearer ' . $this->accessToken]);
+ $pendingRequest->replaceHeaders(['Authorization' => 'Bearer ' . $this->getAccessToken()]);
}
/**
@@ -67,7 +67,7 @@ public function getRefreshToken(): ?string
/**
* Get the expiry.
*/
- public function getExpiresAt(): ?CarbonInterface
+ public function getExpiresAt(): ?DateTimeImmutable
{
return $this->expiresAt;
}
diff --git a/src/saloon/src/Http/Auth/BasicAuthenticator.php b/src/saloon/src/Http/Auth/BasicAuthenticator.php
index c646b72dfe..0eb69420a0 100644
--- a/src/saloon/src/Http/Auth/BasicAuthenticator.php
+++ b/src/saloon/src/Http/Auth/BasicAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class BasicAuthenticator implements Authenticator
+class BasicAuthenticator implements Authenticator
{
/**
* Create a basic authenticator.
*/
public function __construct(
- public string $username,
+ public readonly string $username,
#[SensitiveParameter]
- public string $password,
+ public readonly string $password,
) {
}
@@ -25,6 +25,6 @@ public function __construct(
*/
public function set(PendingRequest $pendingRequest): void
{
- $pendingRequest->setTransportAuthentication([$this->username, $this->password]);
+ $pendingRequest->replaceHeaders(['Authorization' => 'Basic ' . base64_encode($this->username . ':' . $this->password)]);
}
}
diff --git a/src/saloon/src/Http/Auth/CertificateAuthenticator.php b/src/saloon/src/Http/Auth/CertificateAuthenticator.php
index 6ca00ddfba..d01b07e380 100644
--- a/src/saloon/src/Http/Auth/CertificateAuthenticator.php
+++ b/src/saloon/src/Http/Auth/CertificateAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class CertificateAuthenticator implements Authenticator
+class CertificateAuthenticator implements Authenticator
{
/**
* Create a certificate authenticator.
*/
public function __construct(
- public string $path,
+ public readonly string $path,
#[SensitiveParameter]
- public ?string $password = null,
+ public readonly ?string $password = null,
) {
}
diff --git a/src/saloon/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php
index 4ce6088338..099b503c49 100644
--- a/src/saloon/src/Http/Auth/CookieAuthenticator.php
+++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php
@@ -9,16 +9,16 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class CookieAuthenticator implements Authenticator
+class CookieAuthenticator implements Authenticator
{
/**
* Create a cookie authenticator.
*/
public function __construct(
- public string $name,
+ public readonly string $name,
#[SensitiveParameter]
- public string $value,
- public ?string $domain = null,
+ public readonly string $value,
+ public readonly ?string $domain = null,
) {
}
diff --git a/src/saloon/src/Http/Auth/DigestAuthenticator.php b/src/saloon/src/Http/Auth/DigestAuthenticator.php
index 769e389e7d..b864659722 100644
--- a/src/saloon/src/Http/Auth/DigestAuthenticator.php
+++ b/src/saloon/src/Http/Auth/DigestAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class DigestAuthenticator implements Authenticator
+class DigestAuthenticator implements Authenticator
{
/**
* Create a digest authenticator.
*/
public function __construct(
- public string $username,
+ public readonly string $username,
#[SensitiveParameter]
- public string $password,
+ public readonly string $password,
) {
}
diff --git a/src/saloon/src/Http/Auth/HeaderAuthenticator.php b/src/saloon/src/Http/Auth/HeaderAuthenticator.php
index 357f12d9b8..63c0d9350b 100644
--- a/src/saloon/src/Http/Auth/HeaderAuthenticator.php
+++ b/src/saloon/src/Http/Auth/HeaderAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class HeaderAuthenticator implements Authenticator
+class HeaderAuthenticator implements Authenticator
{
/**
* Create a header authenticator.
*/
public function __construct(
#[SensitiveParameter]
- public string $accessToken,
- public string $headerName = 'Authorization',
+ public readonly string $accessToken,
+ public readonly string $headerName = 'Authorization',
) {
}
diff --git a/src/saloon/src/Http/Auth/QueryAuthenticator.php b/src/saloon/src/Http/Auth/QueryAuthenticator.php
index fc70e515a6..4c572e6945 100644
--- a/src/saloon/src/Http/Auth/QueryAuthenticator.php
+++ b/src/saloon/src/Http/Auth/QueryAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class QueryAuthenticator implements Authenticator
+class QueryAuthenticator implements Authenticator
{
/**
* Create a query authenticator.
*/
public function __construct(
- public string $parameter,
+ public readonly string $parameter,
#[SensitiveParameter]
- public string $value,
+ public readonly string $value,
) {
}
diff --git a/src/saloon/src/Http/Auth/TokenAuthenticator.php b/src/saloon/src/Http/Auth/TokenAuthenticator.php
index d8c41fc2da..f37f29df9a 100644
--- a/src/saloon/src/Http/Auth/TokenAuthenticator.php
+++ b/src/saloon/src/Http/Auth/TokenAuthenticator.php
@@ -8,15 +8,15 @@
use Hypervel\Saloon\Http\PendingRequest;
use SensitiveParameter;
-readonly class TokenAuthenticator implements Authenticator
+class TokenAuthenticator implements Authenticator
{
/**
* Create a token authenticator.
*/
public function __construct(
#[SensitiveParameter]
- public string $token,
- public string $prefix = 'Bearer',
+ public readonly string $token,
+ public readonly string $prefix = 'Bearer',
) {
}
diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php
index c16c932b6a..b1c82e659a 100644
--- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php
+++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php
@@ -18,10 +18,7 @@ protected function defaultBody(): array
{
return [
'grant_type' => 'client_credentials',
- 'scope' => implode($this->scopeSeparator, [
- ...$this->oauthConfig->defaultScopes,
- ...$this->scopes,
- ]),
+ 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)),
];
}
diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php
index 75d61ffc89..a3123e106f 100644
--- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php
+++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php
@@ -23,7 +23,7 @@ class GetClientCredentialsTokenRequest extends Request
/**
* Create a client-credentials token request.
*
- * @param list $scopes
+ * @param list $scopes
*/
public function __construct(
protected OAuthConfig $oauthConfig,
@@ -59,10 +59,7 @@ protected function defaultBody(): array
'grant_type' => 'client_credentials',
'client_id' => $this->oauthConfig->clientId,
'client_secret' => $this->oauthConfig->clientSecret,
- 'scope' => implode($this->scopeSeparator, [
- ...$this->oauthConfig->defaultScopes,
- ...$this->scopes,
- ]),
+ 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)),
];
}
}
diff --git a/src/saloon/src/Traits/Auth/AuthenticatesRequests.php b/src/saloon/src/Traits/Auth/AuthenticatesRequests.php
index 6aefd811c1..6f02dff055 100644
--- a/src/saloon/src/Traits/Auth/AuthenticatesRequests.php
+++ b/src/saloon/src/Traits/Auth/AuthenticatesRequests.php
@@ -75,5 +75,8 @@ public function withDigestAuth(string $username, #[SensitiveParameter] string $p
return $this->authenticate(new DigestAuthenticator($username, $password));
}
+ // Upstream's deprecated withTokenAuth(), withQueryAuth(), withHeaderAuth() and withCertificateAuth() are not
+ // included; pass the authenticator to authenticate().
+
// NtlmAuthenticator and withNtlmAuth() are omitted; use a custom authenticator if needed.
}
diff --git a/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php b/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php
index 2535c39f8d..b80239e87a 100644
--- a/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php
+++ b/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php
@@ -4,10 +4,12 @@
namespace Hypervel\Saloon\Traits\OAuth2;
+use DateTimeImmutable;
use Hypervel\Saloon\Contracts\OAuthAuthenticator;
use Hypervel\Saloon\Data\AuthorizationUrl;
use Hypervel\Saloon\Data\OAuthConfig;
use Hypervel\Saloon\Exceptions\InvalidStateException;
+use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\OAuth2\GetAccessTokenRequest;
use Hypervel\Saloon\Http\OAuth2\GetRefreshTokenRequest;
@@ -18,6 +20,7 @@
use Hypervel\Support\Str;
use InvalidArgumentException;
use SensitiveParameter;
+use UnexpectedValueException;
/**
* @phpstan-require-extends Connector
@@ -25,13 +28,13 @@
*/
trait AuthorizationCodeGrant
{
- use CreatesOAuthAuthenticator;
use HasOAuthConfig;
+ use ParsesOAuthTokenResponses;
/**
* Create an authorization URL and its paired state.
*
- * @param list $scopes
+ * @param list $scopes
* @param array $additionalQueryParameters
*/
public function authorizationUrl(
@@ -55,24 +58,24 @@ public function authorizationUrl(
throw new InvalidArgumentException('The PKCE challenge must be non-empty and use the [S256] or [plain] method.');
}
- $resolvedScopes = [...$config->defaultScopes, ...$scopes];
- $queryParameters = [
- 'response_type' => 'code',
- 'client_id' => $config->clientId,
- 'redirect_uri' => $config->redirectUri,
- 'state' => $state,
- ];
+ $resolvedScopes = $config->scopes($scopes);
+ $queryParameters = ['response_type' => 'code'];
if ($resolvedScopes !== []) {
$queryParameters['scope'] = implode($scopeSeparator, $resolvedScopes);
}
+ $queryParameters['client_id'] = $config->clientId;
+ $queryParameters['redirect_uri'] = $config->redirectUri;
+ $queryParameters['state'] = $state;
+
if ($codeChallenge !== null) {
$queryParameters['code_challenge'] = $codeChallenge;
$queryParameters['code_challenge_method'] = $codeChallengeMethod;
}
- $queryParameters += $additionalQueryParameters;
+ // Additional parameters may replace the others, but not the state paired with the returned URL.
+ $queryParameters = [...$queryParameters, ...$additionalQueryParameters, 'state' => $state];
$uri = UrlResolver::resolve(
$this->resolveBaseUrl(),
$config->authorizeEndpoint,
@@ -105,7 +108,14 @@ public function getAccessToken(
$config = $this->oauthConfig();
$config->validate();
$this->validateState($state, $expectedState);
- $request = $config->modify($this->resolveAccessTokenRequest($code, $config, $codeVerifier));
+ $request = $this->resolveAccessTokenRequest($code, $config);
+
+ // Adding the verifier here keeps PKCE working with a custom token request resolver.
+ if ($codeVerifier !== null) {
+ $request->withData(['code_verifier' => $codeVerifier]);
+ }
+
+ $request = $config->modify($request);
$requestModifier?->__invoke($request);
$response = $this->send($request);
@@ -135,8 +145,12 @@ public function refreshAccessToken(
$config->validate();
if ($refreshToken instanceof OAuthAuthenticator) {
- $refreshToken = $refreshToken->getRefreshToken()
- ?? throw new InvalidArgumentException('The provided OAuth authenticator does not contain a refresh token.');
+ if ($refreshToken->isNotRefreshable()) {
+ throw new InvalidArgumentException('The provided OAuthAuthenticator does not contain a refresh token.');
+ }
+
+ /** @var string $refreshToken */
+ $refreshToken = $refreshToken->getRefreshToken();
}
$request = $config->modify($this->resolveRefreshTokenRequest($config, $refreshToken));
@@ -152,32 +166,61 @@ public function refreshAccessToken(
return $this->createOAuthAuthenticatorFromResponse($response, $refreshToken);
}
+ /**
+ * Create an OAuth authenticator from a token response.
+ */
+ protected function createOAuthAuthenticatorFromResponse(
+ #[SensitiveParameter]
+ Response $response,
+ #[SensitiveParameter]
+ ?string $fallbackRefreshToken = null,
+ ): OAuthAuthenticator {
+ [$accessToken, $expiresAt, $data] = $this->parseOAuthTokenResponse($response);
+ $refreshToken = $data['refresh_token'] ?? $fallbackRefreshToken;
+
+ if ($refreshToken !== null && ! is_string($refreshToken)) {
+ throw new UnexpectedValueException('The OAuth token response contains an invalid refresh token.');
+ }
+
+ return $this->createOAuthAuthenticator($accessToken, $refreshToken, $expiresAt);
+ }
+
+ /**
+ * Create an OAuth authenticator.
+ */
+ protected function createOAuthAuthenticator(
+ #[SensitiveParameter]
+ string $accessToken,
+ #[SensitiveParameter]
+ ?string $refreshToken = null,
+ ?DateTimeImmutable $expiresAt = null,
+ ): OAuthAuthenticator {
+ return new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt);
+ }
+
/**
* Retrieve the authenticated OAuth user.
*
* @template TRequest of Request
* @param null|callable(TRequest): void $requestModifier
*/
- public function getUser(OAuthAuthenticator $authenticator, ?callable $requestModifier = null): Response
+ public function getUser(OAuthAuthenticator $oauthAuthenticator, ?callable $requestModifier = null): Response
{
$config = $this->oauthConfig();
- $request = $config->modify($this->resolveUserRequest($config))->authenticate($authenticator);
+ $request = $config->modify($this->resolveUserRequest($config))->authenticate($oauthAuthenticator);
$requestModifier?->__invoke($request);
return $this->send($request);
}
+ // getState() is not included: authorizationUrl() returns the state with its URL, since the connector may be shared.
+
/**
* Resolve the access-token request.
*/
- protected function resolveAccessTokenRequest(
- #[SensitiveParameter]
- string $code,
- OAuthConfig $oauthConfig,
- #[SensitiveParameter]
- ?string $codeVerifier = null,
- ): Request {
- return new GetAccessTokenRequest($code, $oauthConfig, $codeVerifier);
+ protected function resolveAccessTokenRequest(#[SensitiveParameter] string $code, OAuthConfig $oauthConfig): Request
+ {
+ return new GetAccessTokenRequest($code, $oauthConfig);
}
/**
diff --git a/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php b/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php
index 88860d9c49..3c16bc86d6 100644
--- a/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php
+++ b/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php
@@ -20,7 +20,7 @@ trait ClientCredentialsBasicAuthGrant
/**
* Resolve the client-credentials request.
*
- * @param list $scopes
+ * @param list $scopes
*/
protected function resolveAccessTokenRequest(
OAuthConfig $oauthConfig,
diff --git a/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php b/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php
index c0eec1cbe7..d877a6d5a3 100644
--- a/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php
+++ b/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php
@@ -4,12 +4,15 @@
namespace Hypervel\Saloon\Traits\OAuth2;
+use DateTimeImmutable;
use Hypervel\Saloon\Contracts\OAuthAuthenticator;
use Hypervel\Saloon\Data\OAuthConfig;
+use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Http\Response;
+use SensitiveParameter;
/**
* @phpstan-require-extends Connector
@@ -17,14 +20,14 @@
*/
trait ClientCredentialsGrant
{
- use CreatesOAuthAuthenticator;
use HasOAuthConfig;
+ use ParsesOAuthTokenResponses;
/**
* Request a client-credentials access token.
*
* @template TRequest of Request
- * @param list $scopes
+ * @param list $scopes
* @param null|callable(TRequest): void $requestModifier
* @return ($returnResponse is true ? Response : OAuthAuthenticator)
*/
@@ -35,7 +38,7 @@ public function getAccessToken(
?callable $requestModifier = null,
): OAuthAuthenticator|Response {
$config = $this->oauthConfig();
- $config->validate(false);
+ $config->validate(withRedirectUrl: false);
$request = $config->modify($this->resolveAccessTokenRequest($config, $scopes, $scopeSeparator));
$requestModifier?->__invoke($request);
$response = $this->send($request);
@@ -49,10 +52,31 @@ public function getAccessToken(
return $this->createOAuthAuthenticatorFromResponse($response);
}
+ /**
+ * Create an OAuth authenticator from a token response.
+ */
+ protected function createOAuthAuthenticatorFromResponse(#[SensitiveParameter] Response $response): OAuthAuthenticator
+ {
+ [$accessToken, $expiresAt] = $this->parseOAuthTokenResponse($response);
+
+ return $this->createOAuthAuthenticator($accessToken, $expiresAt);
+ }
+
+ /**
+ * Create an OAuth authenticator.
+ */
+ protected function createOAuthAuthenticator(
+ #[SensitiveParameter]
+ string $accessToken,
+ ?DateTimeImmutable $expiresAt = null,
+ ): OAuthAuthenticator {
+ return new AccessTokenAuthenticator($accessToken, null, $expiresAt);
+ }
+
/**
* Resolve the client-credentials request.
*
- * @param list $scopes
+ * @param list $scopes
*/
protected function resolveAccessTokenRequest(
OAuthConfig $oauthConfig,
diff --git a/src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php b/src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php
similarity index 54%
rename from src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php
rename to src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php
index f633860ae8..2998182988 100644
--- a/src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php
+++ b/src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php
@@ -4,11 +4,9 @@
namespace Hypervel\Saloon\Traits\OAuth2;
-use Carbon\CarbonInterface;
-use Hypervel\Saloon\Contracts\OAuthAuthenticator;
-use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Response;
+use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Facades\Date;
use SensitiveParameter;
use UnexpectedValueException;
@@ -17,54 +15,29 @@
* @phpstan-require-extends Connector
* @phpstan-ignore trait.unused (user-facing OAuth 2 trait)
*/
-trait CreatesOAuthAuthenticator
+trait ParsesOAuthTokenResponses
{
/**
- * Create an OAuth authenticator from a token response.
+ * Parse the access token, expiry and data of a token response.
+ *
+ * @return array{string, ?CarbonImmutable, array}
*/
- protected function createOAuthAuthenticatorFromResponse(
- #[SensitiveParameter]
- Response $response,
- #[SensitiveParameter]
- ?string $fallbackRefreshToken = null,
- ): OAuthAuthenticator {
+ protected function parseOAuthTokenResponse(#[SensitiveParameter] Response $response): array
+ {
$data = $response->json();
$accessToken = is_array($data) ? ($data['access_token'] ?? null) : null;
- if (! is_string($accessToken) || $accessToken === '') {
+ if (! is_array($data) || ! is_string($accessToken) || $accessToken === '') {
throw new UnexpectedValueException('The OAuth token response does not contain a valid access token.');
}
- $refreshToken = $data['refresh_token'] ?? $fallbackRefreshToken;
-
- if ($refreshToken !== null && ! is_string($refreshToken)) {
- throw new UnexpectedValueException('The OAuth token response contains an invalid refresh token.');
- }
-
- return $this->createOAuthAuthenticator(
- $accessToken,
- $refreshToken,
- $this->resolveOAuthExpiry($data['expires_in'] ?? null),
- );
- }
-
- /**
- * Create an OAuth authenticator.
- */
- protected function createOAuthAuthenticator(
- #[SensitiveParameter]
- string $accessToken,
- #[SensitiveParameter]
- ?string $refreshToken = null,
- ?CarbonInterface $expiresAt = null,
- ): OAuthAuthenticator {
- return new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt);
+ return [$accessToken, $this->resolveOAuthExpiry($data['expires_in'] ?? null), $data];
}
/**
* Resolve an OAuth token expiry.
*/
- protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonInterface
+ protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonImmutable
{
if ($expiresIn === null) {
return null;
@@ -85,7 +58,8 @@ protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonInterface
throw new UnexpectedValueException('The OAuth token response contains an invalid expiry duration.');
}
- $now = Date::now();
+ // Date may be configured to create mutable dates, so copy the clock into an immutable expiry.
+ $now = CarbonImmutable::instance(Date::now());
if ($now->getTimestamp() > PHP_INT_MAX - $expiresIn) {
throw new UnexpectedValueException('The OAuth token response contains an invalid expiry duration.');
diff --git a/tests/Saloon/Data/OAuthConfigTest.php b/tests/Saloon/Data/OAuthConfigTest.php
deleted file mode 100644
index 1f4431441a..0000000000
--- a/tests/Saloon/Data/OAuthConfigTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
- $request->withHeader('X-OAuth', 'configured'),
- );
- $request = new OAuthConfigRequestStub;
-
- $this->assertSame($request, $config->modify($request));
- $this->assertSame(['X-OAuth' => 'configured'], $request->headers());
- $config->validate();
- }
-
- #[DataProvider('invalidConfigurations')]
- public function testItValidatesRequiredConfiguration(OAuthConfig $config, bool $withRedirectUri, string $message): void
- {
- $this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage($message);
-
- $config->validate($withRedirectUri);
- }
-
- public static function invalidConfigurations(): array
- {
- return [
- 'client id' => [new OAuthConfig('', 'secret', 'https://app.example.com/callback'), true, 'client ID'],
- 'client secret' => [new OAuthConfig('client', '', 'https://app.example.com/callback'), true, 'client secret'],
- 'redirect uri' => [new OAuthConfig('client', 'secret'), true, 'redirect URI'],
- ];
- }
-
- public function testRedirectUriIsOptionalForClientCredentials(): void
- {
- $config = new OAuthConfig('client', 'secret');
-
- $config->validate(false);
-
- $this->addToAssertionCount(1);
- }
-
- public function testAuthorizationUrlKeepsTheUrlAndStateTogether(): void
- {
- $authorization = new AuthorizationUrl('https://provider.example.com/authorize', 'state');
-
- $this->assertSame('https://provider.example.com/authorize', (string) $authorization);
- $this->assertSame('state', $authorization->state);
- }
-}
-
-class OAuthConfigRequestStub extends Request
-{
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return '/oauth';
- }
-}
diff --git a/tests/Saloon/Feature/AuthenticatesRequestsTest.php b/tests/Saloon/Feature/AuthenticatesRequestsTest.php
new file mode 100644
index 0000000000..d541521dd6
--- /dev/null
+++ b/tests/Saloon/Feature/AuthenticatesRequestsTest.php
@@ -0,0 +1,54 @@
+withDigestAuth('Sammyjo20', 'Cowboy1');
+
+ $authorization = [];
+
+ Http::fake(function (HttpRequest $request) use (&$authorization): PromiseInterface {
+ $authorization[] = $request->header('Authorization');
+
+ return count($authorization) === 1
+ ? Factory::response('', 401, ['WWW-Authenticate' => 'Digest realm="saloon", nonce="nonce", qop="auth"'])
+ : Factory::response();
+ });
+
+ $response = $connector->send($request);
+
+ $this->assertSame(200, $response->status());
+ $this->assertCount(2, $authorization);
+ $this->assertSame([], $authorization[0]);
+ $this->assertStringStartsWith('Digest username="Sammyjo20", realm="saloon", nonce="nonce"', $authorization[1][0]);
+ }
+}
diff --git a/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php b/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php
new file mode 100644
index 0000000000..881f48f25e
--- /dev/null
+++ b/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php
@@ -0,0 +1,99 @@
+oauthConfig());
+
+ $pending = $connector->createPendingRequest($request);
+
+ $this->assertSame('https://auth.external.com/oauth/token', (string) $pending->uri());
+ }
+
+ public function testOAuthClientCredentialsThrowsWhenTokenEndpointIsAbsoluteAndOAuthConfigDoesNotAllowOverride(): void
+ {
+ $connector = new AbsoluteTokenOAuthConnectorWithoutAllow;
+ $request = new GetClientCredentialsTokenRequest($connector->oauthConfig());
+
+ $this->expectException(PendingRequestException::class);
+
+ $connector->createPendingRequest($request)->uri();
+ }
+
+ public function testRequestAllowBaseUrlOverrideFalseOverridesOAuthConfigAllowForTokenUrl(): void
+ {
+ $connector = new AbsoluteTokenOAuthConnector;
+ $request = new class($connector->oauthConfig()) extends GetClientCredentialsTokenRequest {
+ /**
+ * Resolve whether the trusted OAuth endpoint may replace the connector base URL.
+ */
+ public function allowsBaseUrlOverride(): bool
+ {
+ return false;
+ }
+ };
+
+ $this->expectException(PendingRequestException::class);
+
+ $connector->createPendingRequest($request)->uri();
+ }
+
+ public function testGetAuthorizationUrlWorksWithAbsoluteAuthorizeEndpointWhenOAuthConfigAllowsOverride(): void
+ {
+ $connector = new AuthorizeUrlOAuthConnector;
+ $url = (string) $connector->authorizationUrl();
+
+ $this->assertStringStartsWith('https://login.provider.com/authorize', $url);
+ }
+
+ public function testGetAuthorizationUrlWorksWithAbsoluteAuthorizeWhenConnectorAllowsOverrideOnly(): void
+ {
+ $connector = new AuthorizeUrlOAuthConnectorWithConnectorAllowOnly;
+ $url = (string) $connector->authorizationUrl();
+
+ $this->assertStringStartsWith('https://login.provider.com/authorize', $url);
+ }
+
+ public function testTheUserRequestUsesAnAbsoluteUserUrlWhenOAuthConfigAllowsBaseOverride(): void
+ {
+ $config = new OAuthConfig(
+ 'client',
+ 'secret',
+ userEndpoint: 'https://oauth.example.net/user',
+ allowBaseUrlOverride: true,
+ );
+
+ $pending = (new TestConnector)->createPendingRequest(new GetUserRequest($config));
+
+ $this->assertSame('https://oauth.example.net/user', (string) $pending->uri());
+ }
+}
diff --git a/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
new file mode 100644
index 0000000000..cd73fe6a1e
--- /dev/null
+++ b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
@@ -0,0 +1,737 @@
+authorizationUrl(['scope-1', 'scope-2'], 'my-state');
+
+ $this->assertSame('my-state', $authorization->state);
+
+ $this->assertSame(
+ 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state',
+ (string) $authorization,
+ );
+ }
+
+ public function testYouCanProvideDefaultScopesThatWillBeAppliedToEveryAuthorizationUrl(): void
+ {
+ $connector = new OAuth2Connector(['defaultScopes' => ['scope-3']]);
+
+ $url = (string) $connector->authorizationUrl(['scope-1', 'scope-2'], 'my-state');
+
+ $this->assertSame(
+ 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-3%20scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state',
+ $url,
+ );
+ }
+
+ public function testYouCanGetAuthorizationUrlWithoutSettingValidScopes(): void
+ {
+ $connector = new OAuth2Connector(['defaultScopes' => ['', null]]);
+
+ $url = (string) $connector->authorizationUrl([], 'my-state');
+
+ $this->assertSame(
+ 'https://oauth.saloon.dev/authorize?response_type=code&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state',
+ $url,
+ );
+ }
+
+ public function testDefaultStateIsGeneratedAutomaticallyWithEveryAuthorizationUrlIfStateIsNotDefined(): void
+ {
+ $connector = new OAuth2Connector(['defaultScopes' => ['scope-3']]);
+
+ $authorization = $connector->authorizationUrl(['scope-1', 'scope-2']);
+ $state = $authorization->state;
+
+ $this->assertNotSame('', $state);
+
+ $this->assertStringEndsWith($state, (string) $authorization);
+ }
+
+ public function testAdditionalQueryParametersCanBeAddedPassedToTheAuthorizationUrl(): void
+ {
+ $connector = new OAuth2Connector;
+
+ $url = (string) $connector->authorizationUrl(
+ ['scope-1', 'scope-2'],
+ state: 'my-state',
+ additionalQueryParameters: ['another-param' => 'another-value', 'yee' => 'haw']
+ );
+
+ $this->assertStringEndsWith('another-param=another-value&yee=haw', $url);
+ $this->assertSame(
+ 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state&another-param=another-value&yee=haw',
+ $url,
+ );
+ }
+
+ #[DataProvider('codeChallenges')]
+ public function testAdditionalQueryParametersMayReplaceEverythingExceptTheState(?string $codeChallenge): void
+ {
+ $authorization = (new OAuth2Connector)->authorizationUrl(
+ scopes: ['profile', '0'],
+ state: 'known-state',
+ additionalQueryParameters: [
+ 'response_type' => 'code id_token',
+ 'client_id' => 'replaced',
+ 'state' => 'replaced-state',
+ 'prompt' => '',
+ 'include_granted_scopes' => false,
+ ],
+ codeChallenge: $codeChallenge,
+ );
+
+ parse_str(parse_url((string) $authorization, PHP_URL_QUERY) ?: '', $query);
+
+ $this->assertSame('known-state', $authorization->state);
+ $this->assertSame('known-state', $query['state']);
+ $this->assertSame('code id_token', $query['response_type']);
+ $this->assertSame('replaced', $query['client_id']);
+ $this->assertSame('profile 0', $query['scope']);
+ $this->assertSame('', $query['prompt']);
+ $this->assertSame('0', $query['include_granted_scopes']);
+ $this->assertSame($codeChallenge, $query['code_challenge'] ?? null);
+ $this->assertSame($codeChallenge === null ? null : 'S256', $query['code_challenge_method'] ?? null);
+ }
+
+ /**
+ * Get authorization URLs with and without a PKCE challenge.
+ *
+ * @return array
+ */
+ public static function codeChallenges(): array
+ {
+ return [
+ 'without PKCE' => [null],
+ 'with PKCE' => ['challenge'],
+ ];
+ }
+
+ public function testAuthorizationUrlPreservesEndpointQueryAndGeneratesState(): void
+ {
+ $authorization = (new OAuth2Connector(['authorizeEndpoint' => 'oauth/authorize?audience=users']))->authorizationUrl();
+
+ $this->assertNotSame('', $authorization->state);
+ $this->assertStringContainsString('audience=users&response_type=code', (string) $authorization);
+ $this->assertStringContainsString('state=' . $authorization->state, (string) $authorization);
+ }
+
+ public function testAuthorizationUrlReplacesProtocolParametersFromBaseAndEndpointQueries(): void
+ {
+ $authorization = (new BaseQueryOAuth2Connector(['authorizeEndpoint' => 'oauth/authorize?client_id=endpoint&audience=users']))
+ ->authorizationUrl(state: 'known-state');
+ $queryString = parse_url((string) $authorization, PHP_URL_QUERY) ?: '';
+ parse_str($queryString, $query);
+
+ $this->assertSame(1, substr_count($queryString, 'client_id='));
+ $this->assertSame('client-id', $query['client_id']);
+ $this->assertSame('value', $query['base']);
+ $this->assertSame('users', $query['audience']);
+ }
+
+ public function testInvalidPkceConfigurationIsRejectedBeforeSending(): void
+ {
+ $connector = new OAuth2Connector;
+
+ foreach ([['', 'S256'], ['challenge', 'unsupported']] as [$challenge, $method]) {
+ try {
+ $connector->authorizationUrl(codeChallenge: $challenge, codeChallengeMethod: $method);
+ $this->fail('An invalid PKCE configuration was accepted.');
+ } catch (InvalidArgumentException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+ }
+
+ public function testYouCanRequestATokenFromAConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken('code');
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertSame('refresh', $authenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+ }
+
+ public function testOauthAccessTokensDeriveExpiryFromTheGlobalClock(): void
+ {
+ $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ ]);
+
+ Date::setTestNow($now);
+
+ $connector = new OAuth2Connector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken('code');
+
+ $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt());
+ }
+
+ public function testExpiriesStayImmutableWhenDateCreatesMutableDates(): void
+ {
+ Date::use(Carbon::class);
+ Date::setTestNow('2026-01-01T00:00:00+00:00');
+ Saloon::fake([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600]),
+ ]);
+
+ $expiresAt = (new OAuth2Connector)->getAccessToken('code')->getExpiresAt();
+
+ $this->assertInstanceOf(CarbonImmutable::class, $expiresAt);
+ $this->assertEquals(new DateTimeImmutable('2026-01-01T01:00:00+00:00'), $expiresAt);
+ $this->assertEquals(new DateTimeImmutable('2026-01-01T00:00:00+00:00'), Date::now());
+ }
+
+ public function testCustomOauthAuthenticatorsUseTheGlobalClockForExpirySemantics(): void
+ {
+ $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ ]);
+
+ Date::setTestNow($now);
+
+ $connector = new CustomResponseOAuth2Connector('hello');
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken('code');
+
+ $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator);
+ $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt());
+ $this->assertFalse($authenticator->hasExpired());
+ }
+
+ public function testYouCanTapIntoTheAccessTokenRequestAndModifyIt(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken('code', requestModifier: function (Request $request): void {
+ $request->withQueryParameters(['yee' => 'haw']);
+ });
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertSame('refresh', $authenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters());
+ }
+
+ #[DataProvider('pkceConnectors')]
+ public function testTheCodeVerifierIsSentWithTheDefaultAndCustomTokenRequests(OAuth2Connector|CustomRequestOAuth2Connector $connector): void
+ {
+ $mockClient = Saloon::fake([
+ MockResponse::make(['access_token' => 'access']),
+ ]);
+
+ $connector->getAccessToken('code', codeVerifier: 'verifier');
+
+ $this->assertSame('verifier', $mockClient->lastPendingRequest()->body()['code_verifier']);
+ }
+
+ /**
+ * Get connectors with the default and a custom access-token request resolver.
+ *
+ * @return array
+ */
+ public static function pkceConnectors(): array
+ {
+ return [
+ 'default resolver' => [new OAuth2Connector],
+ 'custom resolver' => [new CustomRequestOAuth2Connector],
+ ];
+ }
+
+ public function testYouCanRequestTheOriginalResponseInsteadOfTheAuthenticatorOnTheCreateTokensMethod(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $response = $connector->getAccessToken('code', null, null, true);
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], $response->json());
+ }
+
+ public function testReturningTheRawTokenResponseSkipsAuthenticatorValidation(): void
+ {
+ Saloon::fake([MockResponse::make(['provider_specific' => true])]);
+
+ $response = (new OAuth2Connector)->getAccessToken('code', returnResponse: true);
+
+ $this->assertSame(['provider_specific' => true], $response->json());
+ }
+
+ public function testAnInvalidRefreshTokenIsRejected(): void
+ {
+ Saloon::fake([MockResponse::make(['access_token' => 'access', 'refresh_token' => []])]);
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('The OAuth token response contains an invalid refresh token.');
+
+ (new OAuth2Connector)->getAccessToken('code');
+ }
+
+ public function testItWillThrowAnExceptionIfStateIsInvalid(): void
+ {
+ $connector = new OAuth2Connector;
+
+ $state = 'secret';
+ $connector->authorizationUrl(['scope-1', 'scope-2'], $state);
+
+ $this->expectException(InvalidStateException::class);
+ $this->expectExceptionMessage('Invalid state.');
+
+ $connector->getAccessToken('code', 'invalid', $state);
+ }
+
+ public function testStateValidationRequiresACompleteMatchingPair(): void
+ {
+ $connector = new OAuth2Connector;
+ $mockClient = Saloon::fake([
+ MockResponse::make(['access_token' => 'access']),
+ ]);
+
+ foreach ([
+ ['returned', null],
+ [null, 'expected'],
+ ['', 'expected'],
+ ['returned', ''],
+ ['returned', 'expected'],
+ ] as [$returnedState, $expectedState]) {
+ try {
+ $connector->getAccessToken('code', $returnedState, $expectedState);
+ $this->fail('Invalid state was accepted.');
+ } catch (InvalidStateException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+
+ $this->assertSame('access', $connector->getAccessToken('code', 'state', 'state')->getAccessToken());
+ $mockClient->assertSentCount(1);
+ }
+
+ public function testYouCanRefreshATokenFromAConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $newAuthenticator = $connector->refreshAccessToken($authenticator);
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator);
+ $this->assertSame('access-new', $newAuthenticator->getAccessToken());
+ $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt());
+ }
+
+ public function testYouCanTapIntoTheRefreshTokenRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $newAuthenticator = $connector->refreshAccessToken($authenticator, requestModifier: function (Request $request): void {
+ $request->withQueryParameters(['yee' => 'haw']);
+ });
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator);
+ $this->assertSame('access-new', $newAuthenticator->getAccessToken());
+ $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt());
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters());
+ }
+
+ public function testTheRefreshAccessTokenMethodThrowsAnExceptionIfYouProvideItAnAuthenticatorThatIsNotRefreshable(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = new AccessTokenAuthenticator('access', null, Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The provided OAuthAuthenticator does not contain a refresh token.');
+
+ $connector->refreshAccessToken($authenticator);
+ }
+
+ public function testYouCanRequestTheOriginalResponseInsteadOfTheAuthenticatorOnTheRefreshTokensMethod(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $response = $connector->refreshAccessToken($authenticator, true);
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600], $response->json());
+ }
+
+ #[DataProvider('refreshEndpoints')]
+ public function testRefreshUsesTheConfiguredEndpoint(?string $endpoint, bool $allowOverride, string $expected): void
+ {
+ $mockClient = Saloon::fake([
+ GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest) use ($expected): MockResponse {
+ $this->assertSame($expected, (string) $pendingRequest->uri());
+ $this->assertSame([
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => 'refresh',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ ], $pendingRequest->body());
+ $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']);
+ $this->assertSame('per-call', $pendingRequest->headers()['X-Request']);
+
+ return MockResponse::make(['access_token' => 'renewed']);
+ },
+ ]);
+ $connector = new OAuth2Connector([
+ 'tokenEndpoint' => 'oauth/token',
+ 'refreshEndpoint' => $endpoint,
+ 'requestModifier' => fn (Request $request): Request => $request->withHeader('X-OAuth', 'configured'),
+ 'allowBaseUrlOverride' => $allowOverride,
+ ]);
+
+ $authenticator = $connector->refreshAccessToken(
+ 'refresh',
+ requestModifier: fn (Request $request): Request => $request->withHeader('X-Request', 'per-call'),
+ );
+
+ $this->assertSame('renewed', $authenticator->getAccessToken());
+ $this->assertSame('refresh', $authenticator->getRefreshToken());
+ $mockClient->assertSentCount(1);
+ }
+
+ /**
+ * Provide refresh endpoints and their resolved URLs.
+ *
+ * @return array
+ */
+ public static function refreshEndpoints(): array
+ {
+ return [
+ 'token endpoint default' => [null, false, 'https://oauth.saloon.dev/oauth/token'],
+ 'relative refresh endpoint' => ['oauth/refresh?audience=users', false, 'https://oauth.saloon.dev/oauth/refresh?audience=users'],
+ 'trusted absolute refresh endpoint' => ['https://oauth.example.net/refresh', true, 'https://oauth.example.net/refresh'],
+ ];
+ }
+
+ public function testAbsoluteRefreshEndpointRequiresOverridePermission(): void
+ {
+ $mockClient = Saloon::fake([MockResponse::make(['access_token' => 'renewed'])]);
+ $connector = new OAuth2Connector(['refreshEndpoint' => 'https://oauth.example.net/refresh']);
+
+ $this->expectException(PendingRequestException::class);
+ $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+
+ try {
+ $connector->refreshAccessToken('refresh');
+ } finally {
+ $mockClient->assertNothingSent();
+ }
+ }
+
+ // The user request has no body, so unlike upstream it sends no form Content-Type.
+ public function testYouCanGetTheUserFromAnOauthConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['user' => 'Sam']),
+ ]);
+
+ $connector = new OAuth2Connector;
+ Saloon::fake($mockClient);
+
+ $accessToken = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $response = $connector->getUser($accessToken);
+
+ $this->assertInstanceOf(Response::class, $response);
+
+ $pendingRequest = $response->pendingRequest();
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer access',
+ ], $pendingRequest->headers());
+ }
+
+ public function testYouCanTapIntoTheTheUserRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['user' => 'Sam']),
+ ]);
+
+ $connector = new OAuth2Connector;
+ Saloon::fake($mockClient);
+
+ $accessToken = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $response = $connector->getUser($accessToken, function (Request $request): void {
+ $request->withQueryParameters(['yee' => 'haw']);
+ });
+
+ $this->assertInstanceOf(Response::class, $response);
+
+ $pendingRequest = $response->pendingRequest();
+
+ $this->assertSame(['yee' => 'haw'], $pendingRequest->queryParameters());
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer access',
+ ], $pendingRequest->headers());
+ }
+
+ public function testYouCanCustomizeTheOauthAuthenticator(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ ]);
+
+ $customConnector = new CustomResponseOAuth2Connector('Howdy!');
+ Saloon::fake($mockClient);
+
+ $authenticator = $customConnector->getAccessToken('code');
+
+ $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator);
+ $this->assertSame('Howdy!', $authenticator->getGreeting());
+ }
+
+ public function testYouCanRegisterAGlobalRequestModifierThatIsCalledOnEveryStepOfTheOAuth2Process(): void
+ {
+ $mockClient = new MockClient([
+ GetAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ GetRefreshTokenRequest::class => MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ GetUserRequest::class => MockResponse::make(['user' => 'Sam']),
+ ]);
+
+ $requests = [];
+
+ $connector = new OAuth2Connector(['requestModifier' => function (Request $request) use (&$requests): void {
+ $requests[] = $request::class;
+
+ match ($request::class) {
+ GetAccessTokenRequest::class => $request->withQueryParameters(['request' => 'access']),
+ GetRefreshTokenRequest::class => $request->withQueryParameters(['request' => 'refresh']),
+ GetUserRequest::class => $request->withQueryParameters(['request' => 'user']),
+ };
+ }]);
+
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken('code');
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertSame('refresh', $authenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+ $this->assertSame(['request' => 'access'], $mockClient->lastPendingRequest()->queryParameters());
+
+ $newAuthenticator = $connector->refreshAccessToken($authenticator);
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator);
+ $this->assertSame('access-new', $newAuthenticator->getAccessToken());
+ $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken());
+ $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt());
+ $this->assertSame(['request' => 'refresh'], $mockClient->lastPendingRequest()->queryParameters());
+
+ $response = $connector->getUser($newAuthenticator);
+
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertSame(['request' => 'user'], $mockClient->lastPendingRequest()->queryParameters());
+
+ $pendingRequest = $response->pendingRequest();
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer access-new',
+ ], $pendingRequest->headers());
+
+ $this->assertSame([
+ GetAccessTokenRequest::class,
+ GetRefreshTokenRequest::class,
+ GetUserRequest::class,
+ ], $requests);
+ }
+
+ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutAClientIdItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new NoConfigAuthCodeConnector;
+ Saloon::fake($mockClient);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $connector->getAccessToken('code');
+ }
+
+ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutASecretItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new NoConfigAuthCodeConnector(['clientId' => 'hello']);
+ Saloon::fake($mockClient);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+
+ $connector->getAccessToken('code');
+ }
+
+ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutARedirectUriItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new NoConfigAuthCodeConnector(['clientId' => 'hello', 'clientSecret' => 'secret']);
+ Saloon::fake($mockClient);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Redirect URI is empty or has not been provided.');
+
+ $connector->getAccessToken('code');
+ }
+
+ public function testOnTheConnectorYouCanOverwriteAllTheRequestClasses(): void
+ {
+ $mockClient = new MockClient([
+ CustomAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200),
+ CustomRefreshTokenRequest::class => MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]),
+ CustomOAuthUserRequest::class => MockResponse::make(['user' => 'Sam']),
+ ]);
+
+ $connector = new CustomRequestOAuth2Connector;
+ Saloon::fake($mockClient);
+
+ $accessTokenResponse = $connector->getAccessToken('code', returnResponse: true);
+
+ $this->assertInstanceOf(CustomAccessTokenRequest::class, $accessTokenResponse->request());
+
+ $refreshTokenResponse = $connector->refreshAccessToken('howdy', returnResponse: true);
+
+ $this->assertInstanceOf(CustomRefreshTokenRequest::class, $refreshTokenResponse->request());
+
+ $userResponse = $connector->getUser(new AccessTokenAuthenticator('howdy', 'partner'));
+
+ $this->assertInstanceOf(CustomOAuthUserRequest::class, $userResponse->request());
+ }
+}
+
+class BaseQueryOAuth2Connector extends OAuth2Connector
+{
+ /**
+ * Define a base URL with its own query parameters.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://oauth.saloon.dev?client_id=base&base=value';
+ }
+}
diff --git a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
new file mode 100644
index 0000000000..8ebb695124
--- /dev/null
+++ b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
@@ -0,0 +1,380 @@
+ 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken();
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertNull($authenticator->getRefreshToken());
+ $this->assertFalse($authenticator->isRefreshable());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame([
+ 'grant_type' => 'client_credentials',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'scope' => '',
+ ], $mockClient->lastPendingRequest()->body());
+ }
+
+ public function testClientCredentialsTokensDeriveExpiryFromTheGlobalClock(): void
+ {
+ $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ Date::setTestNow($now);
+
+ $connector = new ClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken();
+
+ $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt());
+ }
+
+ public function testYouCanGetTheResponseInsteadOfTheAuthenticator(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $response = $connector->getAccessToken(returnResponse: true);
+
+ $this->assertInstanceOf(Response::class, $response);
+
+ $this->assertSame([
+ 'access_token' => 'access',
+ 'expires_in' => 3600,
+ ], $response->json());
+ }
+
+ public function testYouCanTapIntoTheTokenRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken(requestModifier: function (Request $request): void {
+ $request->withQueryParameters(['yee' => 'haw']);
+ });
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertNull($authenticator->getRefreshToken());
+ $this->assertFalse($authenticator->isRefreshable());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters());
+ }
+
+ public function testYouCanSendScopesWithTheTokenRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing']);
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame([
+ 'grant_type' => 'client_credentials',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'scope' => 'offline_access clients billing',
+ ], $mockClient->lastPendingRequest()->body());
+ }
+
+ public function testDefaultScopesOnTheOauthConfigWillBeMergedInWithTheScopesOnTheTokenRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector(['defaultScopes' => ['compliance']]);
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing']);
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame([
+ 'grant_type' => 'client_credentials',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'scope' => 'compliance offline_access clients billing',
+ ], $mockClient->lastPendingRequest()->body());
+ }
+
+ public function testTheScopeSeparatorCanBeCustomised(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsConnector(['defaultScopes' => ['compliance']]);
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing'], '+');
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame([
+ 'grant_type' => 'client_credentials',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'scope' => 'compliance+offline_access+clients+billing',
+ ], $mockClient->lastPendingRequest()->body());
+ }
+
+ #[DataProvider('scopeConnectors')]
+ public function testNullAndEmptyScopesAreIgnored(ClientCredentialsBasicAuthConnector|ClientCredentialsConnector $connector): void
+ {
+ $mockClient = Saloon::fake([
+ MockResponse::make(['access_token' => 'access']),
+ ]);
+
+ $connector->getAccessToken(['', 'clients', null, '0']);
+
+ $this->assertSame('clients 0', $mockClient->lastPendingRequest()->body()['scope']);
+ }
+
+ /**
+ * Get connectors with the default and Basic authentication token requests.
+ *
+ * @return array
+ */
+ public static function scopeConnectors(): array
+ {
+ return [
+ 'default request' => [new ClientCredentialsConnector],
+ 'basic auth request' => [new ClientCredentialsBasicAuthConnector],
+ ];
+ }
+
+ public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutAClientIdItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new NoConfigClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $connector->getAccessToken();
+ }
+
+ public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutASecretItWillThrowAnException(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new NoConfigClientCredentialsConnector(['clientId' => 'hello']);
+ Saloon::fake($mockClient);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+
+ $connector->getAccessToken();
+ }
+
+ public function testOnTheConnectorYouCanOverwriteTheGetAccessTokenRequest(): void
+ {
+ $mockClient = new MockClient([
+ CustomClientCredentialsAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new CustomRequestClientCredentialsConnector;
+ Saloon::fake($mockClient);
+
+ $accessTokenResponse = $connector->getAccessToken(returnResponse: true);
+
+ $this->assertInstanceOf(CustomClientCredentialsAccessTokenRequest::class, $accessTokenResponse->request());
+ }
+
+ public function testTheClientCredentialsGrantCanUseBasicAuth(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200),
+ ]);
+
+ $connector = new ClientCredentialsBasicAuthConnector;
+ Saloon::fake($mockClient);
+
+ $authenticator = $connector->getAccessToken();
+
+ $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator);
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertNull($authenticator->getRefreshToken());
+ $this->assertFalse($authenticator->isRefreshable());
+ $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt());
+
+ $mockClient->assertSentCount(1);
+
+ $this->assertSame([
+ 'grant_type' => 'client_credentials',
+ 'scope' => '',
+ ], $mockClient->lastPendingRequest()->body());
+
+ $this->assertSame(
+ 'Basic ' . base64_encode('client-id:client-secret'),
+ $mockClient->lastPendingRequest()->headers()['Authorization'],
+ );
+ }
+
+ public function testAReturnedRefreshTokenIsIgnored(): void
+ {
+ Saloon::fake([MockResponse::make(['access_token' => 'access', 'refresh_token' => []])]);
+
+ $authenticator = (new ClientCredentialsConnector)->getAccessToken();
+
+ $this->assertNull($authenticator->getRefreshToken());
+ }
+
+ public function testACustomAuthenticatorReceivesTheExpiry(): void
+ {
+ Date::setTestNow('2026-01-01T00:00:00+00:00');
+ Saloon::fake([MockResponse::make(['access_token' => 'access', 'expires_in' => 3600])]);
+
+ $authenticator = (new CustomAuthenticatorClientCredentialsConnector)->getAccessToken();
+
+ $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator);
+ $this->assertEquals(new DateTimeImmutable('2026-01-01T01:00:00+00:00'), $authenticator->getExpiresAt());
+ }
+
+ public function testTokenResponseValidationRejectsInvalidValues(): void
+ {
+ Date::setTestNow('2026-08-10 12:00:00');
+
+ foreach ([
+ [],
+ ['access_token' => ''],
+ ['access_token' => 123],
+ ['access_token' => 'token', 'expires_in' => -1],
+ ['access_token' => 'token', 'expires_in' => '1.5'],
+ ['access_token' => 'token', 'expires_in' => 1.0E+30],
+ ['access_token' => 'token', 'expires_in' => PHP_INT_MAX],
+ ] as $body) {
+ Saloon::fake([MockResponse::make($body)]);
+
+ try {
+ (new ClientCredentialsConnector)->getAccessToken();
+ $this->fail('An invalid OAuth token response was accepted.');
+ } catch (UnexpectedValueException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+ }
+
+ #[DataProvider('validExpiries')]
+ public function testTokenExpiryAcceptsWholeNumbersOfSecondsUpToTheRepresentableBoundary(int|float|string $expiresIn, int $seconds): void
+ {
+ Date::setTestNow('2026-08-10 12:00:00');
+ Saloon::fake([MockResponse::make(['access_token' => 'token', 'expires_in' => $expiresIn])]);
+
+ $authenticator = (new ClientCredentialsConnector)->getAccessToken();
+
+ $this->assertSame(Date::now()->getTimestamp() + $seconds, $authenticator->getExpiresAt()?->getTimestamp());
+ $this->assertTrue($authenticator->hasNotExpired());
+ }
+
+ /**
+ * Get valid expiry durations and the seconds they represent.
+ *
+ * @return array
+ */
+ public static function validExpiries(): array
+ {
+ // The test clock is fixed at this timestamp, so the boundary is the largest duration that does not overflow.
+ $boundary = PHP_INT_MAX - 1786363200;
+
+ return [
+ 'integer' => [3600, 3600],
+ 'integer string' => ['3600', 3600],
+ 'whole float' => [3600.0, 3600],
+ 'representable boundary' => [$boundary, $boundary],
+ ];
+ }
+}
+
+class CustomAuthenticatorClientCredentialsConnector extends ClientCredentialsConnector
+{
+ /**
+ * Create the OAuth authenticator with upstream's client-credentials signature.
+ */
+ protected function createOAuthAuthenticator(string $accessToken, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator
+ {
+ return new CustomOAuthAuthenticator($accessToken, 'Howdy!', null, $expiresAt);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php b/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php
new file mode 100644
index 0000000000..fc3ef671cf
--- /dev/null
+++ b/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php
@@ -0,0 +1,30 @@
+greeting;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php b/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php
new file mode 100644
index 0000000000..75888df3f9
--- /dev/null
+++ b/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php
@@ -0,0 +1,30 @@
+replaceHeaders(['X-Pizza' => $this->pizza, 'X-Drink' => $this->drink]);
+
+ $pendingRequest->withOptions(['debug' => true]);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php b/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php
new file mode 100644
index 0000000000..1db81b104a
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php
@@ -0,0 +1,35 @@
+ $config
+ */
+ public function __construct(protected array $config = [])
+ {
+ }
+
+ /**
+ * Define the base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://oauth.saloon.dev';
+ }
+
+ /**
+ * Define default Oauth config.
+ */
+ protected function defaultOauthConfig(): OAuthConfig
+ {
+ return new OAuthConfig(...[
+ 'clientId' => 'client-id',
+ 'clientSecret' => 'client-secret',
+ ...$this->config,
+ ]);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php b/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php
new file mode 100644
index 0000000000..3b54602abe
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php
@@ -0,0 +1,22 @@
+ $scopes
+ */
+ protected function resolveAccessTokenRequest(OAuthConfig $oauthConfig, array $scopes = [], string $scopeSeparator = ' '): Request
+ {
+ return new CustomClientCredentialsAccessTokenRequest($oauthConfig, $scopes, $scopeSeparator);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php b/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php
new file mode 100644
index 0000000000..439b134be5
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php
@@ -0,0 +1,62 @@
+greeting, $refreshToken, $expiresAt);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php b/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php
new file mode 100644
index 0000000000..e1dfb30a59
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php
@@ -0,0 +1,41 @@
+ $config
+ */
+ public function __construct(protected array $config = [])
+ {
+ }
+
+ /**
+ * Define the base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://oauth.saloon.dev';
+ }
+
+ /**
+ * Define an OAuth configuration that is empty unless arguments are given.
+ */
+ protected function defaultOAuthConfig(): OAuthConfig
+ {
+ return new OAuthConfig(...['clientId' => '', 'clientSecret' => '', ...$this->config]);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php b/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php
new file mode 100644
index 0000000000..45d23766e5
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php
@@ -0,0 +1,39 @@
+ $config
+ */
+ public function __construct(protected array $config = [])
+ {
+ }
+
+ /**
+ * Define the base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://oauth.saloon.dev';
+ }
+
+ /**
+ * Define an OAuth configuration that is empty unless arguments are given.
+ */
+ protected function defaultOAuthConfig(): OAuthConfig
+ {
+ return new OAuthConfig(...['clientId' => '', 'clientSecret' => '', ...$this->config]);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php b/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php
new file mode 100644
index 0000000000..89120d8696
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php
@@ -0,0 +1,44 @@
+ $config
+ */
+ public function __construct(protected array $config = [])
+ {
+ }
+
+ /**
+ * Define the base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://oauth.saloon.dev';
+ }
+
+ /**
+ * Define default Oauth config.
+ */
+ protected function defaultOauthConfig(): OAuthConfig
+ {
+ return new OAuthConfig(...[
+ 'clientId' => 'client-id',
+ 'clientSecret' => 'client-secret',
+ 'redirectUri' => 'https://my-app.saloon.dev/auth/callback',
+ ...$this->config,
+ ]);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Plugins/AuthenticatorPlugin.php b/tests/Saloon/Fixtures/Plugins/AuthenticatorPlugin.php
new file mode 100644
index 0000000000..31ac6236d6
--- /dev/null
+++ b/tests/Saloon/Fixtures/Plugins/AuthenticatorPlugin.php
@@ -0,0 +1,18 @@
+withToken('plugin-auth');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/AuthenticatorPluginRequest.php b/tests/Saloon/Fixtures/Requests/AuthenticatorPluginRequest.php
new file mode 100644
index 0000000000..12ecd8a51a
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/AuthenticatorPluginRequest.php
@@ -0,0 +1,34 @@
+withToken('howdy-partner');
+ }
+}
diff --git a/tests/Saloon/Fixtures/Requests/DefaultAuthenticatorRequest.php b/tests/Saloon/Fixtures/Requests/DefaultAuthenticatorRequest.php
new file mode 100644
index 0000000000..286e11eccf
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/DefaultAuthenticatorRequest.php
@@ -0,0 +1,44 @@
+authenticate(new CookieAuthenticator('session', 'secret', $domain)),
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
-
- $pendingRequest->applyAuthentication();
-
- $this->assertCount(1, $pendingRequest->cookies());
- $cookie = $pendingRequest->cookies()[0];
- $this->assertSame('session', $cookie['Name']);
- $this->assertSame('secret', $cookie['Value']);
- $this->assertSame($expected, ltrim($cookie['Domain'], '.'));
- $this->assertSame($domain === null, $cookie['HostOnly'] ?? false);
- $this->assertSame($scheme === 'https', $cookie['Secure']);
- $this->assertTrue($cookie['Discard']);
- $this->assertTrue((new SetCookie($cookie))->matchesDomain('api.example.com'));
- $this->assertSame($domain !== null, (new SetCookie($cookie))->matchesDomain('other.example.com'));
- }
-
- /**
- * Provide inferred and explicitly scoped cookie domains.
- */
- public static function cookieDomains(): array
- {
- return [
- 'inferred HTTPS' => [null, 'api.example.com', 'https'],
- 'explicit HTTPS' => ['.example.com', 'example.com', 'https'],
- 'inferred HTTP' => [null, 'api.example.com', 'http'],
- 'explicit HTTP' => ['.example.com', 'example.com', 'http'],
- ];
- }
-
- public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void
- {
- $http = new Factory;
- $http->registerConnection('saloon');
- $cookies = [];
- $http->fake(function (HttpRequest $request, array $options) use (&$cookies) {
- $cookies[] = $options['cookies']->toArray();
-
- return Factory::response('', count($cookies) === 1 ? 500 : 200);
- });
- $config = m::mock(ConfigRepository::class);
- $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
- $manager = new SaloonManager(
- new Sender($http, $config),
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- $config,
- new Dispatcher,
- );
- $request = (new CookieAuthRequestStub)
- ->authenticate(new CookieAuthenticator('session', 'original'))
- ->authenticate(new CookieAuthenticator('session', 'request'))
- ->retry(2);
- $pendingCookies = [];
- $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void {
- $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement'));
- $pendingCookies[] = $pendingRequest->cookies();
- });
-
- $response = $manager->send(new CookieAuthConnectorStub, $request);
-
- $this->assertSame(200, $response->status());
- $this->assertCount(2, $cookies);
- foreach ($cookies as $attemptCookies) {
- $this->assertCount(1, $attemptCookies);
- $this->assertSame('session', $attemptCookies[0]['Name']);
- $this->assertSame('replacement', $attemptCookies[0]['Value']);
- $this->assertSame('api.example.com', $attemptCookies[0]['Domain']);
- $this->assertTrue($attemptCookies[0]['HostOnly']);
- $this->assertTrue($attemptCookies[0]['Secure']);
- $this->assertTrue($attemptCookies[0]['Discard']);
- }
- $this->assertSame($pendingCookies[0], $pendingCookies[1]);
- $this->assertCount(2, $pendingCookies[0]);
- $this->assertSame([], $request->cookies());
- }
-}
-
-class CookieAuthConnectorStub extends Connector
-{
- /**
- * Set the API base URL.
- */
- public function __construct(protected string $baseUrl = 'https://api.example.com')
- {
- }
-
- /**
- * Resolve the API base URL.
- */
- public function resolveBaseUrl(): string
- {
- return $this->baseUrl;
- }
-}
-
-class CookieAuthRequestStub extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-}
diff --git a/tests/Saloon/OAuth2/OAuthGrantTest.php b/tests/Saloon/OAuth2/OAuthGrantTest.php
deleted file mode 100644
index 4eca2a366c..0000000000
--- a/tests/Saloon/OAuth2/OAuthGrantTest.php
+++ /dev/null
@@ -1,436 +0,0 @@
-manager(), $this->config());
-
- $authorization = $connector->authorizationUrl(
- scopes: ['profile'],
- state: 'known-state',
- additionalQueryParameters: [
- 'client_id' => 'attacker',
- 'state' => 'attacker-state',
- 'prompt' => '',
- 'include_granted_scopes' => false,
- ],
- codeChallenge: 'challenge',
- );
-
- parse_str(parse_url((string) $authorization, PHP_URL_QUERY) ?: '', $query);
-
- $this->assertSame('known-state', $authorization->state);
- $this->assertSame('client', $query['client_id']);
- $this->assertSame('known-state', $query['state']);
- $this->assertSame('openid profile', $query['scope']);
- $this->assertSame('', $query['prompt']);
- $this->assertSame('0', $query['include_granted_scopes']);
- $this->assertSame('challenge', $query['code_challenge']);
- $this->assertSame('S256', $query['code_challenge_method']);
- }
-
- public function testAuthorizationUrlPreservesEndpointQueryAndGeneratesState(): void
- {
- $config = new OAuthConfig(
- clientId: 'client',
- clientSecret: 'secret',
- redirectUri: 'https://app.example.com/callback',
- authorizeEndpoint: 'oauth/authorize?audience=users',
- );
-
- $authorization = (new AuthorizationCodeConnectorStub($this->manager(), $config))->authorizationUrl();
-
- $this->assertNotSame('', $authorization->state);
- $this->assertStringContainsString('audience=users&response_type=code', (string) $authorization);
- $this->assertStringContainsString('state=' . $authorization->state, (string) $authorization);
- }
-
- public function testAuthorizationUrlReplacesProtocolParametersFromBaseAndEndpointQueries(): void
- {
- $config = new OAuthConfig(
- clientId: 'client',
- clientSecret: 'secret',
- redirectUri: 'https://app.example.com/callback',
- authorizeEndpoint: 'oauth/authorize?client_id=endpoint&audience=users',
- );
- $authorization = (new BaseQueryAuthorizationCodeConnectorStub($this->manager(), $config))
- ->authorizationUrl(state: 'known-state');
- $queryString = parse_url((string) $authorization, PHP_URL_QUERY) ?: '';
- parse_str($queryString, $query);
-
- $this->assertSame(1, substr_count($queryString, 'client_id='));
- $this->assertSame('client', $query['client_id']);
- $this->assertSame('value', $query['base']);
- $this->assertSame('users', $query['audience']);
- }
-
- public function testStateValidationRequiresACompleteMatchingPair(): void
- {
- $connector = new AuthorizationCodeConnectorStub($this->manager(), $this->config());
-
- foreach ([
- ['returned', null],
- [null, 'expected'],
- ['', 'expected'],
- ['returned', ''],
- ['returned', 'expected'],
- ] as [$returnedState, $expectedState]) {
- try {
- $connector->getAccessToken('code', $returnedState, $expectedState);
- $this->fail('Invalid state was accepted.');
- } catch (InvalidStateException) {
- $this->addToAssertionCount(1);
- }
- }
- }
-
- public function testAuthorizationCodeGrantSendsTokenRefreshAndUserRequests(): void
- {
- Date::setTestNow('2026-08-10 12:00:00');
-
- try {
- $manager = $this->manager();
- $mockClient = $manager->fake([
- GetAccessTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse {
- $this->assertSame('code', $pendingRequest->body()['code']);
- $this->assertSame('verifier', $pendingRequest->body()['code_verifier']);
- $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']);
- $this->assertSame('per-call', $pendingRequest->headers()['X-Request']);
-
- return MockResponse::make([
- 'access_token' => 'access',
- 'refresh_token' => 'refresh',
- 'expires_in' => '3600',
- ]);
- },
- GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse {
- $this->assertSame('refresh', $pendingRequest->body()['refresh_token']);
-
- return MockResponse::make(['access_token' => 'renewed']);
- },
- GetUserRequest::class => function (PendingRequest $pendingRequest): MockResponse {
- $this->assertSame('Bearer renewed', $pendingRequest->headers()['Authorization']);
-
- return MockResponse::make(['id' => 1]);
- },
- ]);
- $connector = new AuthorizationCodeConnectorStub($manager, $this->config(
- requestModifier: fn (Request $request) => $request->withHeader('X-OAuth', 'configured'),
- ));
-
- $authenticator = $connector->getAccessToken(
- code: 'code',
- state: 'state',
- expectedState: 'state',
- requestModifier: fn (Request $request) => $request->withHeader('X-Request', 'per-call'),
- codeVerifier: 'verifier',
- );
- $renewed = $connector->refreshAccessToken($authenticator);
- $user = $connector->getUser($renewed);
-
- $this->assertSame('access', $authenticator->getAccessToken());
- $this->assertSame('refresh', $authenticator->getRefreshToken());
- $this->assertSame(Date::now()->addHour()->getTimestamp(), $authenticator->getExpiresAt()?->getTimestamp());
- $this->assertSame('renewed', $renewed->getAccessToken());
- $this->assertSame('refresh', $renewed->getRefreshToken());
- $this->assertSame(['id' => 1], $user->json());
- $mockClient->assertSentCount(3);
- } finally {
- Date::setTestNow();
- }
- }
-
- #[DataProvider('refreshEndpoints')]
- public function testRefreshUsesTheConfiguredEndpoint(?string $endpoint, bool $allowOverride, string $expected): void
- {
- $manager = $this->manager();
- $mockClient = $manager->fake([
- GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest) use ($expected): MockResponse {
- $this->assertSame($expected, (string) $pendingRequest->uri());
- $this->assertSame([
- 'grant_type' => 'refresh_token',
- 'refresh_token' => 'refresh',
- 'client_id' => 'client',
- 'client_secret' => 'secret',
- ], $pendingRequest->body());
- $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']);
- $this->assertSame('per-call', $pendingRequest->headers()['X-Request']);
-
- return MockResponse::make(['access_token' => 'renewed']);
- },
- ]);
- $config = new OAuthConfig(
- clientId: 'client',
- clientSecret: 'secret',
- redirectUri: 'https://app.example.com/callback',
- tokenEndpoint: 'oauth/token',
- refreshEndpoint: $endpoint,
- requestModifier: fn (Request $request) => $request->withHeader('X-OAuth', 'configured'),
- allowBaseUrlOverride: $allowOverride,
- );
-
- $authenticator = (new AuthorizationCodeConnectorStub($manager, $config))->refreshAccessToken(
- 'refresh',
- requestModifier: fn (Request $request) => $request->withHeader('X-Request', 'per-call'),
- );
-
- $this->assertSame('renewed', $authenticator->getAccessToken());
- $this->assertSame('refresh', $authenticator->getRefreshToken());
- $mockClient->assertSentCount(1);
- }
-
- /**
- * Provide refresh endpoints and their resolved URLs.
- */
- public static function refreshEndpoints(): array
- {
- return [
- 'token endpoint default' => [null, false, 'https://provider.example.com/oauth/token'],
- 'relative refresh endpoint' => ['oauth/refresh?audience=users', false, 'https://provider.example.com/oauth/refresh?audience=users'],
- 'trusted absolute refresh endpoint' => ['https://oauth.example.net/refresh', true, 'https://oauth.example.net/refresh'],
- ];
- }
-
- public function testAbsoluteRefreshEndpointRequiresOverridePermission(): void
- {
- $manager = $this->manager();
- $mockClient = $manager->fake([MockResponse::make(['access_token' => 'renewed'])]);
- $connector = new AuthorizationCodeConnectorStub($manager, new OAuthConfig(
- clientId: 'client',
- clientSecret: 'secret',
- redirectUri: 'https://app.example.com/callback',
- refreshEndpoint: 'https://oauth.example.net/refresh',
- ));
-
- $this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
-
- try {
- $connector->refreshAccessToken('refresh');
- } finally {
- $mockClient->assertNothingSent();
- }
- }
-
- public function testReturningTheRawTokenResponseSkipsAuthenticatorValidation(): void
- {
- $manager = $this->manager();
- $manager->fake([MockResponse::make(['provider_specific' => true])]);
- $connector = new AuthorizationCodeConnectorStub($manager, $this->config());
-
- $response = $connector->getAccessToken('code', returnResponse: true);
-
- $this->assertSame(['provider_specific' => true], $response->json());
- }
-
- public function testClientCredentialsGrantsUseTheirExpectedAuthenticationShape(): void
- {
- $manager = $this->manager();
- $mockClient = $manager->fake([
- GetClientCredentialsTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse {
- $this->assertSame('client', $pendingRequest->body()['client_id']);
- $this->assertNull($pendingRequest->transportAuthentication());
-
- return MockResponse::make(['access_token' => 'body-token']);
- },
- GetClientCredentialsTokenBasicAuthRequest::class => function (PendingRequest $pendingRequest): MockResponse {
- $this->assertArrayNotHasKey('client_id', $pendingRequest->body());
- $this->assertSame(['client', 'secret'], $pendingRequest->transportAuthentication());
-
- return MockResponse::make(['access_token' => 'basic-token']);
- },
- ]);
-
- $bodyAuthenticator = (new ClientCredentialsConnectorStub($manager, $this->config()))
- ->getAccessToken(['profile']);
- $basicAuthenticator = (new ClientCredentialsBasicAuthConnectorStub($manager, $this->config()))
- ->getAccessToken(['profile']);
-
- $this->assertSame('body-token', $bodyAuthenticator->getAccessToken());
- $this->assertSame('basic-token', $basicAuthenticator->getAccessToken());
- $mockClient->assertSentCount(2);
- }
-
- public function testTokenResponseValidationRejectsInvalidValues(): void
- {
- Date::setTestNow('2026-08-10 12:00:00');
-
- try {
- foreach ([
- [],
- ['access_token' => ''],
- ['access_token' => 123],
- ['access_token' => 'token', 'refresh_token' => []],
- ['access_token' => 'token', 'expires_in' => -1],
- ['access_token' => 'token', 'expires_in' => '1.5'],
- ['access_token' => 'token', 'expires_in' => 1.0E+30],
- ['access_token' => 'token', 'expires_in' => PHP_INT_MAX],
- ] as $body) {
- $manager = $this->manager();
- $manager->fake([MockResponse::make($body)]);
-
- try {
- (new ClientCredentialsConnectorStub($manager, $this->config()))->getAccessToken();
- $this->fail('An invalid OAuth token response was accepted.');
- } catch (UnexpectedValueException) {
- $this->addToAssertionCount(1);
- }
- }
- } finally {
- Date::setTestNow();
- }
- }
-
- public function testTokenExpiryAcceptsTheExactRepresentableBoundary(): void
- {
- Date::setTestNow('2026-08-10 12:00:00');
-
- try {
- $expiresIn = PHP_INT_MAX - Date::now()->getTimestamp();
- $manager = $this->manager();
- $manager->fake([MockResponse::make([
- 'access_token' => 'token',
- 'expires_in' => $expiresIn,
- ])]);
-
- $authenticator = (new ClientCredentialsConnectorStub($manager, $this->config()))->getAccessToken();
-
- $this->assertSame(PHP_INT_MAX, $authenticator->getExpiresAt()?->getTimestamp());
- $this->assertTrue($authenticator->hasNotExpired());
- } finally {
- Date::setTestNow();
- }
- }
-
- public function testInvalidPkceConfigurationIsRejectedBeforeSending(): void
- {
- $connector = new AuthorizationCodeConnectorStub($this->manager(), $this->config());
-
- foreach ([['', 'S256'], ['challenge', 'unsupported']] as [$challenge, $method]) {
- try {
- $connector->authorizationUrl(codeChallenge: $challenge, codeChallengeMethod: $method);
- $this->fail('An invalid PKCE configuration was accepted.');
- } catch (InvalidArgumentException) {
- $this->addToAssertionCount(1);
- }
- }
- }
-
- /**
- * Create an OAuth configuration.
- */
- protected function config(?callable $requestModifier = null): OAuthConfig
- {
- return new OAuthConfig(
- clientId: 'client',
- clientSecret: 'secret',
- redirectUri: 'https://app.example.com/callback',
- defaultScopes: ['openid'],
- requestModifier: $requestModifier,
- );
- }
-
- /**
- * Create the manager with isolated framework services.
- */
- protected function manager(): SaloonManager
- {
- $http = new Factory;
- $http->registerConnection('saloon');
- $config = m::mock(ConfigRepository::class);
- $config->shouldReceive('string')
- ->with('saloon.connection.name')
- ->andReturn('saloon');
-
- return new SaloonManager(
- new Sender($http, $config),
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- $config,
- new Dispatcher,
- );
- }
-}
-
-abstract class OAuthConnectorStub extends Connector
-{
- public function __construct(
- protected SaloonManager $manager,
- protected readonly OAuthConfig $config,
- ) {
- }
-
- public function resolveBaseUrl(): string
- {
- return 'https://provider.example.com';
- }
-
- public function send(Request $request, ?MockClient $mockClient = null): Response
- {
- return $this->manager->send($this, $request, $mockClient);
- }
-
- protected function defaultOAuthConfig(): OAuthConfig
- {
- return $this->config;
- }
-}
-
-class AuthorizationCodeConnectorStub extends OAuthConnectorStub
-{
- use AuthorizationCodeGrant;
-}
-
-class BaseQueryAuthorizationCodeConnectorStub extends AuthorizationCodeConnectorStub
-{
- public function resolveBaseUrl(): string
- {
- return 'https://provider.example.com?client_id=base&base=value';
- }
-}
-
-class ClientCredentialsConnectorStub extends OAuthConnectorStub
-{
- use ClientCredentialsGrant;
-}
-
-class ClientCredentialsBasicAuthConnectorStub extends OAuthConnectorStub
-{
- use ClientCredentialsBasicAuthGrant;
-}
diff --git a/tests/Saloon/SensitiveParameterTest.php b/tests/Saloon/SensitiveParameterTest.php
index c47ee68768..4b45f9c42f 100644
--- a/tests/Saloon/SensitiveParameterTest.php
+++ b/tests/Saloon/SensitiveParameterTest.php
@@ -6,7 +6,8 @@
use Hypervel\Saloon\Http\Auth\CookieAuthenticator;
use Hypervel\Saloon\Traits\OAuth2\AuthorizationCodeGrant;
-use Hypervel\Saloon\Traits\OAuth2\CreatesOAuthAuthenticator;
+use Hypervel\Saloon\Traits\OAuth2\ClientCredentialsGrant;
+use Hypervel\Saloon\Traits\OAuth2\ParsesOAuthTokenResponses;
use Hypervel\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
@@ -42,10 +43,13 @@ public static function sensitiveParameters(): array
{
return [
[CookieAuthenticator::class, '__construct', 'value'],
- [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'response'],
- [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'fallbackRefreshToken'],
- [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticator', 'accessToken'],
- [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticator', 'refreshToken'],
+ [ParsesOAuthTokenResponses::class, 'parseOAuthTokenResponse', 'response'],
+ [AuthorizationCodeGrant::class, 'createOAuthAuthenticatorFromResponse', 'response'],
+ [AuthorizationCodeGrant::class, 'createOAuthAuthenticatorFromResponse', 'fallbackRefreshToken'],
+ [AuthorizationCodeGrant::class, 'createOAuthAuthenticator', 'accessToken'],
+ [AuthorizationCodeGrant::class, 'createOAuthAuthenticator', 'refreshToken'],
+ [ClientCredentialsGrant::class, 'createOAuthAuthenticatorFromResponse', 'response'],
+ [ClientCredentialsGrant::class, 'createOAuthAuthenticator', 'accessToken'],
[AuthorizationCodeGrant::class, 'validateState', 'state'],
[AuthorizationCodeGrant::class, 'validateState', 'expectedState'],
];
diff --git a/tests/Saloon/Unit/AuthenticatesRequestsTest.php b/tests/Saloon/Unit/AuthenticatesRequestsTest.php
new file mode 100644
index 0000000000..2c502e7203
--- /dev/null
+++ b/tests/Saloon/Unit/AuthenticatesRequestsTest.php
@@ -0,0 +1,243 @@
+withBasicAuth('Sammyjo20', 'Cowboy1');
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame('Basic ' . base64_encode('Sammyjo20:Cowboy1'), $headers['Authorization']);
+ }
+
+ public function testYouCanAttachAnAuthorizationTokenToARequest(): void
+ {
+ $request = UserRequest::make()->withToken('Sammyjo20');
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame('Bearer Sammyjo20', $headers['Authorization']);
+ }
+
+ // Upstream's digest-type argument and its array-sender case are not included: digest is the only supported type,
+ // and every request uses the HTTP client's Guzzle transport.
+ public function testYouCanAddDigestAuthToARequest(): void
+ {
+ $request = new UserRequest;
+ $request->withDigestAuth('Sammyjo20', 'Cowboy1');
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $this->assertSame(['Sammyjo20', 'Cowboy1', 'digest'], $pendingRequest->transportAuthentication());
+ }
+
+ public function testYouCanAddATokenToAQueryParameter(): void
+ {
+ $request = UserRequest::make()->authenticate(new QueryAuthenticator('token', 'Sammyjo20'));
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+ $query = $pendingRequest->queryParameters();
+
+ $this->assertSame('Sammyjo20', $query['token']);
+ }
+
+ public function testYouCanAddAHeaderToARequest(): void
+ {
+ $request = UserRequest::make()->authenticate(new HeaderAuthenticator('Sammyjo20', 'X-Authorization'));
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame('Sammyjo20', $headers['X-Authorization']);
+ }
+
+ public function testYouCanAddACertificateToARequest(): void
+ {
+ $certPath = __DIR__ . '/certificate.cer';
+
+ $requestA = UserRequest::make()->authenticate(new CertificateAuthenticator($certPath));
+
+ $pendingRequestA = (new TestConnector)->createPendingRequest($requestA);
+
+ $this->assertSame($certPath, $pendingRequestA->certificate());
+
+ // Test with password
+
+ $requestB = UserRequest::make()->authenticate(new CertificateAuthenticator($certPath, 'example'));
+
+ $pendingRequestB = (new TestConnector)->createPendingRequest($requestB);
+
+ $this->assertSame([$certPath, 'example'], $pendingRequestB->certificate());
+ }
+
+ public function testYouCanUseMultipleAuthenticatorsAtTheSameTimeUsingTheDefaultAuthMethod(): void
+ {
+ $request = UserRequest::make()->authenticate(new MultiAuthenticator(
+ new TokenAuthenticator('example'),
+ new HeaderAuthenticator('api-key', 'X-API-Key'),
+ ));
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer example',
+ 'X-API-Key' => 'api-key',
+ ], $headers);
+ }
+
+ public function testYouCanUseANullAuthenticatorToDisableDefaultAuthenticationEntirely(): void
+ {
+ $connector = new DefaultAuthenticatorConnector;
+ $request = new UserRequest;
+
+ $request->authenticate(new NullAuthenticator);
+
+ $pendingRequest = $connector->createPendingRequest($request);
+
+ $this->assertSame(['Accept' => 'application/json'], $pendingRequest->headers());
+ }
+
+ #[DataProvider('authorizationHeaderOrders')]
+ public function testTheLastAuthorizationHeaderReachesTheTransport(Authenticator $first, Authenticator $last, string $expected): void
+ {
+ $sent = [];
+ Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface {
+ $sent[] = $request->header('Authorization');
+
+ return Factory::response();
+ });
+
+ (new TestConnector)->send(UserRequest::make()->authenticate(new MultiAuthenticator($first, $last)));
+
+ $this->assertSame([[$expected]], $sent);
+ }
+
+ /**
+ * Get authenticator pairs and the Authorization header the last one sets.
+ *
+ * @return array
+ */
+ public static function authorizationHeaderOrders(): array
+ {
+ $basic = 'Basic ' . base64_encode('Sammyjo20:Cowboy1');
+
+ return [
+ 'basic then token' => [new BasicAuthenticator('Sammyjo20', 'Cowboy1'), new TokenAuthenticator('example'), 'Bearer example'],
+ 'token then basic' => [new TokenAuthenticator('example'), new BasicAuthenticator('Sammyjo20', 'Cowboy1'), $basic],
+ ];
+ }
+
+ #[DataProvider('cookieDomains')]
+ public function testCookieDomainIsInferredOrExplicit(?string $domain, string $expected, string $scheme): void
+ {
+ $pendingRequest = (new TestConnector($scheme . '://api.example.com'))->createPendingRequest(
+ UserRequest::make()->authenticate(new CookieAuthenticator('session', 'secret', $domain)),
+ );
+
+ $this->assertCount(1, $pendingRequest->cookies());
+ $cookie = $pendingRequest->cookies()[0];
+ $this->assertSame('session', $cookie['Name']);
+ $this->assertSame('secret', $cookie['Value']);
+ $this->assertSame($expected, ltrim($cookie['Domain'], '.'));
+ $this->assertSame($domain === null, $cookie['HostOnly'] ?? false);
+ $this->assertSame($scheme === 'https', $cookie['Secure']);
+ $this->assertTrue($cookie['Discard']);
+ $this->assertTrue((new SetCookie($cookie))->matchesDomain('api.example.com'));
+ $this->assertSame($domain !== null, (new SetCookie($cookie))->matchesDomain('other.example.com'));
+ }
+
+ /**
+ * Provide inferred and explicitly scoped cookie domains.
+ *
+ * @return array
+ */
+ public static function cookieDomains(): array
+ {
+ return [
+ 'inferred HTTPS' => [null, 'api.example.com', 'https'],
+ 'explicit HTTPS' => ['.example.com', 'example.com', 'https'],
+ 'inferred HTTP' => [null, 'api.example.com', 'http'],
+ 'explicit HTTP' => ['.example.com', 'example.com', 'http'],
+ ];
+ }
+
+ public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void
+ {
+ $cookies = [];
+ Http::fake(function (HttpRequest $request, array $options) use (&$cookies): PromiseInterface {
+ $cookies[] = $options['cookies']->toArray();
+
+ return Factory::response('', count($cookies) === 1 ? 500 : 200);
+ });
+ $request = UserRequest::make()
+ ->authenticate(new CookieAuthenticator('session', 'original'))
+ ->authenticate(new CookieAuthenticator('session', 'request'))
+ ->retry(2);
+ $pendingCookies = [];
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void {
+ $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement'));
+ $pendingCookies[] = $pendingRequest->cookies();
+ });
+
+ $response = (new TestConnector('https://api.example.com'))->send($request);
+
+ $this->assertSame(200, $response->status());
+ $this->assertCount(2, $cookies);
+ foreach ($cookies as $attemptCookies) {
+ $this->assertCount(1, $attemptCookies);
+ $this->assertSame('session', $attemptCookies[0]['Name']);
+ $this->assertSame('replacement', $attemptCookies[0]['Value']);
+ $this->assertSame('api.example.com', $attemptCookies[0]['Domain']);
+ $this->assertTrue($attemptCookies[0]['HostOnly']);
+ $this->assertTrue($attemptCookies[0]['Secure']);
+ $this->assertTrue($attemptCookies[0]['Discard']);
+ }
+ $this->assertSame($pendingCookies[0], $pendingCookies[1]);
+ $this->assertCount(2, $pendingCookies[0]);
+ $this->assertSame([], $request->cookies());
+ }
+}
diff --git a/tests/Saloon/Unit/AuthenticatorTest.php b/tests/Saloon/Unit/AuthenticatorTest.php
new file mode 100644
index 0000000000..9af914b61e
--- /dev/null
+++ b/tests/Saloon/Unit/AuthenticatorTest.php
@@ -0,0 +1,187 @@
+createPendingRequest($request);
+
+ $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanProvideADefaultAuthenticatorOnTheConnector(): void
+ {
+ $request = new UserRequest;
+ $connector = new DefaultAuthenticatorConnector;
+
+ $pendingRequest = $connector->createPendingRequest($request);
+
+ $this->assertSame('Bearer yee-haw-connector', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanProvideADefaultAuthenticatorOnTheRequestAndItTakesPriorityOverTheConnector(): void
+ {
+ $request = new DefaultAuthenticatorRequest;
+ $connector = new DefaultAuthenticatorConnector;
+
+ $pendingRequest = $connector->createPendingRequest($request);
+
+ $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanProvideAnAuthenticatorOnTheFlyAndItWillTakePriorityOverAllDefaults(): void
+ {
+ $request = new DefaultAuthenticatorRequest;
+ $connector = new DefaultAuthenticatorConnector;
+
+ $request->withToken('yee-haw-on-the-fly', 'PewPew');
+
+ $pendingRequest = $connector->createPendingRequest($request);
+
+ $this->assertSame('PewPew yee-haw-on-the-fly', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testTheRequiresAuthTraitWillThrowAnExceptionIfAnAuthenticatorIsNotFound(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(),
+ ]);
+
+ $this->expectException(MissingAuthenticatorException::class);
+ $this->expectExceptionMessage('The "Hypervel\Tests\Saloon\Fixtures\Requests\RequiresAuthRequest" request requires authentication.');
+
+ $request = new RequiresAuthRequest;
+
+ (new TestConnector)->send($request, $mockClient);
+ }
+
+ public function testYouCanUseYourOwnAuthenticators(): void
+ {
+ $request = new UserRequest;
+ $request->authenticate(new PizzaAuthenticator('Margherita', 'San Pellegrino'));
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame('Margherita', $headers['X-Pizza']);
+ $this->assertSame('San Pellegrino', $headers['X-Drink']);
+ $this->assertTrue($pendingRequest->options()['debug']);
+ }
+
+ public function testYouCanUseYourOwnAuthenticatorsAsDefault(): void
+ {
+ $request = new DefaultPizzaAuthenticatorRequest;
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $headers = $pendingRequest->headers();
+
+ $this->assertSame('BBQ Chicken', $headers['X-Pizza']);
+ $this->assertSame('Lemonade', $headers['X-Drink']);
+ $this->assertTrue($pendingRequest->options()['debug']);
+ }
+
+ public function testYouCanCustomiseTheAuthenticatorInsideOfTheBootMethod(): void
+ {
+ $request = new BootAuthenticatorRequest;
+
+ $this->assertNull($request->authenticator());
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $this->assertEquals(new TokenAuthenticator('howdy-partner'), $pendingRequest->authenticator());
+ $this->assertSame('Bearer howdy-partner', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanCustomiseTheAuthenticatorInsideOfPlugins(): void
+ {
+ $request = new AuthenticatorPluginRequest;
+
+ $this->assertNull($request->authenticator());
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $this->assertEquals(new TokenAuthenticator('plugin-auth'), $pendingRequest->authenticator());
+ $this->assertSame('Bearer plugin-auth', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanCustomiseTheAuthenticatorInsideOfAMiddlewarePipeline(): void
+ {
+ $request = new UserRequest;
+
+ $this->assertNull($request->authenticator());
+
+ $request->middleware()
+ ->onRequest(function (PendingRequest $pendingRequest): void {
+ $pendingRequest->withToken('ooh-this-is-cool');
+ });
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $this->assertEquals(new TokenAuthenticator('ooh-this-is-cool'), $pendingRequest->authenticator());
+ $this->assertSame('Bearer ooh-this-is-cool', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testYouCanAddAnAuthenticatorInsideOfRequestMiddleware(): void
+ {
+ $request = new UserRequest;
+
+ // The returned pending request is ignored; authenticate() applies to the one the middleware receives.
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest): PendingRequest {
+ return $pendingRequest->withToken('yee-haw-request');
+ });
+
+ $pendingRequest = (new TestConnector)->createPendingRequest($request);
+
+ $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']);
+ }
+
+ public function testIfYouUseTheAuthenticateMethodOnAFullyConstructedPendingRequestItWillAuthenticateRightAway(): void
+ {
+ $connector = new TestConnector;
+ $pendingRequest = $connector->createPendingRequest(new UserRequest);
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ ], $pendingRequest->headers());
+
+ $pendingRequest->authenticate(new TokenAuthenticator('yee-haw-request'));
+
+ $this->assertSame([
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer yee-haw-request',
+ ], $pendingRequest->headers());
+ }
+}
diff --git a/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php b/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php
new file mode 100644
index 0000000000..3c918038c0
--- /dev/null
+++ b/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php
@@ -0,0 +1,99 @@
+subMinutes(5)->toDateTimeImmutable();
+
+ $authenticator = new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt);
+
+ $this->assertTrue($authenticator->isRefreshable());
+ $this->assertFalse($authenticator->isNotRefreshable());
+ $this->assertTrue($authenticator->hasExpired());
+ $this->assertFalse($authenticator->hasNotExpired());
+ }
+
+ public function testCanBeConstructedWithoutARefreshTokenOrExpiry(): void
+ {
+ $authenticator = new AccessTokenAuthenticator('access');
+
+ $this->assertSame('access', $authenticator->getAccessToken());
+ $this->assertNull($authenticator->getRefreshToken());
+ $this->assertNull($authenticator->getExpiresAt());
+ $this->assertFalse($authenticator->isRefreshable());
+ $this->assertTrue($authenticator->isNotRefreshable());
+ }
+
+ public function testCanBeConstructedWithJustAnAccessTokenAndExpiry(): void
+ {
+ $expiresAt = Date::now()->subMinutes(5)->toDateTimeImmutable();
+
+ $authenticator = new AccessTokenAuthenticator('access', null, $expiresAt);
+
+ $this->assertTrue($authenticator->hasExpired());
+ $this->assertFalse($authenticator->hasNotExpired());
+ }
+
+ public function testItAllowsExpiresInToBeOptional(): void
+ {
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh', null);
+
+ $this->assertNull($authenticator->getExpiresAt());
+ $this->assertTrue($authenticator->isRefreshable());
+ $this->assertFalse($authenticator->isNotRefreshable());
+ }
+
+ public function testItCanUseTheGlobalClockForExpiryChecks(): void
+ {
+ $expiresAt = new DateTimeImmutable('2026-01-01T01:00:00+00:00');
+
+ Date::setTestNow('2026-01-01T02:00:00+00:00');
+
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh', $expiresAt);
+
+ $this->assertTrue($authenticator->hasExpired());
+ $this->assertFalse($authenticator->hasNotExpired());
+ }
+
+ public function testTheAuthorizationHeaderUsesTheAccessTokenGetter(): void
+ {
+ $authenticator = new class('stored') extends AccessTokenAuthenticator {
+ /**
+ * Get the access token.
+ */
+ public function getAccessToken(): string
+ {
+ return 'resolved';
+ }
+ };
+
+ $pendingRequest = (new TestConnector)->createPendingRequest(UserRequest::make()->authenticate($authenticator));
+
+ $this->assertSame('Bearer resolved', $pendingRequest->headers()['Authorization']);
+ }
+}
diff --git a/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
new file mode 100644
index 0000000000..3eb00d693f
--- /dev/null
+++ b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
@@ -0,0 +1,104 @@
+oauthConfig();
+
+ $this->assertInstanceOf(OAuthConfig::class, $config);
+ $this->assertSame('client-id', $config->clientId);
+ $this->assertSame('client-secret', $config->clientSecret);
+ $this->assertSame('https://my-app.saloon.dev/auth/callback', $config->redirectUri);
+ }
+
+ public function testTheOauthConfigIsValidatedWhenGeneratingAnAuthorizationUrl(): void
+ {
+ $connector = new OAuth2Connector(['clientId' => '']);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $connector->authorizationUrl();
+ }
+
+ public function testTheOauthConfigIsValidatedWhenCreatingAccessTokens(): void
+ {
+ $connector = new OAuth2Connector(['clientId' => '']);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $connector->getAccessToken('code');
+ }
+
+ public function testTheOauthConfigIsValidatedWhenRefreshingAccessTokens(): void
+ {
+ $connector = new OAuth2Connector(['clientId' => '']);
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $connector->refreshAccessToken('');
+ }
+
+ public function testTheOldRefreshTokenIsCarriedOverIfAResponseDoesNotIncludeANewRefreshToken(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $authenticator = new AccessTokenAuthenticator('access', 'refresh-old', Date::now()->addSeconds(3600)->toDateTimeImmutable());
+
+ $newAuthenticator = $connector->refreshAccessToken($authenticator);
+
+ $this->assertSame('refresh-old', $newAuthenticator->getRefreshToken());
+ }
+
+ public function testTheOldRefreshTokenIsCarriedOverIfAResponseDoesNotIncludeANewRefreshTokenAndTheRefreshIsAString(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['access_token' => 'access-new', 'expires_in' => 3600]),
+ ]);
+
+ $connector = new OAuth2Connector;
+
+ Saloon::fake($mockClient);
+
+ $newAuthenticator = $connector->refreshAccessToken('refresh-old');
+
+ $this->assertSame('refresh-old', $newAuthenticator->getRefreshToken());
+ }
+}
diff --git a/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
new file mode 100644
index 0000000000..5399318474
--- /dev/null
+++ b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
@@ -0,0 +1,112 @@
+assertSame('', $config->redirectUri);
+ $this->assertSame('authorize', $config->authorizeEndpoint);
+ $this->assertSame('token', $config->tokenEndpoint);
+ $this->assertNull($config->refreshEndpoint);
+ $this->assertSame('user', $config->userEndpoint);
+ $this->assertSame([], $config->defaultScopes);
+ $this->assertNull($config->requestModifier);
+ $this->assertFalse($config->allowBaseUrlOverride);
+
+ $config = new OAuthConfig(
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ redirectUri: 'https://my-app.saloon.dev/auth/callback',
+ authorizeEndpoint: 'auth/authorize',
+ tokenEndpoint: 'auth/token',
+ refreshEndpoint: 'auth/refresh',
+ userEndpoint: 'auth/user',
+ defaultScopes: ['profile'],
+ allowBaseUrlOverride: true,
+ );
+
+ $this->assertSame('client-id', $config->clientId);
+ $this->assertSame('client-secret', $config->clientSecret);
+ $this->assertSame('https://my-app.saloon.dev/auth/callback', $config->redirectUri);
+ $this->assertSame('auth/authorize', $config->authorizeEndpoint);
+ $this->assertSame('auth/token', $config->tokenEndpoint);
+ $this->assertSame('auth/refresh', $config->refreshEndpoint);
+ $this->assertSame('auth/user', $config->userEndpoint);
+ $this->assertSame(['profile'], $config->defaultScopes);
+ $this->assertTrue($config->allowBaseUrlOverride);
+ }
+
+ // REMOVED: make method creates an instance of OAuthConfig. The configuration is built with its constructor.
+
+ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientId(): void
+ {
+ $config = new OAuthConfig('', 'client-secret', 'https://my-app.saloon.dev/auth/callback');
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+
+ $config->validate();
+ }
+
+ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientSecret(): void
+ {
+ $config = new OAuthConfig('client-id', '', 'https://my-app.saloon.dev/auth/callback');
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+
+ $config->validate();
+ }
+
+ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheRedirectUri(): void
+ {
+ $config = new OAuthConfig('client-id', 'client-secret');
+
+ $this->expectException(OAuthConfigValidationException::class);
+ $this->expectExceptionMessage('The Redirect URI is empty or has not been provided.');
+
+ $config->validate();
+ }
+
+ public function testValidationAcceptsNonEmptyValuesAndOptionalRedirectUri(): void
+ {
+ $this->assertTrue((new OAuthConfig('0', '0', '0'))->validate());
+ $this->assertTrue((new OAuthConfig('client-id', 'client-secret'))->validate(withRedirectUrl: false));
+ }
+
+ public function testTheRequestModifierChangesARequest(): void
+ {
+ $config = new OAuthConfig(
+ clientId: 'client-id',
+ clientSecret: 'client-secret',
+ requestModifier: fn (Request $request): Request => $request->withHeader('X-OAuth', 'configured'),
+ );
+ $request = new UserRequest;
+
+ $this->assertSame($request, $config->modify($request));
+ $this->assertSame(['X-OAuth' => 'configured'], $request->headers());
+ }
+
+ public function testAuthorizationUrlKeepsTheUrlAndStateTogether(): void
+ {
+ $authorization = new AuthorizationUrl('https://provider.example.com/authorize', 'state');
+
+ $this->assertSame('https://provider.example.com/authorize', (string) $authorization);
+ $this->assertSame('state', $authorization->state);
+ }
+}
diff --git a/tests/Saloon/Http/OAuthRequestTest.php b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
similarity index 71%
rename from tests/Saloon/Http/OAuthRequestTest.php
rename to tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
index c1d24b2308..3b685f4292 100644
--- a/tests/Saloon/Http/OAuthRequestTest.php
+++ b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
@@ -2,19 +2,18 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit\Oauth2;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Data\OAuthConfig;
-use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\OAuth2\GetAccessTokenRequest;
use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenBasicAuthRequest;
use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
use Hypervel\Saloon\Http\OAuth2\GetRefreshTokenRequest;
-use Hypervel\Saloon\Http\OAuth2\GetUserRequest;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Request;
+use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -34,6 +33,11 @@ public function testTokenRequestsUseTheExpectedFormBody(Request $request, array
$this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']);
}
+ /**
+ * Get each OAuth token request with its expected form body.
+ *
+ * @return array}>
+ */
public static function requestBodies(): array
{
$config = static::config();
@@ -78,32 +82,6 @@ public static function requestBodies(): array
];
}
- public function testBasicAuthRequestKeepsCredentialsOutOfTheBody(): void
- {
- $pendingRequest = $this->pendingRequest(
- new GetClientCredentialsTokenBasicAuthRequest(static::config()),
- )->applyAuthentication();
-
- $this->assertSame(['client', 'secret'], $pendingRequest->transportAuthentication());
- }
-
- public function testTrustedAbsoluteOAuthEndpointsUseTheSharedUrlPolicy(): void
- {
- $config = new OAuthConfig(
- 'client',
- 'secret',
- tokenEndpoint: 'https://oauth.example.net/token',
- userEndpoint: 'https://oauth.example.net/user',
- allowBaseUrlOverride: true,
- );
-
- $token = $this->pendingRequest(new GetClientCredentialsTokenRequest($config))->finalizeUri();
- $user = $this->pendingRequest(new GetUserRequest($config))->finalizeUri();
-
- $this->assertSame('https://oauth.example.net/token', (string) $token->uri());
- $this->assertSame('https://oauth.example.net/user', (string) $user->uri());
- }
-
/**
* Create the OAuth configuration used by request tests.
*/
@@ -123,18 +101,10 @@ protected static function config(): OAuthConfig
protected function pendingRequest(Request $request): PendingRequest
{
return new PendingRequest(
- new OAuthConnectorStub,
+ new TestConnector('https://api.example.com'),
$request,
m::mock(CacheFactory::class),
m::mock(RateLimiter::class),
);
}
}
-
-class OAuthConnectorStub extends Connector
-{
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-}
From 0ee1b77741e73b1aa42d3595b30c902e65236635 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 20:55:36 +0000
Subject: [PATCH 08/26] Reconcile Saloon mocking, fixtures and assertions with
upstream
Ports Saloon's mock client, mock client assertion, mock response,
mocks-requests, global mock client, mock config, fixture data, fixture
path traversal, simulated response payload and mock request tests
(saloonphp/saloon v4 at 2f45e526f9d1) with their fixtures, merging the
existing Hypervel mock client, fake response, fixture and recorded
response tests into the upstream-named files. This completes
saloonphp/saloon#539's fixture, storage and mock changes, #548
(MockClient::withoutCache()) and #563 (typed assertSent closures).
Defects fixed:
- URL patterns for mock responses, URL assertions and allowed stray
requests needed the full URL, so upstream's path patterns such as
'/user' or 'api.example.com/*' never matched. One matcher now
prefixes the pattern with '*' and matches the URL with or without
its query string; a pattern that includes a query still matches only
that query.
- Typed assertion closures only understood class names and unions, so
a mixed, object or intersection parameter threw. They now use
upstream's recursive type check: mixed and object match any request,
other built-in types none, and intersections need every part.
- Fixture header redaction rules were applied to whole header values,
so upstream-style closures failed on list headers and headers with
several values, such as Set-Cookie, were stored unredacted. Rules now
apply to each value, case-insensitively, and only closures are
called, so callable-looking strings stay literal.
- Fixtures whose data is a JSON object failed to load. Array data now
survives loading, replay and merging; storing encodes it before
redaction, and base64 applies only to binary strings.
- A fixture's own context overrode the recorded response's context,
including beforeSave() changes. The recorded context now wins
without changing the fixture.
- Nine request fixtures declared upstream's $connector property and
DefaultPropertiesRequest a defaultData() method, though nothing reads
either. Both are removed.
Upstream changes:
- MockClient::withoutCache() keeps requests that reach the transport,
such as recording a missing fixture, out of the response cache.
- NoMockResponseFoundException uses upstream's message.
Adaptations, recorded in the README:
- Connectors take no mock client: attach one to the request, pass it to
send() or use Saloon::fake(), which also backs MockClient::global().
- recorded(), lastRequest(), lastPendingRequest(), lastResponse() and
match() replace upstream's getters; assertSentJson() is not ported.
- MockConfig maps to the saloon.fixtures.path configuration, fixtures
are read and written through Filesystem instead of upstream's Storage
helper, getFixturePath() is absolute, and recorded fixtures list
every header value.
Validation: Saloon suite (895 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon; composer analyse; php-cs-fixer. The private SDK
generator was checked by source: it uses only APIs this change keeps.
---
src/docs/saloon.md | 12 +-
src/saloon/README.md | 2 +
src/saloon/src/Data/RecordedResponse.php | 13 +-
.../NoMockResponseFoundException.php | 2 +-
src/saloon/src/Http/Faking/Fixture.php | 31 +-
src/saloon/src/Http/Faking/MockClient.php | 89 +-
src/saloon/src/SaloonManager.php | 4 +-
tests/Saloon/Data/RecordedResponseTest.php | 48 -
.../Feature/FixturePathTraversalTest.php | 102 +++
tests/Saloon/Feature/MockRequestTest.php | 855 ++++++++++++++++++
.../Feature/SimulatedResponsePayloadTest.php | 43 +
.../Connectors/DifferentServiceConnector.php | 31 +
.../Exceptions/TestResponseException.php | 34 +
.../Mocking/BeforeSaveUserFixture.php | 29 +
.../Fixtures/Mocking/CallableMockResponse.php | 19 +
.../Fixtures/Mocking/MissingNameFixture.php | 11 +
.../Fixtures/Mocking/RegexUserFixture.php | 31 +
.../Fixtures/Mocking/SafeUserFixture.php | 49 +
.../Fixtures/Mocking/SuperheroFixture.php | 28 +
tests/Saloon/Fixtures/Mocking/UserFixture.php | 18 +
.../Fixtures/Requests/ConfigRequest.php | 6 +-
.../Requests/DefaultPropertiesRequest.php | 16 +-
.../Requests/DifferentServiceUserRequest.php | 26 +
.../Fixtures/Requests/FileDownloadRequest.php | 24 +
.../Fixtures/Requests/HeaderRequest.php | 6 +-
...rwrittenQueryParameterConnectorRequest.php | 6 +-
.../Requests/PagedSuperheroRequest.php | 24 +
.../QueryParameterConnectorBlankRequest.php | 6 +-
.../QueryParameterConnectorRequest.php | 6 +-
.../Requests/QueryParameterRequest.php | 6 +-
.../Fixtures/Requests/TimeoutRequest.php | 6 +-
tests/Saloon/Fixtures/Saloon/users.json | 1 +
tests/Saloon/Http/FakeResponseTest.php | 43 -
tests/Saloon/Http/MockClientTest.php | 295 ------
tests/Saloon/Unit/FixtureDataTest.php | 268 ++++++
tests/Saloon/{Http => Unit}/FixtureTest.php | 57 +-
tests/Saloon/Unit/GlobalMockClientTest.php | 78 ++
.../Saloon/Unit/MockClientAssertionsTest.php | 340 +++++++
tests/Saloon/Unit/MockClientTest.php | 366 ++++++++
tests/Saloon/Unit/MockConfigTest.php | 99 ++
tests/Saloon/Unit/MockResponseTest.php | 97 ++
tests/Saloon/Unit/MocksRequestsTest.php | 85 ++
42 files changed, 2845 insertions(+), 467 deletions(-)
delete mode 100644 tests/Saloon/Data/RecordedResponseTest.php
create mode 100644 tests/Saloon/Feature/FixturePathTraversalTest.php
create mode 100644 tests/Saloon/Feature/MockRequestTest.php
create mode 100644 tests/Saloon/Feature/SimulatedResponsePayloadTest.php
create mode 100644 tests/Saloon/Fixtures/Connectors/DifferentServiceConnector.php
create mode 100644 tests/Saloon/Fixtures/Exceptions/TestResponseException.php
create mode 100644 tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php
create mode 100644 tests/Saloon/Fixtures/Mocking/CallableMockResponse.php
create mode 100644 tests/Saloon/Fixtures/Mocking/MissingNameFixture.php
create mode 100644 tests/Saloon/Fixtures/Mocking/RegexUserFixture.php
create mode 100644 tests/Saloon/Fixtures/Mocking/SafeUserFixture.php
create mode 100644 tests/Saloon/Fixtures/Mocking/SuperheroFixture.php
create mode 100644 tests/Saloon/Fixtures/Mocking/UserFixture.php
create mode 100644 tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/FileDownloadRequest.php
create mode 100644 tests/Saloon/Fixtures/Requests/PagedSuperheroRequest.php
create mode 100644 tests/Saloon/Fixtures/Saloon/users.json
delete mode 100644 tests/Saloon/Http/FakeResponseTest.php
delete mode 100644 tests/Saloon/Http/MockClientTest.php
create mode 100644 tests/Saloon/Unit/FixtureDataTest.php
rename tests/Saloon/{Http => Unit}/FixtureTest.php (82%)
create mode 100644 tests/Saloon/Unit/GlobalMockClientTest.php
create mode 100644 tests/Saloon/Unit/MockClientAssertionsTest.php
create mode 100644 tests/Saloon/Unit/MockClientTest.php
create mode 100644 tests/Saloon/Unit/MockConfigTest.php
create mode 100644 tests/Saloon/Unit/MockResponseTest.php
create mode 100644 tests/Saloon/Unit/MocksRequestsTest.php
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 9920ddff14..fe13668c7e 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -2209,6 +2209,8 @@ Saloon::fake([
]);
```
+A URL pattern may leave out the scheme and host, such as `'api.github.com/repos/*'` or `'/user'`, and matches the URL with or without its query string. A pattern that includes a query string, such as `'/users?page=2'`, matches only that encoded query. Assertions and `allowStrayRequests` use the same patterns.
+
Each matching value may also be a callback that receives the pending request and returns a mock response or fixture. A lower-level `Http::fake()` still prevents a network request after the Saloon lifecycle reaches Hypervel's HTTP client. The response is recorded by the HTTP client and `isMocked()` returns false. When no Saloon mock client is active, Saloon facade assertions do not include the response.
You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. Mock responses are matched after request middleware has run and the URL is final, so URL matches see any changes middleware made.
@@ -2317,7 +2319,15 @@ class GitHubFixture extends Fixture
}
```
-Invalid or failed regular expressions prevent the fixture from being written. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata.
+Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata.
+
+Mock responses and existing fixtures never use the response cache, but a request that reaches the network through a mock client, such as a missing fixture being recorded or an allowed stray request, still does. Call `withoutCache` on the mock client to keep those requests out of the cache, so a recorded fixture always comes from the network:
+
+```php
+Saloon::fake([
+ GetUser::class => new Fixture('github/users/hypervel'),
+])->withoutCache();
+```
Tests may override the fixture directory and missing-fixture behavior:
diff --git a/src/saloon/README.md b/src/saloon/README.md
index bed22f1a77..114fa3cf27 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -12,6 +12,8 @@ There is no process-global `Config` class and no pluggable sender. Global middle
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
+Connectors are read-only, so they have no `withMockClient()`: attach a mock client to the request, pass it to `send()`, or use `Saloon::fake()`, which also backs `MockClient::global()`. Mock clients use the HTTP client's names. `recorded()` returns the recorded responses as a collection, optionally filtered by a callback, in place of `getRecordedResponses()`, `findResponseByRequest()` and `findResponseByRequestUrl()`; `lastRequest()`, `lastPendingRequest()` and `lastResponse()` drop the `get` prefix; and `match()` replaces `guessNextResponse()` and `getNextFromSequence()`. The deprecated `assertSentJson()` is not included. There is no `MockConfig`: fixtures live under the `saloon.fixtures.path` configuration, which tests override with `Saloon::fixturePath()` and `Saloon::throwOnMissingFixtures()`, and are read and written through the framework `Filesystem` rather than upstream's `Storage` helper. `getFixturePath()` returns the absolute path, fixture names separate directories with forward slashes, a missing directory is created when a fixture is first recorded, and recorded fixtures list every header value. See [Testing](https://hypervel.org/docs/saloon#testing).
+
Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeResponse` and `ResponseMiddleware::__invoke()` returns `?Response`, so a ported middleware class declared `: void` returns `null` instead. A pending request returned from request middleware is ignored; change the one it receives. Pipelines expose `pipes()`, `requestPipeline()`, `responsePipeline()` and `fatalPipeline()` instead of upstream's `get` methods. Connector middleware is registered from `boot()` and runs after global and plugin middleware but before the request's own middleware.
Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties and `handleRetry()` methods; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries).
diff --git a/src/saloon/src/Data/RecordedResponse.php b/src/saloon/src/Data/RecordedResponse.php
index 4abebde40e..48d824582e 100644
--- a/src/saloon/src/Data/RecordedResponse.php
+++ b/src/saloon/src/Data/RecordedResponse.php
@@ -14,13 +14,14 @@ class RecordedResponse implements JsonSerializable
/**
* Create a recorded response.
*
- * @param array> $headers
+ * @param array|string> $headers
+ * @param array|string $data
* @param array $context
*/
public function __construct(
public int $statusCode,
public array $headers = [],
- public string $data = '',
+ public array|string $data = '',
public array $context = [],
) {
}
@@ -30,11 +31,11 @@ public function __construct(
*/
public static function fromFile(string $contents): static
{
- /** @var array{statusCode: int, headers: array>, data: string, encoding?: string, context?: array} $fileData */
+ /** @var array{statusCode: int, headers: array|string>, data: array|string, encoding?: string, context?: array} $fileData */
$fileData = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
$data = $fileData['data'];
- if (($fileData['encoding'] ?? null) === 'base64') {
+ if (is_string($data) && ($fileData['encoding'] ?? null) === 'base64') {
$decoded = base64_decode($data, true);
if ($decoded === false) {
@@ -83,7 +84,7 @@ public function toMockResponse(): MockResponse
/**
* Convert the recorded response to its fixture representation.
*
- * @return array{statusCode: int, headers: array>, data: string, context: array, encoding?: 'base64'}
+ * @return array{statusCode: int, headers: array|string>, data: array|string, context: array, encoding?: 'base64'}
*/
public function jsonSerialize(): array
{
@@ -94,7 +95,7 @@ public function jsonSerialize(): array
'context' => $this->context,
];
- if (! mb_check_encoding($this->data, 'UTF-8')) {
+ if (is_string($this->data) && ! mb_check_encoding($this->data, 'UTF-8')) {
$response['data'] = base64_encode($this->data);
$response['encoding'] = 'base64';
}
diff --git a/src/saloon/src/Exceptions/NoMockResponseFoundException.php b/src/saloon/src/Exceptions/NoMockResponseFoundException.php
index e36b242e5a..bd443963e2 100644
--- a/src/saloon/src/Exceptions/NoMockResponseFoundException.php
+++ b/src/saloon/src/Exceptions/NoMockResponseFoundException.php
@@ -13,6 +13,6 @@ class NoMockResponseFoundException extends SaloonException
*/
public function __construct(PendingRequest $pendingRequest)
{
- parent::__construct(sprintf('Saloon could not find a mock response for [%s]. Consider using a wildcard URL mock or a connector mock.', $pendingRequest->uri()));
+ parent::__construct(sprintf('Saloon was unable to guess a mock response for your request [%s], consider using a wildcard url mock or a connector mock.', $pendingRequest->uri()));
}
}
diff --git a/src/saloon/src/Http/Faking/Fixture.php b/src/saloon/src/Http/Faking/Fixture.php
index 7b8c9efb81..7e19d63edf 100644
--- a/src/saloon/src/Http/Faking/Fixture.php
+++ b/src/saloon/src/Http/Faking/Fixture.php
@@ -104,11 +104,16 @@ public function getMockResponse(): ?MockResponse
return $response;
}
- /** @var null|string $body */
$body = $response->body()->all();
- $contents = $body === null || $body === ''
- ? []
- : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+
+ if (is_array($body)) {
+ $contents = $body;
+ } else {
+ /** @var null|string $body */
+ $contents = $body === null || $body === ''
+ ? []
+ : json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ }
if (! is_array($contents)) {
throw new FixtureException('Fixture merge and through transforms require a JSON array or object body.');
@@ -122,7 +127,7 @@ public function getMockResponse(): ?MockResponse
$contents = ($this->through)($contents);
}
- $response->body()->set(json_encode($contents, JSON_THROW_ON_ERROR));
+ $response->body()->set(is_array($body) ? $contents : json_encode($contents, JSON_THROW_ON_ERROR));
return $response;
}
@@ -134,11 +139,16 @@ public function getMockResponse(): ?MockResponse
*/
public function store(RecordedResponse $recordedResponse): static
{
+ // Redaction rules match the response text, so array data is encoded first rather than skipping them.
+ if (is_array($recordedResponse->data)) {
+ $recordedResponse->data = json_encode($recordedResponse->data, JSON_THROW_ON_ERROR);
+ }
+
$recordedResponse = $this->swapSensitiveHeaders($recordedResponse);
$recordedResponse = $this->swapSensitiveJson($recordedResponse);
$recordedResponse = $this->swapSensitiveBodyWithRegex($recordedResponse);
$recordedResponse = $this->beforeSave($recordedResponse);
- $recordedResponse->context = array_merge($recordedResponse->context, $this->context->all());
+ $recordedResponse->context = array_merge($this->context->all(), $recordedResponse->context);
$fixturePath = $this->getFixturePath();
$this->files->ensureDirectoryExists(dirname($fixturePath));
@@ -197,8 +207,9 @@ protected function swapSensitiveHeaders(RecordedResponse $recordedResponse): Rec
continue;
}
- $replacement = $replacement instanceof Closure ? $replacement($values) : $replacement;
- $recordedResponse->headers[$name] = is_array($replacement) ? $replacement : [$replacement];
+ $redact = static fn (string $value): string => $replacement instanceof Closure ? $replacement($value) : $replacement;
+
+ $recordedResponse->headers[$name] = is_array($values) ? array_map($redact, $values) : $redact($values);
}
return $recordedResponse;
@@ -253,7 +264,9 @@ protected function swapSensitiveBodyWithRegex(RecordedResponse $recordedResponse
/**
* Define sensitive response headers.
*
- * @return array): (list|string)|string>
+ * Rules apply to each value of a matching header.
+ *
+ * @return array
*/
protected function defineSensitiveHeaders(): array
{
diff --git a/src/saloon/src/Http/Faking/MockClient.php b/src/saloon/src/Http/Faking/MockClient.php
index 879e5e6b25..5dbd94c86d 100644
--- a/src/saloon/src/Http/Faking/MockClient.php
+++ b/src/saloon/src/Http/Faking/MockClient.php
@@ -13,14 +13,15 @@
use Hypervel\Saloon\SaloonManager;
use Hypervel\Support\Collection;
use Hypervel\Support\Str;
-use Hypervel\Support\Traits\ReflectsClosures;
use PHPUnit\Framework\Assert as PHPUnit;
use ReflectionFunction;
+use ReflectionIntersectionType;
+use ReflectionNamedType;
+use ReflectionType;
+use ReflectionUnionType;
class MockClient
{
- use ReflectsClosures;
-
/**
* Responses consumed in registration order.
*
@@ -70,6 +71,11 @@ class MockClient
*/
protected ?array $allowedStrayRequestUrls = [];
+ /**
+ * Whether requests using this mock client bypass the response cache.
+ */
+ protected bool $withoutResponseCache = false;
+
/**
* Create a mock client.
*
@@ -138,7 +144,7 @@ public function match(PendingRequest $pendingRequest): MockResponse|Fixture|null
}
foreach ($this->urlResponses as $url => $response) {
- if (Str::is($url, (string) $pendingRequest->uri())) {
+ if ($this->urlMatches($url, $pendingRequest)) {
return $this->resolveValue($response, $pendingRequest);
}
}
@@ -301,6 +307,26 @@ public function assertSentCount(int $count, ?string $requestClass = null): void
PHPUnit::assertCount($count, $responses);
}
+ /**
+ * Bypass the response cache for requests using this mock client.
+ *
+ * @return $this
+ */
+ public function withoutCache(): static
+ {
+ $this->withoutResponseCache = true;
+
+ return $this;
+ }
+
+ /**
+ * Determine if requests using this mock client bypass the response cache.
+ */
+ public function shouldBypassResponseCache(): bool
+ {
+ return $this->withoutResponseCache;
+ }
+
/**
* Register or retrieve the manager-backed global mock client.
*
@@ -356,21 +382,62 @@ protected function responseMatches(Response $response, string|callable $value):
if (is_string($value)) {
return is_a($value, Request::class, true)
? $request instanceof $value
- : Str::is($value, (string) $response->pendingRequest()->uri());
+ : $this->urlMatches($value, $response->pendingRequest());
}
$closure = $value(...);
$parameters = (new ReflectionFunction($closure))->getParameters();
- if ($parameters !== [] && $parameters[0]->hasType()) {
- $types = $this->firstClosureParameterTypes($closure);
+ if ($parameters !== [] && ! $this->requestMatchesType($request, $parameters[0]->getType())) {
+ return false;
+ }
+
+ return (bool) $closure($request, $response);
+ }
- if (! array_any($types, fn (string $type): bool => $request instanceof $type)) {
- return false;
+ /**
+ * Determine if the request satisfies a closure parameter type.
+ */
+ protected function requestMatchesType(Request $request, ?ReflectionType $type): bool
+ {
+ if ($type instanceof ReflectionNamedType) {
+ if ($type->isBuiltin()) {
+ return in_array($type->getName(), ['mixed', 'object'], true);
}
+
+ $class = $type->getName();
+
+ return $request instanceof $class;
}
- return (bool) $closure($request, $response);
+ if ($type instanceof ReflectionUnionType) {
+ return array_any(
+ $type->getTypes(),
+ fn (ReflectionType $subType): bool => $this->requestMatchesType($request, $subType),
+ );
+ }
+
+ if ($type instanceof ReflectionIntersectionType) {
+ return array_all(
+ $type->getTypes(),
+ fn (ReflectionType $subType): bool => $this->requestMatchesType($request, $subType),
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Determine if the pending request's URL matches the pattern.
+ *
+ * A pattern may omit the scheme and host, and matches the URL with or without its query string.
+ */
+ protected function urlMatches(string $pattern, PendingRequest $pendingRequest): bool
+ {
+ $pattern = Str::start($pattern, '*');
+ $uri = $pendingRequest->uri();
+
+ return Str::is($pattern, (string) $uri) || Str::is($pattern, (string) $uri->withQuery(''));
}
/**
@@ -388,7 +455,7 @@ protected function allowsStrayRequest(PendingRequest $pendingRequest): bool
return array_any(
$this->allowedStrayRequestUrls,
- fn (string $url): bool => Str::is($url, (string) $pendingRequest->uri()),
+ fn (string $url): bool => $this->urlMatches($url, $pendingRequest),
);
}
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index 27d7244e14..a919ecca9c 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -130,7 +130,9 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
$cacheRepository = null;
$cacheKey = null;
- if ($pendingRequest->fakeResponse() === null && $pendingRequest->isCacheable()) {
+ if ($pendingRequest->fakeResponse() === null
+ && $mockClient?->shouldBypassResponseCache() !== true
+ && $pendingRequest->isCacheable()) {
[$cacheRepository, $cacheKey] = $this->resolveCache($pendingRequest, $transport);
if ($pendingRequest->shouldInvalidateCache()) {
diff --git a/tests/Saloon/Data/RecordedResponseTest.php b/tests/Saloon/Data/RecordedResponseTest.php
deleted file mode 100644
index 41083b6a80..0000000000
--- a/tests/Saloon/Data/RecordedResponseTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
- ['abc']],
- '{"name":"Taylor"}',
- ['provider' => 'example'],
- );
-
- $restored = RecordedResponse::fromFile($recorded->toFile());
-
- $this->assertSame(201, $restored->statusCode);
- $this->assertSame(['X-Trace' => ['abc']], $restored->headers);
- $this->assertSame('{"name":"Taylor"}', $restored->data);
- $this->assertSame(['provider' => 'example'], $restored->context);
- $this->assertSame('{"name":"Taylor"}', (string) $restored->toMockResponse()->createPsrResponse()->getBody());
- }
-
- public function testItRoundTripsBinaryResponseData(): void
- {
- $recorded = new RecordedResponse(200, data: "\xB1\x31");
-
- $contents = $recorded->toFile();
- $restored = RecordedResponse::fromFile($contents);
-
- $this->assertStringContainsString('"encoding": "base64"', $contents);
- $this->assertSame("\xB1\x31", $restored->data);
- }
-
- public function testItRejectsInvalidBase64Data(): void
- {
- $this->expectException(FixtureException::class);
-
- RecordedResponse::fromFile('{"statusCode":200,"headers":[],"data":"%","encoding":"base64"}');
- }
-}
diff --git a/tests/Saloon/Feature/FixturePathTraversalTest.php b/tests/Saloon/Feature/FixturePathTraversalTest.php
new file mode 100644
index 0000000000..3e6630ddba
--- /dev/null
+++ b/tests/Saloon/Feature/FixturePathTraversalTest.php
@@ -0,0 +1,102 @@
+files = new Filesystem;
+ $this->directory = ParallelTesting::tempDir('SaloonFixturePathTraversalTest');
+ $this->fixtureBaseDir = $this->directory . '/fixtures';
+ $this->files->deleteDirectory($this->directory);
+ $this->files->ensureDirectoryExists($this->fixtureBaseDir);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ $this->files->deleteDirectory($this->directory);
+
+ parent::tearDown();
+ }
+
+ public function testFixtureNameWithPathTraversalThrowsWhenGettingMockResponseAndDoesNotReadOutsideBase(): void
+ {
+ Saloon::fixturePath($this->fixtureBaseDir);
+
+ $externalPath = $this->directory . '/traversal_read_target.json';
+ $secretContent = 'read_from_outside';
+ file_put_contents($externalPath, json_encode([
+ 'statusCode' => 200,
+ 'headers' => [],
+ 'data' => '{"secret":"' . $secretContent . '"}',
+ 'context' => [],
+ ]));
+
+ $fixture = new Fixture('../traversal_read_target', $this->files);
+
+ try {
+ $fixture->getMockResponse();
+ $this->fail('A traversing fixture name was accepted.');
+ } catch (FixtureException $exception) {
+ $this->assertSame('Fixture names must contain only portable path segments separated by forward slashes.', $exception->getMessage());
+ }
+
+ $this->assertStringContainsString($secretContent, file_get_contents($externalPath));
+ }
+
+ public function testFixtureNameWithPathTraversalThrowsWhenStoringAndDoesNotWriteOutsideBase(): void
+ {
+ Saloon::fixturePath($this->fixtureBaseDir);
+
+ $fixture = new Fixture('../traversal_write_test', $this->files);
+
+ $recordedResponse = new RecordedResponse(200, [], '{"pwned":true}');
+
+ try {
+ $fixture->store($recordedResponse);
+ $this->fail('A traversing fixture name was accepted.');
+ } catch (FixtureException $exception) {
+ $this->assertSame('Fixture names must contain only portable path segments separated by forward slashes.', $exception->getMessage());
+ }
+
+ $this->assertFileDoesNotExist($this->directory . '/traversal_write_test.json');
+ }
+}
diff --git a/tests/Saloon/Feature/MockRequestTest.php b/tests/Saloon/Feature/MockRequestTest.php
new file mode 100644
index 0000000000..57a74182a2
--- /dev/null
+++ b/tests/Saloon/Feature/MockRequestTest.php
@@ -0,0 +1,855 @@
+files = new Filesystem;
+ $this->fixturePath = ParallelTesting::tempDir('SaloonMockRequestTest');
+ $this->files->deleteDirectory($this->fixturePath);
+ $this->files->ensureDirectoryExists($this->fixturePath);
+
+ Saloon::fixturePath($this->fixturePath);
+
+ Http::fake([
+ 'tests.saloon.dev/api/user' => Http::response(
+ ['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam'],
+ 200,
+ ['Server' => 'cloudflare', 'Cache-Control' => 'no-cache, private'],
+ ),
+ 'tests.saloon.dev/api/error' => Http::response(['message' => 'Fake Error'], 500),
+ 'tests.saloon.dev/api/download-test-pdf' => Http::response(self::FILE_CONTENTS, 200, ['Content-Type' => 'application/pdf']),
+ 'tests.saloon.dev/api/superheroes/per-page' => Http::response([
+ 'data' => [
+ ['superhero' => 'Batman', 'publisher' => 'DC Comics'],
+ ['superhero' => 'Superman', 'publisher' => 'DC Comics'],
+ ],
+ ]),
+ ]);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ $this->files->deleteDirectory($this->fixturePath);
+
+ parent::tearDown();
+ }
+
+ public function testARequestCanBeMockedWithASequence(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam'], 200, ['X-Foo' => 'Bar']),
+ MockResponse::make(['name' => 'Alex']),
+ MockResponse::make(['error' => 'Server Unavailable'], 500),
+ ]);
+
+ $connector = new TestConnector;
+
+ $responseA = $connector->send(new UserRequest);
+
+ $this->assertInstanceOf(Response::class, $responseA);
+ $this->assertTrue($responseA->isMocked());
+ $this->assertTrue($responseA->isFaked());
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+ $this->assertInstanceOf(MockResponse::class, $responseA->fakeResponse());
+ $this->assertSame(['X-Foo' => ['Bar']], $responseA->headers());
+
+ $responseB = $connector->send(new UserRequest);
+
+ $this->assertInstanceOf(Response::class, $responseB);
+ $this->assertTrue($responseB->isMocked());
+ $this->assertTrue($responseB->isFaked());
+ $this->assertFalse($responseB->isCached());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ $this->assertInstanceOf(MockResponse::class, $responseB->fakeResponse());
+
+ $responseC = $connector->send(new UserRequest);
+
+ $this->assertInstanceOf(Response::class, $responseC);
+ $this->assertTrue($responseC->isMocked());
+ $this->assertTrue($responseC->isFaked());
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['error' => 'Server Unavailable'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+ $this->assertInstanceOf(MockResponse::class, $responseC->fakeResponse());
+
+ $this->expectException(NoMockResponseFoundException::class);
+ $this->expectExceptionMessage('Saloon was unable to guess a mock response for your request [https://tests.saloon.dev/api/user], consider using a wildcard url mock or a connector mock.');
+
+ $connector->send(new UserRequest);
+ }
+
+ public function testARequestCanBeMockedWithAConnectorDefined(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $connectorARequest = new UserRequest;
+ $connectorBRequest = new QueryParameterConnectorRequest;
+
+ $mockClient = new MockClient([
+ TestConnector::class => $responseA,
+ QueryParameterConnector::class => $responseB,
+ ]);
+
+ $responseA = $connectorA->send($connectorARequest, $mockClient);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorB->send($connectorBRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ }
+
+ public function testARequestCanBeMockedWithARequestDefined(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new QueryParameterConnectorRequest;
+
+ $mockClient = new MockClient([
+ UserRequest::class => $responseA,
+ QueryParameterConnectorRequest::class => $responseB,
+ ]);
+
+ $responseA = $connectorA->send($requestA, $mockClient);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorB->send($requestB, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ }
+
+ public function testARequestCanBeMockedWithAUrlDefined(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+ $responseC = MockResponse::make(['error' => 'Server Broken'], 500);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ 'google.com/*' => $responseC, // Test Different Route,
+ ]);
+
+ $responseA = $connectorA->send($requestA, $mockClient);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorA->send($requestB, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $responseC = $connectorB->send($requestC, $mockClient);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(['error' => 'Server Broken'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+ }
+
+ public function testYouCanCreateWildcardUrlMocks(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+ $responseC = MockResponse::make(['error' => 'Server Broken'], 500);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ '*' => $responseC,
+ ]);
+
+ $responseA = $connectorA->send($requestA, $mockClient);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorA->send($requestB, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $responseC = $connectorB->send($requestC, $mockClient);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(['error' => 'Server Broken'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+ }
+
+ public function testYouCanUseAClosureForTheMockResponse(): void
+ {
+ $sequenceMock = new MockClient([
+ function (PendingRequest $pendingRequest): MockResponse {
+ return new MockResponse(['request' => (string) $pendingRequest->uri()]);
+ },
+ ]);
+
+ $sequenceResponse = (new TestConnector)->send(new UserRequest, $sequenceMock);
+
+ $this->assertTrue($sequenceResponse->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $sequenceResponse->json());
+
+ // Connector mock
+
+ $connectorMock = new MockClient([
+ TestConnector::class => function (PendingRequest $pendingRequest): MockResponse {
+ return new MockResponse(['request' => (string) $pendingRequest->uri()]);
+ },
+ ]);
+
+ $connectorResponse = (new TestConnector)->send(new UserRequest, $connectorMock);
+
+ $this->assertTrue($connectorResponse->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $connectorResponse->json());
+
+ // Request mock
+
+ $requestMock = new MockClient([
+ UserRequest::class => function (PendingRequest $pendingRequest): MockResponse {
+ return new MockResponse(['request' => (string) $pendingRequest->uri()]);
+ },
+ ]);
+
+ $requestResponse = (new TestConnector)->send(new UserRequest, $requestMock);
+
+ $this->assertTrue($requestResponse->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $requestResponse->json());
+
+ // URL mock
+
+ $urlMock = new MockClient([
+ 'tests.saloon.dev/*' => function (PendingRequest $pendingRequest): MockResponse {
+ return new MockResponse(['request' => (string) $pendingRequest->uri()]);
+ },
+ ]);
+
+ $urlResponse = (new TestConnector)->send(new UserRequest, $urlMock);
+
+ $this->assertTrue($urlResponse->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $urlResponse->json());
+ }
+
+ public function testYouCanUseACallableClassAsTheMockResponse(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => new CallableMockResponse,
+ ]);
+
+ $sequenceResponse = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($sequenceResponse->isMocked());
+ $this->assertSame(['request_class' => UserRequest::class], $sequenceResponse->json());
+ }
+
+ public function testAFixtureCanBeUsedWithAMockSequence(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::fixture('user'),
+ MockResponse::fixture('user'),
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+ }
+
+ public function testAFixtureCanBeUsedWithAConnectorMock(): void
+ {
+ $mockClient = new MockClient([
+ TestConnector::class => MockResponse::fixture('connector'),
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+
+ // Even though it's a different request, it should use the same fixture
+
+ $responseC = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(200, $responseC->status());
+ $this->assertSame($this->user(), $responseC->json());
+ }
+
+ public function testAFixtureCanBeUsedWithARequestMock(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::fixture('user'),
+ ]);
+
+ $this->assertFalse($this->files->exists($this->fixturePath . '/user.json'));
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $this->assertTrue($this->files->exists($this->fixturePath . '/user.json'));
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+
+ Http::assertSentCount(1);
+ $mockClient->assertSentCount(2);
+ }
+
+ public function testAFixtureCanBeUsedWithAUrlMock(): void
+ {
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => MockResponse::fixture('user'), // Test Exact Route
+ 'tests.saloon.dev/*' => MockResponse::fixture('other'), // Test Wildcard Routes
+ ]);
+
+ $this->assertFalse($this->files->exists($this->fixturePath . '/user.json'));
+ $this->assertFalse($this->files->exists($this->fixturePath . '/other.json'));
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($this->files->exists($this->fixturePath . '/user.json'));
+ $this->assertFalse($this->files->exists($this->fixturePath . '/other.json'));
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertTrue($this->files->exists($this->fixturePath . '/user.json'));
+ $this->assertTrue($this->files->exists($this->fixturePath . '/other.json'));
+
+ $this->assertFalse($responseB->isMocked());
+ $this->assertSame(500, $responseB->status());
+ $this->assertSame(['message' => 'Fake Error'], $responseB->json());
+
+ // This should use the first mock
+
+ $responseC = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(200, $responseC->status());
+ $this->assertSame($this->user(), $responseC->json());
+
+ // Another error request should use the "other" mock
+
+ $responseD = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertTrue($responseD->isMocked());
+ $this->assertSame(500, $responseD->status());
+ $this->assertSame(['message' => 'Fake Error'], $responseD->json());
+ }
+
+ public function testAFixtureCanBeUsedWithAWildcardUrlMock(): void
+ {
+ $mockClient = new MockClient([
+ '*' => MockResponse::fixture('user'), // Test Exact Route
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+ }
+
+ public function testAFixtureCanBeUsedWithinAClosureMock(): void
+ {
+ $mockClient = new MockClient([
+ '*' => function (PendingRequest $pendingRequest): Fixture {
+ if ($pendingRequest->request() instanceof UserRequest) {
+ return MockResponse::fixture('user');
+ }
+
+ return MockResponse::fixture('other');
+ },
+ ]);
+
+ $this->assertFalse($this->files->exists($this->fixturePath . '/user.json'));
+ $this->assertFalse($this->files->exists($this->fixturePath . '/other.json'));
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+
+ // Now we'll test a different route
+
+ $responseC = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertFalse($responseC->isMocked());
+ $this->assertSame(500, $responseC->status());
+ $this->assertSame(['message' => 'Fake Error'], $responseC->json());
+
+ // Another error request should use the "other" mock
+
+ $responseD = (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->assertTrue($responseD->isMocked());
+ $this->assertSame(500, $responseD->status());
+ $this->assertSame(['message' => 'Fake Error'], $responseD->json());
+ }
+
+ public function testWhenUsingTheAlwaysThrowRequestTraitTheResponseRecorderWillStillRecordTheResponse(): void
+ {
+ $mockClient = new MockClient([
+ AlwaysThrowRequest::class => MockResponse::fixture('error'),
+ ]);
+
+ $exception = null;
+
+ try {
+ (new TestConnector)->send(new AlwaysThrowRequest, $mockClient);
+ } catch (Exception $exception) {
+ }
+
+ $this->assertInstanceOf(RequestException::class, $exception);
+
+ $fixture = MockResponse::fixture('error')->getMockResponse();
+
+ $this->assertInstanceOf(MockResponse::class, $fixture);
+ }
+
+ public function testAFixtureCanRecordTheFileDataFromARequestThatReturnsAFileDownload(): void
+ {
+ $mockClient = new MockClient([
+ FileDownloadRequest::class => MockResponse::fixture('file'),
+ ]);
+
+ $requestA = new FileDownloadRequest;
+ $responseA = (new TestConnector)->send($requestA, $mockClient);
+
+ $this->assertSame(self::FILE_CONTENTS, $responseA->body());
+
+ $requestB = new FileDownloadRequest;
+ $responseB = (new TestConnector)->send($requestB, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(self::FILE_CONTENTS, $responseB->body());
+ }
+
+ public function testYouCanCreateACustomFixtureClass(): void
+ {
+ $mockClient = new MockClient([
+ new UserFixture,
+ new UserFixture,
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isMocked());
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame($this->user(), $responseA->json());
+
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame($this->user(), $responseB->json());
+ }
+
+ public function testItWillThrowAnExceptionIfTheCustomFixtureClassIsMissingAName(): void
+ {
+ $mockClient = new MockClient([
+ new MissingNameFixture,
+ ]);
+
+ $this->expectException(FixtureException::class);
+ $this->expectExceptionMessage('The fixture must have a name');
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+ }
+
+ public function testYouCanHideSensitiveJsonBodyParametersAndHeadersBeforeTheFixtureIsStored(): void
+ {
+ $mockClient = new MockClient([
+ new SafeUserFixture,
+ new SafeUserFixture,
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame($this->user(), $responseA->json());
+
+ $this->assertSame('cloudflare', $responseA->header('Server'));
+ $this->assertSame('no-cache, private', $responseA->header('Cache-Control'));
+
+ $this->assertFalse($responseA->isFaked());
+ $this->assertTrue($responseB->isFaked());
+
+ $this->assertSame([
+ 'name' => 'Sxxx',
+ 'actual_name' => 'REDACTED',
+ 'twitter' => '@saloonphp',
+ ], $responseB->json());
+
+ $this->assertSame('secret', $responseB->header('Server'));
+ $this->assertSame('no-cache, private, yeehaw', $responseB->header('Cache-Control'));
+
+ $fixtureData = json_decode($this->files->get($this->fixturePath . '/user.json'), true, 512, JSON_THROW_ON_ERROR);
+
+ // Recorded fixtures list every header value.
+ $this->assertSame(['secret'], $fixtureData['headers']['Server']);
+ $this->assertSame(['no-cache, private, yeehaw'], $fixtureData['headers']['Cache-Control']);
+ $this->assertSame(json_encode([
+ 'name' => 'Sxxx',
+ 'actual_name' => 'REDACTED',
+ 'twitter' => '@saloonphp',
+ ], JSON_THROW_ON_ERROR), $fixtureData['data']);
+ }
+
+ public function testTheFixtureSwapToolWorksOnMultipleAttemptsAndRecursively(): void
+ {
+ $mockClient = new MockClient([
+ new SuperheroFixture,
+ new SuperheroFixture,
+ ]);
+
+ $responseA = (new TestConnector)->send(new PagedSuperheroRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new PagedSuperheroRequest, $mockClient);
+
+ foreach ($responseA->json()['data'] as $superhero) {
+ $this->assertSame('DC Comics', $superhero['publisher']);
+ }
+
+ foreach ($responseB->json()['data'] as $superhero) {
+ $this->assertSame('REDACTED', $superhero['publisher']);
+ }
+ }
+
+ public function testYouCanDefineACustomRedactionMethodForNonJsonBodyFixtures(): void
+ {
+ $mockClient = new MockClient([
+ new BeforeSaveUserFixture,
+ new BeforeSaveUserFixture,
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(200, $responseA->status());
+ $this->assertSame(222, $responseB->status());
+ }
+
+ public function testYouCanDefineRegexPatternsThatShouldBeUsedToReplaceTheBodyInFixtures(): void
+ {
+ $mockClient = new MockClient([
+ new RegexUserFixture,
+ new RegexUserFixture,
+ ]);
+
+ $responseA = (new TestConnector)->send(new UserRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame($this->user(), $responseA->json());
+
+ $this->assertFalse($responseA->isFaked());
+ $this->assertTrue($responseB->isFaked());
+
+ $this->assertSame([
+ 'name' => 'Sxxxmyjo20',
+ 'actual_name' => 'Sxxx',
+ 'twitter' => '**REDACTED-TWITTER**',
+ ], $responseB->json());
+
+ $fixtureData = json_decode($this->files->get($this->fixturePath . '/user.json'), true, 512, JSON_THROW_ON_ERROR);
+
+ $this->assertSame(json_encode([
+ 'name' => 'Sxxxmyjo20',
+ 'actual_name' => 'Sxxx',
+ 'twitter' => '**REDACTED-TWITTER**',
+ ], JSON_THROW_ON_ERROR), $fixtureData['data']);
+ }
+
+ public function testRequestAndResponseMiddlewareIsInvokedWhenUsingFakeResponses(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam'], 200, ['X-Foo' => 'Bar']),
+ MockResponse::make(['name' => 'Alex']),
+ MockResponse::make(['error' => 'Server Unavailable'], 500),
+ ]);
+
+ $middlewareA = false;
+ $middlewareB = false;
+ $middlewareC = false;
+ $middlewareD = false;
+
+ $connector = new MiddlewareTestConnector(
+ function () use (&$middlewareA): void {
+ $middlewareA = true;
+ },
+ function () use (&$middlewareB): void {
+ $middlewareB = true;
+ },
+ );
+
+ $request = new UserRequest;
+
+ $request->middleware()->onRequest(function () use (&$middlewareC): void {
+ $middlewareC = true;
+ });
+
+ $request->middleware()->onResponse(function () use (&$middlewareD): void {
+ $middlewareD = true;
+ });
+
+ $connector->send($request);
+
+ $this->assertTrue($middlewareA);
+ $this->assertTrue($middlewareB);
+ $this->assertTrue($middlewareC);
+ $this->assertTrue($middlewareD);
+ }
+
+ public function testFixturesAreStillRecordedOnTheFirstRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::fixture('user'), // Test Exact Route
+ ]);
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSent(UserRequest::class);
+ }
+
+ public function testFixturesCanBeCreatedInSubDirectories(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::fixture('my-integration/user'), // Test Exact Route
+ ]);
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSent(UserRequest::class);
+ $this->assertTrue($this->files->exists($this->fixturePath . '/my-integration/user.json'));
+ }
+
+ public function testWithoutCacheRecordsAMissingFixtureFromTheTransport(): void
+ {
+ $version = 0;
+ Http::fake(['tests.saloon.dev/api/cached-user' => function () use (&$version): PromiseInterface {
+ return Http::response(['version' => ++$version]);
+ }]);
+
+ $this->assertSame(['version' => 1], (new TestConnector)->send(new WithoutCacheUserRequest)->json());
+
+ $mockClient = (new MockClient([
+ WithoutCacheUserRequest::class => MockResponse::fixture('cached-user'),
+ ]))->withoutCache();
+
+ $recorded = (new TestConnector)->send(new WithoutCacheUserRequest, $mockClient);
+
+ $this->assertFalse($recorded->isCached());
+ $this->assertSame(['version' => 2], $recorded->json());
+ $this->assertSame(
+ '{"version":2}',
+ RecordedResponse::fromFile($this->files->get($this->fixturePath . '/cached-user.json'))->data,
+ );
+
+ $cached = (new TestConnector)->send(new WithoutCacheUserRequest);
+
+ $this->assertTrue($cached->isCached());
+ $this->assertSame(['version' => 1], $cached->json());
+ }
+
+ /**
+ * Get the user returned by the test API.
+ *
+ * @return array
+ */
+ protected function user(): array
+ {
+ return [
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ];
+ }
+}
+
+class MiddlewareTestConnector extends TestConnector
+{
+ /**
+ * Create a connector that registers the given middleware.
+ */
+ public function __construct(
+ protected Closure $onRequest,
+ protected Closure $onResponse,
+ ) {
+ parent::__construct();
+ }
+
+ /**
+ * Register the connector middleware.
+ */
+ public function boot(PendingRequest $pendingRequest): void
+ {
+ $pendingRequest->middleware()->onRequest($this->onRequest);
+ $pendingRequest->middleware()->onResponse($this->onResponse);
+ }
+}
+
+class WithoutCacheUserRequest extends UserRequest implements Cacheable
+{
+ use HasCaching;
+
+ /**
+ * Define the endpoint for the request.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/cached-user';
+ }
+
+ /**
+ * Get the cache lifetime.
+ */
+ public function cacheFor(): int
+ {
+ return 60;
+ }
+}
diff --git a/tests/Saloon/Feature/SimulatedResponsePayloadTest.php b/tests/Saloon/Feature/SimulatedResponsePayloadTest.php
new file mode 100644
index 0000000000..52c80505ec
--- /dev/null
+++ b/tests/Saloon/Feature/SimulatedResponsePayloadTest.php
@@ -0,0 +1,43 @@
+ 'Sam'], 200, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $fakeResponse = new FakeResponse(['name' => 'Gareth'], 201, ['X-Greeting' => 'Hello']);
+
+ $request = new UserRequest;
+ $request->middleware()->onRequest(fn (): FakeResponse => $fakeResponse);
+
+ $response = TestConnector::make()->send($request, $mockClient);
+
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+ $this->assertSame(201, $response->status());
+ $this->assertSame('Hello', $response->header('X-Greeting'));
+ }
+}
diff --git a/tests/Saloon/Fixtures/Connectors/DifferentServiceConnector.php b/tests/Saloon/Fixtures/Connectors/DifferentServiceConnector.php
new file mode 100644
index 0000000000..7b61ae0115
--- /dev/null
+++ b/tests/Saloon/Fixtures/Connectors/DifferentServiceConnector.php
@@ -0,0 +1,31 @@
+pendingRequest = $pendingRequest;
+
+ parent::__construct($message);
+ }
+
+ /**
+ * Get the pending request.
+ */
+ public function getPendingRequest(): PendingRequest
+ {
+ return $this->pendingRequest;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php b/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php
new file mode 100644
index 0000000000..1559089a2b
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php
@@ -0,0 +1,29 @@
+statusCode = 222;
+
+ return $recordedResponse;
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php b/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php
new file mode 100644
index 0000000000..8e2b80415e
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php
@@ -0,0 +1,19 @@
+ $pendingRequest->request()::class], 200);
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php b/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php
new file mode 100644
index 0000000000..1883ff6be3
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php
@@ -0,0 +1,11 @@
+ '**REDACTED-TWITTER**',
+ // The name Sam
+ '/Sam/' => fn (string $value): string => substr_replace($value, 'xxx', 1),
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php b/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php
new file mode 100644
index 0000000000..5627e16abc
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php
@@ -0,0 +1,49 @@
+ 'secret',
+
+ // Check for case-insensitive
+ 'cache-control' => function (string $value): string {
+ return $value . ', yeehaw';
+ },
+ ];
+ }
+
+ /**
+ * Swap any sensitive JSON parameters.
+ */
+ protected function defineSensitiveJsonParameters(): array
+ {
+ return [
+ // You can also define callables that should be run to replace the value!
+
+ 'name' => static function (string $value): string {
+ return substr_replace($value, 'xxx', 1);
+ },
+ 'actual_name' => 'REDACTED',
+ 'twitter' => '@saloonphp',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php b/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php
new file mode 100644
index 0000000000..5a69de4c83
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php
@@ -0,0 +1,28 @@
+ 'REDACTED',
+ ];
+ }
+}
diff --git a/tests/Saloon/Fixtures/Mocking/UserFixture.php b/tests/Saloon/Fixtures/Mocking/UserFixture.php
new file mode 100644
index 0000000000..5dc03c1bf4
--- /dev/null
+++ b/tests/Saloon/Fixtures/Mocking/UserFixture.php
@@ -0,0 +1,18 @@
+ 'Call Me',
- ];
- }
+ // Upstream's defaultData() is removed: nothing calls it, so the request has no default body.
/**
* Define the default request options.
diff --git a/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php b/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php
new file mode 100644
index 0000000000..be3762a38e
--- /dev/null
+++ b/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php
@@ -0,0 +1,26 @@
+ 'Taylor'], 201, ['X-Count' => 2]);
- $text = new FakeResponse('complete');
-
- $this->assertInstanceOf(JsonBodyRepository::class, $json->body());
- $this->assertSame('{"name":"Taylor"}', (string) $json->createPsrResponse()->getBody());
- $this->assertSame(201, $json->status());
- $this->assertSame(['2'], $json->createPsrResponse()->getHeader('X-Count'));
- $this->assertInstanceOf(StringBodyRepository::class, $text->body());
- $this->assertSame('complete', (string) $text->createPsrResponse()->getBody());
- }
-
- public function testItResolvesConfiguredExceptionsAgainstThePendingRequest(): void
- {
- $pendingRequest = m::mock(PendingRequest::class);
- $exception = new RuntimeException('Failed.');
- $response = (new FakeResponse)->throw(
- fn (PendingRequest $request): RuntimeException => $request === $pendingRequest
- ? $exception
- : new RuntimeException('Wrong request.'),
- );
-
- $this->assertSame($exception, $response->getException($pendingRequest));
- $this->assertNull((new FakeResponse)->getException($pendingRequest));
- }
-}
diff --git a/tests/Saloon/Http/MockClientTest.php b/tests/Saloon/Http/MockClientTest.php
deleted file mode 100644
index 51c94af0b9..0000000000
--- a/tests/Saloon/Http/MockClientTest.php
+++ /dev/null
@@ -1,295 +0,0 @@
-http();
- $client = new MockClient([
- new MockResponse('sequence'),
- MockConnectorStub::class => new MockResponse('connector'),
- 'https://api.example.com/users/*' => new MockResponse('url'),
- MockRequestA::class => new MockResponse('request'),
- ]);
- $manager = $this->manager($http);
-
- $request = $manager->send(new MockConnectorStub, new MockRequestA, $client);
- $connector = $manager->send(new MockConnectorStub, new MockRequestB, $client);
- $url = $manager->send(new OtherMockConnectorStub, new MockRequestB, $client);
- $sequence = $manager->send(new OtherMockConnectorStub, new MockRequestC, $client);
-
- $this->assertSame('request', $request->body());
- $this->assertSame('connector', $connector->body());
- $this->assertSame('url', $url->body());
- $this->assertSame('sequence', $sequence->body());
- $this->assertTrue($request->isMocked());
- $this->assertFalse($client->isEmpty());
- $client->assertSentCount(4);
- $http->assertNothingSent();
- }
-
- public function testUnmatchedRequestsAreStrictUnlessTheirUrlIsAllowed(): void
- {
- $http = $this->http();
- $http->fake(['*' => Factory::response('network')]);
- $manager = $this->manager($http);
- $client = (new MockClient)->allowStrayRequests(['https://api.example.com/allowed*']);
-
- $allowed = $manager->send(new OtherMockConnectorStub, new AllowedMockRequest, $client);
-
- $this->assertSame('network', $allowed->body());
- $this->assertFalse($allowed->isMocked());
- $client->assertSentCount(1);
-
- $this->expectException(NoMockResponseFoundException::class);
-
- $manager->send(new OtherMockConnectorStub, new MockRequestC, $client);
- }
-
- public function testExplicitRequestAndGlobalMockClientsUseTheExpectedPrecedence(): void
- {
- $http = $this->http();
- $manager = $this->manager($http);
- $manager->fake([new MockResponse('global')]);
- $requestClient = new MockClient([new MockResponse('request')]);
- $explicitClient = new MockClient([new MockResponse('explicit')]);
- $request = (new MockRequestA)->withMockClient($requestClient);
-
- $this->assertSame('explicit', $manager->send(new MockConnectorStub, $request, $explicitClient)->body());
- $this->assertSame('request', $manager->send(new MockConnectorStub, $request)->body());
- $this->assertSame('global', $manager->send(new MockConnectorStub, new MockRequestA)->body());
- }
-
- public function testAssertionsSupportRequestClassesUrlsAndTypedClosureUnions(): void
- {
- $http = $this->http();
- $manager = $this->manager($http);
- $client = new MockClient([
- new MockResponse('c'),
- new MockResponse('a'),
- new MockResponse('b'),
- ]);
- $manager->send(new OtherMockConnectorStub, new MockRequestC, $client);
- $manager->send(new MockConnectorStub, new MockRequestA, $client);
- $manager->send(new MockConnectorStub, new MockRequestB, $client);
- $typedCalls = 0;
-
- $client->assertSent(function (MockRequestA|MockRequestB $request) use (&$typedCalls): bool {
- ++$typedCalls;
-
- return $request instanceof MockRequestA;
- });
- $client->assertSent(MockRequestA::class);
- $client->assertSent('https://api.example.com/users/*');
- $client->assertNotSent('https://api.example.com/missing');
- $client->assertSentInOrder([
- MockRequestC::class,
- MockRequestA::class,
- MockRequestB::class,
- ]);
- $client->assertSentCount(1, MockRequestA::class);
-
- $this->assertSame(1, $typedCalls);
- $this->assertSame(MockRequestB::class, $client->lastRequest()::class);
- $this->assertSame(MockRequestB::class, $client->lastPendingRequest()?->request()::class);
- $this->assertCount(2, $client->recorded(fn (Request $request): bool => $request instanceof MockRequestA || $request instanceof MockRequestB));
- }
-
- public function testMissingFixtureRecordsTheNetworkResponseAndThenReplaysIt(): void
- {
- $http = $this->http();
- $http->fake(['*' => Factory::response('recorded', 201, ['X-Trace' => 'abc'])]);
- $manager = $this->manager($http);
- $client = new MockClient([
- MockRequestA::class => new Fixture('users/show', $this->files),
- ]);
-
- $recorded = $manager->send(new MockConnectorStub, new MockRequestA, $client);
- $replayed = $manager->send(new MockConnectorStub, new MockRequestA, $client);
-
- $this->assertFalse($recorded->isMocked());
- $this->assertTrue($replayed->isMocked());
- $this->assertSame('recorded', $replayed->body());
- $this->assertSame('abc', $replayed->header('X-Trace'));
- $http->assertSentCount(1);
- $client->assertSentCount(2);
- }
-
- public function testGlobalClientDelegatesToTheContainerManagerWithoutStaticState(): void
- {
- $manager = $this->manager($this->http());
- $container = new Container;
- $container->instance('saloon', $manager);
- Container::setInstance($container);
-
- $global = MockClient::global([new MockResponse('global')]);
-
- $this->assertSame($global, MockClient::getGlobal());
- $this->assertSame($global, $manager->mockClient());
-
- MockClient::destroyGlobal();
-
- $this->assertNull(MockClient::getGlobal());
- }
-
- /**
- * Set up the test environment.
- */
- protected function setUp(): void
- {
- parent::setUp();
-
- $this->files = new Filesystem;
- $this->fixturePath = ParallelTesting::tempDir('SaloonMockClientTest');
- $this->files->deleteDirectory($this->fixturePath);
- $this->files->ensureDirectoryExists($this->fixturePath);
- }
-
- /**
- * Clean up the test environment.
- */
- protected function tearDown(): void
- {
- $this->files->deleteDirectory($this->fixturePath);
-
- parent::tearDown();
- }
-
- /**
- * Create an isolated HTTP factory with the Saloon connection.
- */
- protected function http(): Factory
- {
- $http = new Factory;
- $http->registerConnection('saloon');
-
- return $http;
- }
-
- /**
- * Create the manager with isolated framework services.
- */
- protected function manager(Factory $http): SaloonManager
- {
- $config = m::mock(ConfigRepository::class);
- $config->shouldReceive('string')
- ->with('saloon.connection.name')
- ->andReturn('saloon');
- $config->shouldReceive('string')
- ->with('saloon.fixtures.path', m::type(Closure::class))
- ->andReturn($this->fixturePath);
- $config->shouldReceive('boolean')
- ->with('saloon.fixtures.throw_on_missing', false)
- ->andReturn(false);
-
- $manager = new SaloonManager(
- new Sender($http, $config),
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- $config,
- new Dispatcher,
- );
-
- $container = new Container;
- $container->instance('saloon', $manager);
- Container::setInstance($container);
-
- return $manager;
- }
-}
-
-class MockConnectorStub extends Connector
-{
- /**
- * Resolve the integration base URL.
- */
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-}
-
-class OtherMockConnectorStub extends MockConnectorStub
-{
-}
-
-class MockRequestA extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/users/a';
- }
-}
-
-class MockRequestB extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/users/b';
- }
-}
-
-class MockRequestC extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/other';
- }
-}
-
-class AllowedMockRequest extends Request
-{
- protected Method $method = Method::GET;
-
- /**
- * Resolve the request endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/allowed/request';
- }
-}
diff --git a/tests/Saloon/Unit/FixtureDataTest.php b/tests/Saloon/Unit/FixtureDataTest.php
new file mode 100644
index 0000000000..365fcbba23
--- /dev/null
+++ b/tests/Saloon/Unit/FixtureDataTest.php
@@ -0,0 +1,268 @@
+ 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ ];
+
+ $fixtureData = RecordedResponse::fromFile(json_encode($data));
+
+ $this->assertSame($data['statusCode'], $fixtureData->statusCode);
+ $this->assertSame($data['headers'], $fixtureData->headers);
+ $this->assertSame($data['data'], $fixtureData->data);
+ }
+
+ public function testYouCanCreateAMockResponseFromFixtureData(): void
+ {
+ $data = [
+ 'statusCode' => 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ ];
+
+ $fixtureData = RecordedResponse::fromFile(json_encode($data));
+ $mockResponse = $fixtureData->toMockResponse();
+
+ $this->assertEquals(new MockResponse($data['data'], $data['statusCode'], $data['headers']), $mockResponse);
+ }
+
+ /**
+ * @param array $data
+ * @param null|array $expected
+ */
+ #[DataProvider('fixtureFiles')]
+ public function testYouCanJsonSerializeTheFixtureDataOrConvertItIntoAFile(array $data, ?array $expected = null): void
+ {
+ $expected ??= $data;
+
+ $fixtureData = RecordedResponse::fromFile(json_encode($data, JSON_PRETTY_PRINT));
+
+ $serialized = json_encode($fixtureData, JSON_PRETTY_PRINT);
+
+ $this->assertSame(json_encode($expected, JSON_PRETTY_PRINT), $serialized);
+ $this->assertSame($serialized, $fixtureData->toFile());
+ }
+
+ /**
+ * Get fixture files with their expected serialization.
+ *
+ * @return array, 1?: array}>
+ */
+ public static function fixtureFiles(): array
+ {
+ return [
+ 'without context key' => [
+ [
+ 'statusCode' => 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ ],
+ [
+ 'statusCode' => 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ 'context' => [],
+ ],
+ ],
+ 'with context key' => [
+ [
+ 'statusCode' => 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ 'context' => [],
+ ],
+ ],
+ 'with context data' => [
+ [
+ 'statusCode' => 200,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'data' => [
+ 'name' => 'Sam',
+ ],
+ 'context' => [
+ 'test' => 'you can json serialize the fixture data or convert it into a file',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public function testItRoundTripsTextAndContext(): void
+ {
+ $recorded = new RecordedResponse(
+ 201,
+ ['X-Trace' => ['abc']],
+ '{"name":"Taylor"}',
+ ['provider' => 'example'],
+ );
+
+ $restored = RecordedResponse::fromFile($recorded->toFile());
+
+ $this->assertSame(201, $restored->statusCode);
+ $this->assertSame(['X-Trace' => ['abc']], $restored->headers);
+ $this->assertSame('{"name":"Taylor"}', $restored->data);
+ $this->assertSame(['provider' => 'example'], $restored->context);
+ $this->assertSame('{"name":"Taylor"}', (string) $restored->toMockResponse()->createPsrResponse()->getBody());
+ }
+
+ public function testItRoundTripsBinaryResponseData(): void
+ {
+ $recorded = new RecordedResponse(200, data: "\xB1\x31");
+
+ $contents = $recorded->toFile();
+ $restored = RecordedResponse::fromFile($contents);
+
+ $this->assertStringContainsString('"encoding": "base64"', $contents);
+ $this->assertSame("\xB1\x31", $restored->data);
+ }
+
+ public function testItRejectsInvalidBase64Data(): void
+ {
+ $this->expectException(FixtureException::class);
+
+ RecordedResponse::fromFile('{"statusCode":200,"headers":[],"data":"%","encoding":"base64"}');
+ }
+
+ public function testArbitraryDataCanBeMergedInTheFixture(): void
+ {
+ $response = (new TestConnector)->send(new DTORequest, new MockClient([
+ MockResponse::fixture('user')->merge([
+ 'name' => 'Sam Carré',
+ ]),
+ ]));
+
+ $user = $response->dto();
+
+ $this->assertSame('Sam Carré', $user->name);
+ $this->assertSame('Sam', $user->actualName);
+ $this->assertSame('@carre_sam', $user->twitter);
+ }
+
+ public function testArbitraryDataUsingDotNotationCanBeMergedInTheFixture(): void
+ {
+ $response = (new TestConnector)->send(new DTORequest, new MockClient([
+ MockResponse::fixture('users')->merge([
+ 'data.0.twitter' => '@jon_doe',
+ ]),
+ ]));
+
+ $users = $response->json('data');
+
+ $this->assertCount(2, $users);
+ $this->assertSame('@jon_doe', $users[0]['twitter']);
+ $this->assertSame('@janedoe', $users[1]['twitter']);
+ }
+
+ public function testAClosureCanBeUsedToModifyTheMockResponseData(): void
+ {
+ $response = (new TestConnector)->send(new DTORequest, new MockClient([
+ MockResponse::fixture('users')->through(fn (array $data): array => array_merge_recursive($data, [
+ 'data' => [
+ [
+ 'name' => 'Sam',
+ 'actual_name' => 'Carré',
+ 'twitter' => '@carre_sam',
+ ],
+ ],
+ ])),
+ ]));
+
+ $users = $response->json('data');
+
+ $this->assertCount(3, $users);
+ $this->assertSame('@jondoe', $users[0]['twitter']);
+ $this->assertSame('@janedoe', $users[1]['twitter']);
+ $this->assertSame('@carre_sam', $users[2]['twitter']);
+ }
+
+ public function testMergingKeepsAnArrayFixtureBodyAsJson(): void
+ {
+ $files = new Filesystem;
+ $fixturePath = ParallelTesting::tempDir('SaloonFixtureDataTest');
+ $files->deleteDirectory($fixturePath);
+ $files->ensureDirectoryExists($fixturePath);
+ Saloon::fixturePath($fixturePath);
+
+ try {
+ $files->put($fixturePath . '/array.json', json_encode([
+ 'statusCode' => 200,
+ 'headers' => ['Content-Type' => 'application/json'],
+ 'data' => ['name' => 'Sam'],
+ ], JSON_THROW_ON_ERROR));
+
+ $response = MockResponse::fixture('array')
+ ->merge(['twitter' => '@carre_sam'])
+ ->through(fn (array $data): array => [...$data, 'actual_name' => 'Sam'])
+ ->getMockResponse();
+
+ $this->assertInstanceOf(JsonBodyRepository::class, $response->body());
+ $this->assertSame(['name' => 'Sam', 'twitter' => '@carre_sam', 'actual_name' => 'Sam'], $response->body()->all());
+ } finally {
+ $files->deleteDirectory($fixturePath);
+ }
+ }
+}
diff --git a/tests/Saloon/Http/FixtureTest.php b/tests/Saloon/Unit/FixtureTest.php
similarity index 82%
rename from tests/Saloon/Http/FixtureTest.php
rename to tests/Saloon/Unit/FixtureTest.php
index 702d336587..02f2841dcb 100644
--- a/tests/Saloon/Http/FixtureTest.php
+++ b/tests/Saloon/Unit/FixtureTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use Hypervel\Container\Container;
use Hypervel\Filesystem\Filesystem;
@@ -31,7 +31,7 @@ public function testItStoresLoadsRedactsAndTransformsFixtures(): void
201,
['Authorization' => ['Bearer secret'], 'X-Trace' => ['abc']],
'{"token":"secret","nested":{"token":"secret"},"message":"key=secret"}',
- ['existing' => true],
+ ['existing' => true, 'provider' => 'recorded'],
));
$this->assertTrue($this->files->exists($fixture->getFixturePath()));
@@ -60,7 +60,43 @@ public function testItStoresLoadsRedactsAndTransformsFixtures(): void
json_decode((string) $response->body()->all(), true, 512, JSON_THROW_ON_ERROR),
);
$restored = RecordedResponse::fromFile($contents);
- $this->assertSame(['existing' => true, 'provider' => 'example'], $restored->context);
+ $this->assertSame(['provider' => 'recorded', 'existing' => true], $restored->context);
+ $this->assertSame(['provider' => 'example'], $fixture->getContext()->all());
+ }
+
+ public function testHeaderRulesApplyToEachHeaderValue(): void
+ {
+ $fixture = new HeaderFixtureStub('headers', $this->files);
+
+ $fixture->store(new RecordedResponse(
+ 200,
+ [
+ 'Set-Cookie' => ['session=abc', 'token=def'],
+ 'server' => ['nginx'],
+ 'X-Api-Key' => 'abc',
+ ],
+ ));
+
+ $recorded = RecordedResponse::fromFile($this->files->get($fixture->getFixturePath()));
+
+ $this->assertSame(['session=[redacted]', 'token=[redacted]'], $recorded->headers['Set-Cookie']);
+ $this->assertSame(['secret'], $recorded->headers['server']);
+ $this->assertSame('[redacted]', $recorded->headers['X-Api-Key']);
+ }
+
+ public function testArrayDataIsRedactedBeforeStorage(): void
+ {
+ $fixture = new RedactingFixtureStub('array-data', $this->files);
+
+ $fixture->store(new RecordedResponse(200, [], ['token' => 'secret', 'message' => 'key=secret']));
+
+ $contents = $this->files->get($fixture->getFixturePath());
+
+ $this->assertStringNotContainsString('secret', $contents);
+ $this->assertSame(
+ ['token' => '[redacted]', 'message' => '[redacted]'],
+ json_decode(RecordedResponse::fromFile($contents)->data, true, 512, JSON_THROW_ON_ERROR),
+ );
}
#[DataProvider('invalidFixtureNames')]
@@ -248,6 +284,21 @@ protected function defineSensitiveRegexPatterns(): array
}
}
+class HeaderFixtureStub extends Fixture
+{
+ /**
+ * Define sensitive response headers.
+ */
+ protected function defineSensitiveHeaders(): array
+ {
+ return [
+ 'set-cookie' => fn (string $value): string => strstr($value, '=', true) . '=[redacted]',
+ 'Server' => 'secret',
+ 'x-api-key' => '[redacted]',
+ ];
+ }
+}
+
class RegexFixtureStub extends Fixture
{
/**
diff --git a/tests/Saloon/Unit/GlobalMockClientTest.php b/tests/Saloon/Unit/GlobalMockClientTest.php
new file mode 100644
index 0000000000..42b9a4d645
--- /dev/null
+++ b/tests/Saloon/Unit/GlobalMockClientTest.php
@@ -0,0 +1,78 @@
+ 'Sam']),
+ ]);
+
+ $this->assertInstanceOf(MockClient::class, $mockClient);
+ $this->assertSame($mockClient, MockClient::getGlobal());
+ $this->assertSame($mockClient, Saloon::mockClient());
+
+ $connector = new TestConnector;
+ $response = $connector->send(new UserRequest);
+
+ $this->assertTrue($response->isMocked());
+ $this->assertSame(['name' => 'Sam'], $response->json());
+
+ $mockClient->assertSent(UserRequest::class);
+ }
+
+ public function testTheMockClientCanBeDestroyed(): void
+ {
+ $mockClient = MockClient::global();
+
+ $this->assertSame($mockClient, MockClient::getGlobal());
+
+ MockClient::destroyGlobal();
+
+ $this->assertNull(MockClient::getGlobal());
+ }
+
+ public function testALocalMockClientIsGivenPriorityOverTheGlobalMockClient(): void
+ {
+ MockClient::global([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $localMockClient = new MockClient([
+ MockResponse::make(['name' => 'Taylor']),
+ ]);
+
+ $request = (new UserRequest)->withMockClient($localMockClient);
+
+ $response = (new TestConnector)->send($request);
+
+ $this->assertTrue($response->isMocked());
+ $this->assertSame(['name' => 'Taylor'], $response->json());
+
+ $localMockClient->assertSentCount(1);
+ MockClient::global()->assertNothingSent();
+ }
+}
diff --git a/tests/Saloon/Unit/MockClientAssertionsTest.php b/tests/Saloon/Unit/MockClientAssertionsTest.php
new file mode 100644
index 0000000000..a3720746ab
--- /dev/null
+++ b/tests/Saloon/Unit/MockClientAssertionsTest.php
@@ -0,0 +1,340 @@
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSent(UserRequest::class);
+ }
+
+ public function testAssertSentWorksWithAClosure(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500),
+ ]);
+
+ $originalRequest = new UserRequest;
+ $originalResponse = (new TestConnector)->send($originalRequest, $mockClient);
+
+ $mockClient->assertSent(function (Request $request, Response $response) use ($originalRequest, $originalResponse): bool {
+ return $request === $originalRequest && $response === $originalResponse;
+ });
+
+ $newRequest = new ErrorRequest;
+ $newResponse = (new TestConnector)->send($newRequest, $mockClient);
+
+ $mockClient->assertSent(function (Request $request, Response $response) use ($newRequest, $newResponse): bool {
+ return $request === $newRequest && $response === $newResponse;
+ });
+ }
+
+ public function testAssertSentWorksWithAUrl(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSent('saloon.dev/*');
+ $mockClient->assertSent('/user');
+ $mockClient->assertSent('api/user');
+ }
+
+ public function testAssertNotSentWorksWithARequest(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500),
+ ]);
+
+ (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $mockClient->assertNotSent(UserRequest::class);
+ }
+
+ public function testAssertNotSentWorksWithAClosure(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500),
+ ]);
+
+ (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $mockClient->assertNotSent(function (Request $request): bool {
+ return $request instanceof UserRequest;
+ });
+ }
+
+ public function testAssertNotSentWorksWithAUrl(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $mockClient->assertNotSent('google.com/*');
+ $mockClient->assertNotSent('/error');
+ }
+
+ // REMOVED: assertSentJson tests - the deprecated assertSentJson() is not ported; use assertSent() with a closure.
+
+ public function testAssertNothingSentWorksProperly(): void
+ {
+ $mockClient = new MockClient([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $mockClient->assertNothingSent();
+ }
+
+ public function testAssertSentCountWorksProperly(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor']),
+ MockResponse::make(['name' => 'Marcel']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSentCount(3);
+ }
+
+ public function testCanAssertCountOfRequests(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor']),
+ MockResponse::make(['name' => 'Marcel']),
+ MockResponse::make(['message' => 'Error'], 500),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new ErrorRequest, $mockClient);
+
+ $mockClient->assertSentCount(3, UserRequest::class);
+ $mockClient->assertSentCount(1, ErrorRequest::class);
+ }
+
+ public function testAssertSentWithAClosureWorksWithMoreThanOneRequestInTheHistory(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor'], 201),
+ MockResponse::make(['name' => 'Marcel'], 204),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSent(function (Request $request, Response $response): bool {
+ return $response->json() === ['name' => 'Sam'] && $response->status() === 200;
+ });
+
+ $mockClient->assertSent(function (Request $request, Response $response): bool {
+ return $response->json() === ['name' => 'Taylor'] && $response->status() === 201;
+ });
+
+ $mockClient->assertSent(function (Request $request, Response $response): bool {
+ return $response->json() === ['name' => 'Marcel'] && $response->status() === 204;
+ });
+ }
+
+ public function testAssertSentWithATypedClosureSkipsNonMatchingRequestsAndStillFindsAMatch(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['message' => 'Error'], 500),
+ ]);
+
+ $connector = new TestConnector;
+
+ // Recorded responses are checked in send order, so the non-matching request goes first.
+ $connector->send(new ErrorRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $calls = 0;
+
+ $mockClient->assertSent(function (UserRequest $request) use (&$calls): bool {
+ ++$calls;
+
+ return $request->userId === null;
+ });
+
+ $this->assertSame(1, $calls);
+ }
+
+ public function testAssertSentWithAUnionTypedClosureSkipsNonMatchingRequestsAndStillFindsAMatch(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['config' => true]),
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['message' => 'Error'], 500),
+ ]);
+
+ $connector = new TestConnector;
+
+ // The non-matching request goes first so both assertions must skip it.
+ $connector->send(new ConfigRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new ErrorRequest, $mockClient);
+ $calls = 0;
+
+ $mockClient->assertSent(function (UserRequest|ErrorRequest $request) use (&$calls): bool {
+ ++$calls;
+
+ return $request instanceof UserRequest && $request->userId === null;
+ });
+
+ $this->assertSame(1, $calls);
+
+ $mockClient->assertSent(function (UserRequest|ErrorRequest $request) use (&$calls): bool {
+ ++$calls;
+
+ return $request instanceof ErrorRequest;
+ });
+
+ $this->assertSame(3, $calls);
+ }
+
+ public function testAssertSentWithATypedClosureFailsAssertionWhenNoMatchingRequestTypeWasSent(): void
+ {
+ $mockClient = new MockClient([
+ ErrorRequest::class => MockResponse::make(['message' => 'Error'], 500),
+ ]);
+
+ (new TestConnector)->send(new ErrorRequest, $mockClient);
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $mockClient->assertSent(function (UserRequest $request): bool {
+ return true;
+ });
+ }
+
+ public function testTypedClosuresAcceptMixedObjectAndIntersectionTypes(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new MarkedUserRequest, $mockClient);
+ $intersectionCalls = 0;
+
+ $mockClient->assertSent(fn (mixed $request): bool => $request instanceof MarkedUserRequest);
+ $mockClient->assertSent(fn (object $request): bool => $request instanceof MarkedUserRequest);
+ $mockClient->assertSent(function (UserRequest&MarkedRequest $request) use (&$intersectionCalls): bool {
+ ++$intersectionCalls;
+
+ return true;
+ });
+ $mockClient->assertNotSent(fn (string $request): bool => true);
+
+ $this->assertSame(1, $intersectionCalls);
+ }
+
+ public function testItCanAssertRequestsAreSentInASpecificOrder(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor'], 201),
+ MockResponse::make(['name' => 'Marcel'], 204),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+
+ $mockClient->assertSentInOrder([
+ UserRequest::class,
+ function (UserRequest $request, Response $response): bool {
+ return $response->json() === ['name' => 'Taylor'];
+ },
+ '/user',
+ ]);
+ }
+
+ public function testItCanAssertRequestsAreSentInASpecificOrderFailure(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor'], 201),
+ MockResponse::make(['name' => 'Marcel'], 204),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest(userId: 2), $mockClient);
+ $connector->send(new UserRequest(userId: 1), $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $mockClient->assertSentInOrder([
+ UserRequest::class,
+ function (UserRequest $request): bool {
+ return $request->userId === 2;
+ },
+ '/user',
+ ]);
+ }
+}
+
+interface MarkedRequest
+{
+}
+
+class MarkedUserRequest extends UserRequest implements MarkedRequest
+{
+}
diff --git a/tests/Saloon/Unit/MockClientTest.php b/tests/Saloon/Unit/MockClientTest.php
new file mode 100644
index 0000000000..720d88c965
--- /dev/null
+++ b/tests/Saloon/Unit/MockClientTest.php
@@ -0,0 +1,366 @@
+ 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $mockClient = new MockClient([$responseA, $responseB]);
+ $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest);
+
+ $this->assertSame($responseA, $mockClient->match($pendingRequest));
+ $this->assertSame($responseB, $mockClient->match($pendingRequest));
+ $this->assertTrue($mockClient->isEmpty());
+ }
+
+ public function testYouCanCreateConnectorMocks(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $connectorARequest = new UserRequest;
+ $connectorBRequest = new QueryParameterConnectorRequest;
+
+ $mockClient = new MockClient([
+ TestConnector::class => $responseA,
+ QueryParameterConnector::class => $responseB,
+ ]);
+
+ $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($connectorARequest)));
+ $this->assertSame($responseB, $mockClient->match($connectorB->createPendingRequest($connectorBRequest)));
+ $this->assertFalse($mockClient->isEmpty());
+ }
+
+ public function testYouCanCreateRequestMocks(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new QueryParameterConnectorRequest;
+
+ $mockClient = new MockClient([
+ UserRequest::class => $responseA,
+ QueryParameterConnectorRequest::class => $responseB,
+ ]);
+
+ $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA)));
+ $this->assertSame($responseB, $mockClient->match($connectorB->createPendingRequest($requestB)));
+ $this->assertFalse($mockClient->isEmpty());
+ }
+
+ public function testYouCanCreateUrlMocks(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+ $responseC = MockResponse::make(['name' => 'Sam Carré']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ 'google.com/*' => $responseC, // Test Different Route,
+ ]);
+
+ $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA)));
+ $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB)));
+ $this->assertSame($responseC, $mockClient->match($connectorB->createPendingRequest($requestC)));
+ }
+
+ public function testYouCanCreateWildcardUrlMocks(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+ $responseC = MockResponse::make(['name' => 'Sam Carré']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ '*' => $responseC,
+ ]);
+
+ $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA)));
+ $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB)));
+ $this->assertSame($responseC, $mockClient->match($connectorB->createPendingRequest($requestC)));
+ }
+
+ public function testSaloonThrowsAnExceptionIfItCantWorkOutTheUrlResponse(): void
+ {
+ $responseA = MockResponse::make(['name' => 'Sammyjo20']);
+ $responseB = MockResponse::make(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ ]);
+
+ $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA)));
+ $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB)));
+
+ $this->expectException(NoMockResponseFoundException::class);
+ $this->expectExceptionMessage('Saloon was unable to guess a mock response for your request [https://google.com/user], consider using a wildcard url mock or a connector mock.');
+
+ $mockClient->match($connectorB->createPendingRequest($requestC));
+ }
+
+ public function testUrlPatternsMatchWithOrWithoutTheQueryString(): void
+ {
+ $mockClient = new MockClient([
+ 'tests.saloon.dev/api/user?per_page=50' => MockResponse::make(['matched' => 'query']),
+ 'tests.saloon.dev/api/user' => MockResponse::make(['matched' => 'path']),
+ ]);
+ $connector = new TestConnector;
+
+ $withQuery = $connector->send((new QueryParameterRequest)->withQueryParameters(['per_page' => 50]), $mockClient);
+ $otherQuery = $connector->send(new QueryParameterRequest, $mockClient);
+
+ $this->assertSame(['matched' => 'query'], $withQuery->json());
+ $this->assertSame(['matched' => 'path'], $otherQuery->json());
+ $mockClient->assertSent('/user?per_page=50');
+ $mockClient->assertSent('/user?per_page=100');
+ $mockClient->assertNotSent('/user?per_page=25');
+ }
+
+ public function testRequestConnectorUrlAndSequenceResponsesUseTheirPrecedence(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['matched' => 'sequence']),
+ TestConnector::class => MockResponse::make(['matched' => 'connector']),
+ '*/error' => MockResponse::make(['matched' => 'url']),
+ UserRequest::class => MockResponse::make(['matched' => 'request']),
+ ]);
+
+ $request = (new TestConnector)->send(new UserRequest, $mockClient);
+ $connector = (new TestConnector)->send(new ErrorRequest, $mockClient);
+ $url = (new DifferentServiceConnector)->send(new ErrorRequest, $mockClient);
+ $sequence = (new DifferentServiceConnector)->send(new DifferentServiceUserRequest, $mockClient);
+
+ $this->assertSame(['matched' => 'request'], $request->json());
+ $this->assertSame(['matched' => 'connector'], $connector->json());
+ $this->assertSame(['matched' => 'url'], $url->json());
+ $this->assertSame(['matched' => 'sequence'], $sequence->json());
+ $this->assertFalse($mockClient->isEmpty());
+ }
+
+ public function testUnmatchedRequestsAreStrictUnlessTheirUrlIsAllowed(): void
+ {
+ Http::fake(['*' => Http::response('network')]);
+ $mockClient = (new MockClient)->allowStrayRequests(['tests.saloon.dev/api/user']);
+
+ $allowed = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame('network', $allowed->body());
+ $this->assertFalse($allowed->isMocked());
+ $mockClient->assertSentCount(1);
+
+ $this->expectException(NoMockResponseFoundException::class);
+
+ (new TestConnector)->send(new ErrorRequest, $mockClient);
+ }
+
+ public function testYouCanGetAnArrayOfTheRecordedRequests(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor']),
+ MockResponse::make(['name' => 'Marcel']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $responseA = $connector->send(new UserRequest, $mockClient);
+ $responseB = $connector->send(new UserRequest, $mockClient);
+ $responseC = $connector->send(new UserRequest, $mockClient);
+
+ $this->assertSame([$responseA, $responseB, $responseC], $mockClient->recorded()->all());
+ $this->assertSame(
+ [$responseB, $responseC],
+ $mockClient->recorded(fn (Request $request, Response $response): bool => $response->json('name') !== 'Sam')->all(),
+ );
+ }
+
+ public function testYouCanGetTheLastRecordedRequest(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Taylor']),
+ MockResponse::make(['name' => 'Marcel']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest, $mockClient);
+ $connector->send(new UserRequest, $mockClient);
+ $requestC = new UserRequest;
+ $responseC = $connector->send($requestC, $mockClient);
+
+ $this->assertSame($responseC, $mockClient->lastResponse());
+ $this->assertSame($requestC, $mockClient->lastRequest());
+ $this->assertSame($responseC->pendingRequest(), $mockClient->lastPendingRequest());
+ }
+
+ public function testIfThereAreNoRecordedResponsesTheLastResponseWillReturnNull(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $this->assertNull($mockClient->lastResponse());
+ }
+
+ public function testIfThereAreNoRecordedResponsesTheLastRequestWillReturnNull(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $this->assertNull($mockClient->lastRequest());
+ }
+
+ public function testIfTheResponseIsNotTheLastResponseItWillUseTheLoopToFindIt(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['error' => 'Server Error'], 500),
+ ]);
+
+ $responseA = (new TestConnector)->send(new ErrorRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame($responseB, $mockClient->lastResponse());
+
+ // Uses last response
+
+ $this->assertSame($responseB, $mockClient->recorded(fn (Request $request): bool => $request instanceof UserRequest)->first());
+
+ // Does not use the last response
+
+ $this->assertSame($responseA, $mockClient->recorded(fn (Request $request): bool => $request instanceof ErrorRequest)->first());
+ }
+
+ public function testItWillFindTheResponseByUrlIfItIsNotTheLastResponse(): void
+ {
+ $mockClient = new MockClient([
+ '/user' => MockResponse::make(['name' => 'Sam']),
+ '/error' => MockResponse::make(['error' => 'Server Error'], 500),
+ ]);
+
+ $responseA = (new TestConnector)->send(new ErrorRequest, $mockClient);
+ $responseB = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame($responseB, $mockClient->lastResponse());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ $this->assertSame(['error' => 'Server Error'], $responseA->json());
+
+ // Uses last response
+
+ $mockClient->assertSent('/user');
+
+ // Does not use the last response
+
+ $mockClient->assertSent('/error');
+ }
+
+ public function testYouCanMockExceptionsWithAClosure(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Patrick'])->throw(fn (PendingRequest $pendingRequest): TestResponseException => new TestResponseException('Unable to connect!', $pendingRequest)),
+ ]);
+
+ $okResponse = (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertSame(['name' => 'Sam'], $okResponse->json());
+
+ $this->expectException(TestResponseException::class);
+ $this->expectExceptionMessage('Unable to connect!');
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+ }
+
+ public function testYouCanMockNormalExceptions(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Michael'])->throw(new Exception('Custom Exception!')),
+ ]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Custom Exception!');
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+ }
+
+ public function testMockClientCanOptOutOfResponseCacheIntegration(): void
+ {
+ $client = new MockClient([]);
+
+ $this->assertFalse($client->shouldBypassResponseCache());
+
+ $client->withoutCache();
+
+ $this->assertTrue($client->shouldBypassResponseCache());
+ }
+}
diff --git a/tests/Saloon/Unit/MockConfigTest.php b/tests/Saloon/Unit/MockConfigTest.php
new file mode 100644
index 0000000000..a9170bcef5
--- /dev/null
+++ b/tests/Saloon/Unit/MockConfigTest.php
@@ -0,0 +1,99 @@
+files = new Filesystem;
+ $this->fixturePath = ParallelTesting::tempDir('SaloonMockConfigTest');
+ $this->files->deleteDirectory($this->fixturePath);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ $this->files->deleteDirectory($this->fixturePath);
+
+ parent::tearDown();
+ }
+
+ public function testYouCanChangeTheDefaultFixturePath(): void
+ {
+ $this->assertSame(base_path('tests/Fixtures/Saloon'), Saloon::getFixturePath());
+
+ Saloon::fixturePath('saloon-requests/responses');
+
+ $this->assertSame('saloon-requests/responses', Saloon::getFixturePath());
+ }
+
+ public function testYouCanThrowAnExceptionIfTheFixtureDoesNotExist(): void
+ {
+ Saloon::fixturePath($this->fixturePath);
+
+ $this->assertFalse(Saloon::throwsOnMissingFixtures());
+
+ Saloon::throwOnMissingFixtures();
+
+ $mockClient = new MockClient([
+ MockResponse::fixture('example'),
+ ]);
+
+ $this->expectException(FixtureMissingException::class);
+ $this->expectExceptionMessage('example.json" could not be found in storage.');
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+ }
+
+ public function testIfTheFixturePathDoesNotExistItWillBeCreatedWhenAFixtureIsRecorded(): void
+ {
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+ Saloon::fixturePath($this->fixturePath . '/OtherFixturePath');
+
+ $mockClient = new MockClient([
+ MockResponse::fixture('example'),
+ ]);
+
+ $this->assertDirectoryDoesNotExist($this->fixturePath . '/OtherFixturePath');
+
+ (new TestConnector)->send(new UserRequest, $mockClient);
+
+ $this->assertFileExists($this->fixturePath . '/OtherFixturePath/example.json');
+ }
+}
diff --git a/tests/Saloon/Unit/MockResponseTest.php b/tests/Saloon/Unit/MockResponseTest.php
new file mode 100644
index 0000000000..6f0daa24d0
--- /dev/null
+++ b/tests/Saloon/Unit/MockResponseTest.php
@@ -0,0 +1,97 @@
+createPendingRequest(new UserRequest);
+
+ $this->assertSame($responseA->status(), $mockClient->match($pendingRequest)->status());
+ $this->assertSame($responseB->status(), $mockClient->match($pendingRequest)->status());
+ $this->assertSame($responseC->status(), $mockClient->match($pendingRequest)->status());
+ $this->assertTrue($mockClient->isEmpty());
+ }
+
+ public function testAMockResponseCanHaveRawBodyData(): void
+ {
+ $response = MockResponse::make('xml', 200, ['Content-Type' => 'application/json']);
+
+ $this->assertSame(['Content-Type' => 'application/json'], $response->headers());
+ $this->assertSame(200, $response->status());
+ $this->assertInstanceOf(StringBodyRepository::class, $response->body());
+ $this->assertSame('xml', $response->body()->all());
+ }
+
+ public function testAResponseCanBeACustomResponseClass(): void
+ {
+ $mockClient = new MockClient([MockResponse::make(['foo' => 'bar'])]);
+ $request = new UserRequestWithCustomResponse;
+
+ $response = (new TestConnector)->send($request, $mockClient);
+
+ $this->assertInstanceOf(UserResponse::class, $response);
+ $this->assertInstanceOf(UserData::class, $response->customCastMethod());
+ $this->assertSame('bar', $response->foo());
+ }
+
+ public function testItCreatesJsonAndStringResponses(): void
+ {
+ $json = new FakeResponse(['name' => 'Taylor'], 201, ['X-Count' => 2]);
+ $text = new FakeResponse('complete');
+
+ $this->assertInstanceOf(JsonBodyRepository::class, $json->body());
+ $this->assertSame('{"name":"Taylor"}', (string) $json->createPsrResponse()->getBody());
+ $this->assertSame(201, $json->status());
+ $this->assertSame(['2'], $json->createPsrResponse()->getHeader('X-Count'));
+ $this->assertInstanceOf(StringBodyRepository::class, $text->body());
+ $this->assertSame('complete', (string) $text->createPsrResponse()->getBody());
+ }
+
+ public function testItResolvesConfiguredExceptionsAgainstThePendingRequest(): void
+ {
+ $pendingRequest = m::mock(PendingRequest::class);
+ $exception = new RuntimeException('Failed.');
+ $response = (new FakeResponse)->throw(
+ fn (PendingRequest $request): RuntimeException => $request === $pendingRequest
+ ? $exception
+ : new RuntimeException('Wrong request.'),
+ );
+
+ $this->assertSame($exception, $response->getException($pendingRequest));
+ $this->assertNull((new FakeResponse)->getException($pendingRequest));
+ }
+}
diff --git a/tests/Saloon/Unit/MocksRequestsTest.php b/tests/Saloon/Unit/MocksRequestsTest.php
new file mode 100644
index 0000000000..099e29e6bd
--- /dev/null
+++ b/tests/Saloon/Unit/MocksRequestsTest.php
@@ -0,0 +1,85 @@
+ 'Sam']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $responseA = $connector->send(new UserRequest);
+ $responseB = $connector->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertTrue($responseB->isMocked());
+ }
+
+ public function testYouCanProvideAMockClientOnARequestAndAllRequestsWillBeMocked(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $request = new UserRequest;
+ $request->withMockClient($mockClient);
+
+ $response = (new TestConnector)->send($request);
+
+ $this->assertTrue($response->isMocked());
+ }
+
+ public function testRequestMockClientsAreAlwaysPrioritized(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $mockClientB = new MockClient([
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $request = new UserRequest;
+ $request->withMockClient($mockClientB);
+
+ $response = (new TestConnector)->send($request);
+
+ $this->assertTrue($response->isMocked());
+ $this->assertSame(['name' => 'Mantas'], $response->json());
+ }
+
+ public function testAMockClientPassedToSendTakesPrecedenceOverTheRequestClient(): void
+ {
+ $requestClient = new MockClient([MockResponse::make(['client' => 'request'])]);
+ $explicitClient = new MockClient([MockResponse::make(['client' => 'explicit'])]);
+ $request = (new UserRequest)->withMockClient($requestClient);
+
+ $this->assertSame(['client' => 'explicit'], (new TestConnector)->send($request, $explicitClient)->json());
+ $this->assertSame(['client' => 'request'], (new TestConnector)->send($request)->json());
+ }
+}
From 362708c6c909691317e24ee74f3f28d2b3eb0523 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 21:21:21 +0000
Subject: [PATCH 09/26] Reconcile Saloon pools with upstream
Ports Saloon's pool tests (saloonphp/saloon v4 at 2f45e526f9d1) and
maps its asynchronous request tests onto pools, merging the existing
Hypervel pool tests into the upstream-named files. Upstream's live pool
case runs against the engine test server and replaces
SaloonClientTest's pool case.
Defects fixed:
- Pooled requests ignored throwable responses: a 4xx or 5xx response,
or one a request or connector failure hook marks as failed, went to
the response handler and came back from send() as a success. Pool
requests now call throw(), so every response toException() treats as
failed reaches the exception handler, or PoolException::failures()
when there is none. Upstream's real asynchronous path checks these
hooks only after Guzzle rejects a transfer; Hypervel applies the rule
to every response.
- PoolException had no previous exception unless scheduling failed, so
reported pool failures hid their cause. It now chains the failure
that stopped scheduling, otherwise the first request failure, or the
first callback failure if no request failed.
Adaptations, recorded in the README with source comments where upstream
methods are omitted:
- Pools send through coroutines instead of promises: there is no
sendAsync(), isAsynchronous(), setAsynchronous() or promise item.
send() returns the successful responses keyed and ordered by input,
and process() sends without keeping them.
- Concurrency is a positive integer; upstream's callable and unlimited
forms are not supported.
- Handlers receive the response or exception and the key, without the
aggregate promise.
- Unhandled request, callback and item failures throw PoolException
once every started request has finished.
- Ending a request generator stops further requests, but one already
waiting for a slot still starts.
- requests() replaces getRequests() and runs a request callback on each
send.
The pool docs now cover failed responses, the exception cause, sending
a pool again and stopping early.
Validation: Saloon suite (910 tests) with ParaTest, tests/Integration/
Saloon against the engine test servers; composer analyse;
php-cs-fixer. The private SDK generator was checked by source: it uses
pools only through paginators, whose throw middleware already fails
pages.
---
src/docs/saloon.md | 10 +-
src/saloon/README.md | 2 +
src/saloon/src/Exceptions/PoolException.php | 2 +-
src/saloon/src/Http/Connector.php | 3 +
src/saloon/src/Http/PendingRequest.php | 3 +
src/saloon/src/Http/Pool.php | 3 +-
tests/Integration/Saloon/Feature/PoolTest.php | 98 ++++
tests/Integration/Saloon/SaloonClientTest.php | 15 -
tests/Saloon/Feature/PoolTest.php | 258 +++++++++
.../{Http => Unit}/PoolNonCoroutineTest.php | 14 +-
tests/Saloon/{Http => Unit}/PoolTest.php | 488 +++++++++++++++---
11 files changed, 813 insertions(+), 83 deletions(-)
create mode 100644 tests/Integration/Saloon/Feature/PoolTest.php
create mode 100644 tests/Saloon/Feature/PoolTest.php
rename tests/Saloon/{Http => Unit}/PoolNonCoroutineTest.php (89%)
rename tests/Saloon/{Http => Unit}/PoolTest.php (56%)
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index fe13668c7e..331e32e894 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1545,7 +1545,7 @@ $pool = $github->pool([
$responses = $pool->send();
-$responses['hypervel']->throw();
+$responses['hypervel']->json();
```
The pool also accepts a lazy iterable or a producer callback that receives the connector:
@@ -1558,6 +1558,8 @@ $responses = $github->pool(function (Connector $connector) use ($usernames) {
}, concurrency: 10)->send();
```
+The callback runs each time the pool is sent, so a pool whose callback returns a new iterable each time may be sent again. A generator passed directly can only be sent once.
+
The concurrency value must be a positive integer. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines.
You may also create an empty pool and configure it fluently before sending:
@@ -1602,7 +1604,11 @@ $responses = $github->pool(
)->send();
```
-A handled failure is omitted from the returned responses. Without an exception handler, or when a response or exception callback fails, Saloon waits for every started child and then throws `PoolException`. The exception provides `orchestrationFailure`, `failures`, `callbackFailures`, and `responses` methods so no completed work or cause is lost.
+A failed response is a request failure: when the response would throw, such as a `4xx` or `5xx` status or a request your [failure hooks](#error-handling) mark as failed, the exception handler receives its request exception, and `$exception->response()` returns the response. A handled failure is omitted from the returned responses.
+
+Without an exception handler, or when a response or exception callback fails, Saloon waits for every started child and then throws `PoolException`. The exception provides `orchestrationFailure`, `failures`, `callbackFailures`, and `responses` methods so no completed work or cause is lost. Its previous exception is the failure that stopped scheduling, otherwise the first request failure, or the first callback failure if no request failed, so reported pool failures show their cause.
+
+To stop sending early, end the request generator. Requests that have already started, or that are waiting for a free slot, still run to completion.
## Caching
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 114fa3cf27..6fd335d32c 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -18,6 +18,8 @@ Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeRe
Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties and `handleRetry()` methods; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries).
+Pools send requests concurrently through coroutines instead of promises, so there is no `sendAsync()`, pending requests have no `isAsynchronous()` or `setAsynchronous()`, and pools accept requests but not promises. A pool's `send()` waits for every request and returns the successful responses keyed and ordered as the requests were given; `process()` sends them without keeping the responses. Concurrency is a positive integer: upstream's callable and unlimited `0` forms are not supported, because the coroutine pool has a fixed number of slots. Handlers receive the response or exception and the request's key, without upstream's aggregate promise. Ending a request generator stops further requests, but a request already waiting for a slot still starts and running requests are not canceled. Every response that `toException()` treats as failed, including through custom failure hooks, goes to the exception handler; upstream's real asynchronous path checks them only after Guzzle rejects a transfer. Unhandled request failures, handler failures and invalid items throw `PoolException` once every started request has finished, instead of being ignored or ending the pool early. `requests()` replaces `getRequests()` and calls a request callback each time the pool is sent, so a callback that returns a new iterable lets the pool be sent again. See [Concurrent Requests](https://hypervel.org/docs/saloon#concurrent-requests).
+
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
diff --git a/src/saloon/src/Exceptions/PoolException.php b/src/saloon/src/Exceptions/PoolException.php
index 978b02e58b..93c2bbc688 100644
--- a/src/saloon/src/Exceptions/PoolException.php
+++ b/src/saloon/src/Exceptions/PoolException.php
@@ -24,7 +24,7 @@ public function __construct(
) {
parent::__construct(
'One or more requests or callbacks failed while processing the Saloon pool.',
- previous: $orchestrationFailure,
+ previous: $orchestrationFailure ?? array_first($failures) ?? array_first($callbackFailures),
);
}
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index a5ea19f827..7fa32cd4a6 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -57,6 +57,9 @@ public function send(Request $request, ?MockClient $mockClient = null): Response
return $manager->send($this, $request, $mockClient);
}
+ // sendAsync() is not included: pool() sends concurrently through coroutines instead of promises. Upstream's
+ // deprecated sendAndRetry() is not included either; use retry() or defaultRetryPolicy(). See the package README.
+
/**
* Prepare a request for sending through this connector.
*
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 7f390bd37b..9bac962e83 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -617,6 +617,9 @@ public function fakeResponse(): ?FakeResponse
return $this->fakeResponse;
}
+ // isAsynchronous() and setAsynchronous() are not included: every pending request is sent synchronously, and pools
+ // send concurrently through coroutines. See the package README.
+
/**
* Validate the request's caching configuration.
*/
diff --git a/src/saloon/src/Http/Pool.php b/src/saloon/src/Http/Pool.php
index b9903d0b25..23cb58b96d 100644
--- a/src/saloon/src/Http/Pool.php
+++ b/src/saloon/src/Http/Pool.php
@@ -211,7 +211,8 @@ protected function orchestrate(bool $collectResponses): array
&$callbackFailures,
): void {
try {
- $response = $this->connector->send($request);
+ // Throwable responses are failures, so the exception handler receives every failed request.
+ $response = $this->connector->send($request)->throw();
} catch (CanceledException $exception) {
$failures[$inputPosition] = [$key, new ChildCancellationException(
'A child coroutine running a Saloon pool request was canceled while its owner remained active.',
diff --git a/tests/Integration/Saloon/Feature/PoolTest.php b/tests/Integration/Saloon/Feature/PoolTest.php
new file mode 100644
index 0000000000..cd1eb34404
--- /dev/null
+++ b/tests/Integration/Saloon/Feature/PoolTest.php
@@ -0,0 +1,98 @@
+setUpInteractsWithServer();
+ }
+
+ public function testYouCanCreateAPoolOnAConnector(): void
+ {
+ $connector = new TestConnector($this->serverUrl());
+ $handled = [];
+ $exceptions = [];
+
+ $pool = $connector->pool([
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new ErrorRequest,
+ ]);
+
+ $pool->setConcurrency(6);
+
+ $pool->withResponseHandler(function (Response $response, int $key) use (&$handled): void {
+ $handled[$key] = $response;
+ });
+
+ $pool->withExceptionHandler(function (RequestException $exception, int $key) use (&$exceptions): void {
+ $exceptions[$key] = $exception;
+ });
+
+ $responses = $pool->send();
+
+ // Responses follow input order whatever order the requests complete in.
+ $this->assertSame([0, 1, 2, 3, 4], array_keys($responses));
+
+ ksort($handled);
+
+ $this->assertSame($responses, $handled);
+
+ foreach ($responses as $response) {
+ $this->assertFalse($response->isMocked());
+ $this->assertSame(200, $response->status());
+ $this->assertSame([
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ], $response->json());
+ }
+
+ $this->assertSame([5], array_keys($exceptions));
+ $this->assertSame(500, $exceptions[5]->response()->status());
+ $this->assertFalse($exceptions[5]->response()->isMocked());
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Integration/Saloon/SaloonClientTest.php b/tests/Integration/Saloon/SaloonClientTest.php
index a4043200dd..edcd3da494 100644
--- a/tests/Integration/Saloon/SaloonClientTest.php
+++ b/tests/Integration/Saloon/SaloonClientTest.php
@@ -9,7 +9,6 @@
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Request;
-use Hypervel\Saloon\Http\Response;
use Hypervel\Saloon\SaloonServiceProvider;
use Hypervel\Testbench\TestCase;
@@ -62,20 +61,6 @@ public function testSequentialRequestsReuseTheNamedConnection(): void
$this->assertSame([0, 0, 0], array_slice($connectionTimes, 1));
}
- public function testPoolSendsConcurrentRequestsThroughTheSameConnector(): void
- {
- $connector = new SaloonEngineConnector($this->serverUrl());
- $responses = $connector->pool(
- array_map(fn (): SaloonEngineRequest => new SaloonEngineRequest, range(1, 6)),
- concurrency: 3,
- )->send();
-
- $this->assertSame(
- array_fill(0, 6, 'Hello World.'),
- array_map(fn (Response $response): string => $response->body(), $responses),
- );
- }
-
/**
* Get the test server URL.
*/
diff --git a/tests/Saloon/Feature/PoolTest.php b/tests/Saloon/Feature/PoolTest.php
new file mode 100644
index 0000000000..d280642f18
--- /dev/null
+++ b/tests/Saloon/Feature/PoolTest.php
@@ -0,0 +1,258 @@
+pool([
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ ]);
+
+ $pool->setConcurrency(5);
+
+ $pool->withExceptionHandler(function (FatalRequestException $exception, int $key) use (&$exceptions): void {
+ $exceptions[$key] = $exception;
+ });
+
+ $this->assertSame([], $pool->send());
+ $this->assertCount(5, $exceptions);
+
+ foreach ($exceptions as $exception) {
+ $this->assertInstanceOf(ConnectionException::class, $exception->getPrevious());
+ $this->assertInstanceOf(PendingRequest::class, $exception->pendingRequest());
+ }
+ }
+
+ public function testYouCanUsePoolWithAMockClientAddedAndItWontSendRealRequests(): void
+ {
+ Http::fake();
+
+ $mockResponses = [
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ MockResponse::make(['name' => 'Emily']),
+ MockResponse::make(['name' => 'Error'], 500),
+ ];
+
+ Saloon::fake($mockResponses);
+
+ $connector = new TestConnector;
+ $handled = [];
+ $exceptions = [];
+
+ $pool = $connector->pool([
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ new ErrorRequest,
+ ]);
+
+ $pool->setConcurrency(6);
+
+ $pool->withResponseHandler(function (Response $response, int $key) use (&$handled): void {
+ $handled[$key] = $response;
+ });
+
+ $pool->withExceptionHandler(function (RequestException $exception, int $key) use (&$exceptions): void {
+ $exceptions[$key] = $exception;
+ });
+
+ $responses = $pool->send();
+
+ $this->assertSame([0, 1, 2, 3], array_keys($handled));
+
+ foreach ($handled as $key => $response) {
+ $this->assertSame($mockResponses[$key]->body()->all(), $response->json());
+ }
+
+ $this->assertSame([4], array_keys($exceptions));
+ $this->assertSame(500, $exceptions[4]->response()->status());
+ $this->assertSame(['name' => 'Error'], $exceptions[4]->response()->json());
+ $this->assertEquals($exceptions[4]->response()->toException(), $exceptions[4]);
+ $this->assertSame($handled, $responses);
+ Http::assertNothingSent();
+ }
+
+ public function testPooledRequestsRunTheirMiddlewareWhenSentAndReturnCustomResponses(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['foo' => 'bar']),
+ ]);
+
+ $request = new UserRequest;
+ $events = [];
+ $request->middleware()
+ ->onRequest(function () use (&$events): void {
+ $events[] = 'request';
+ })
+ ->onResponse(function () use (&$events): void {
+ $events[] = 'response';
+ });
+
+ $pool = (new TestConnector)->pool([$request, new UserRequestWithCustomResponse]);
+
+ $this->assertSame([], $events);
+
+ $responses = $pool->send();
+
+ $this->assertSame(['request', 'response'], $events);
+ $this->assertSame(['name' => 'Sam'], $responses[0]->json());
+ $this->assertSame(200, $responses[0]->status());
+ $this->assertInstanceOf(UserResponse::class, $responses[1]);
+ $this->assertInstanceOf(UserData::class, $responses[1]->customCastMethod());
+ $this->assertSame('bar', $responses[1]->foo());
+ }
+
+ public function testHandledRequestFailuresAreOmittedAfterEveryChildSettles(): void
+ {
+ Saloon::fake([
+ ErrorRequest::class => MockResponse::make()->throw(
+ fn (PendingRequest $pendingRequest): TestResponseException => new TestResponseException('Unable to connect!', $pendingRequest),
+ ),
+ UserRequest::class => MockResponse::make(['ok' => true]),
+ ]);
+ $handled = [];
+
+ $responses = (new TestConnector)->pool([
+ 'failed' => new ErrorRequest,
+ 'successful' => new UserRequest,
+ ])->withExceptionHandler(function (TestResponseException $exception, string $key) use (&$handled): void {
+ $handled[$key] = $exception;
+ })->send();
+
+ $this->assertSame(['failed'], array_keys($handled));
+ $this->assertSame('Unable to connect!', $handled['failed']->getMessage());
+ $this->assertInstanceOf(ErrorRequest::class, $handled['failed']->getPendingRequest()->request());
+ $this->assertSame(['successful'], array_keys($responses));
+ }
+
+ public function testPoolExceptionPreservesRequestCallbackAndPartialResults(): void
+ {
+ $sendFailure = new RuntimeException('send failed');
+ $callbackFailure = new RuntimeException('callback failed');
+ Saloon::fake([
+ ErrorRequest::class => MockResponse::make()->throw($sendFailure),
+ UserRequest::class => MockResponse::make(['ok' => true]),
+ ]);
+
+ try {
+ (new TestConnector)->pool([
+ 'failed' => new ErrorRequest,
+ 'successful' => new UserRequest,
+ ])->withResponseHandler(function () use ($callbackFailure): never {
+ throw $callbackFailure;
+ })->send();
+ $this->fail('The pool exception was not thrown.');
+ } catch (PoolException $exception) {
+ $this->assertSame(['failed' => $sendFailure], $exception->failures());
+ $this->assertSame(['successful' => $callbackFailure], $exception->callbackFailures());
+ $this->assertSame(['successful'], array_keys($exception->responses()));
+ $this->assertNull($exception->orchestrationFailure());
+ $this->assertSame($sendFailure, $exception->getPrevious());
+ }
+ }
+
+ public function testThrowableResponsesWithoutAnExceptionHandlerArePoolFailures(): void
+ {
+ $mockClient = Saloon::fake([
+ ErrorRequest::class => MockResponse::make(['message' => 'Server Error'], 500),
+ CustomFailHandlerRequest::class => MockResponse::make('Yee-naw: Something went wrong'),
+ UserRequest::class => MockResponse::make(['ok' => true]),
+ ]);
+
+ try {
+ (new TestConnector)->pool([
+ 'server-error' => new ErrorRequest,
+ 'custom-failure' => new CustomFailHandlerRequest,
+ 'successful' => new UserRequest,
+ ])->send();
+ $this->fail('The pool exception was not thrown.');
+ } catch (PoolException $exception) {
+ $failures = $exception->failures();
+ $recorded = $mockClient->recorded()->keyBy(fn (Response $response): string => $response->request()::class);
+
+ $this->assertSame(['server-error', 'custom-failure'], array_keys($failures));
+ $this->assertInstanceOf(RequestException::class, $failures['server-error']);
+ $this->assertSame($recorded[ErrorRequest::class], $failures['server-error']->response());
+ $this->assertSame(500, $failures['server-error']->status());
+ $this->assertInstanceOf(RequestException::class, $failures['custom-failure']);
+ $this->assertSame($recorded[CustomFailHandlerRequest::class], $failures['custom-failure']->response());
+ $this->assertSame(200, $failures['custom-failure']->status());
+ $this->assertSame([], $exception->callbackFailures());
+ $this->assertSame(['successful'], array_keys($exception->responses()));
+ $this->assertSame($failures['server-error'], $exception->getPrevious());
+ }
+ }
+
+ public function testACallbackFailureIsThePoolExceptionCauseWhenNoRequestFails(): void
+ {
+ $callbackFailure = new RuntimeException('callback failed');
+ Saloon::fake([MockResponse::make(['ok' => true])]);
+
+ try {
+ (new TestConnector)->pool(['successful' => new UserRequest])
+ ->withResponseHandler(function () use ($callbackFailure): never {
+ throw $callbackFailure;
+ })
+ ->send();
+ $this->fail('The pool exception was not thrown.');
+ } catch (PoolException $exception) {
+ $this->assertSame([], $exception->failures());
+ $this->assertSame(['successful' => $callbackFailure], $exception->callbackFailures());
+ $this->assertSame(['successful'], array_keys($exception->responses()));
+ $this->assertSame($callbackFailure, $exception->getPrevious());
+ }
+ }
+}
diff --git a/tests/Saloon/Http/PoolNonCoroutineTest.php b/tests/Saloon/Unit/PoolNonCoroutineTest.php
similarity index 89%
rename from tests/Saloon/Http/PoolNonCoroutineTest.php
rename to tests/Saloon/Unit/PoolNonCoroutineTest.php
index ed6bf6ef65..483f8a8cce 100644
--- a/tests/Saloon/Http/PoolNonCoroutineTest.php
+++ b/tests/Saloon/Unit/PoolNonCoroutineTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
@@ -64,15 +64,24 @@ protected function manager(): SaloonManager
class PoolNonCoroutineConnectorStub extends Connector
{
+ /**
+ * Create a connector that sends through the given manager.
+ */
public function __construct(protected SaloonManager $manager)
{
}
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Send a request through the isolated manager.
+ */
public function send(Request $request, ?MockClient $mockClient = null): Response
{
return $this->manager->send($this, $request, $mockClient);
@@ -83,6 +92,9 @@ class PoolNonCoroutineRequestStub extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Define the endpoint for the request.
+ */
public function resolveEndpoint(): string
{
return '/users';
diff --git a/tests/Saloon/Http/PoolTest.php b/tests/Saloon/Unit/PoolTest.php
similarity index 56%
rename from tests/Saloon/Http/PoolTest.php
rename to tests/Saloon/Unit/PoolTest.php
index 056d56bda0..07c3ae526b 100644
--- a/tests/Saloon/Http/PoolTest.php
+++ b/tests/Saloon/Unit/PoolTest.php
@@ -2,15 +2,18 @@
declare(strict_types=1);
-namespace Hypervel\Tests\Saloon\Http;
+namespace Hypervel\Tests\Saloon\Unit;
use DateInterval;
use DateTimeInterface;
+use Generator;
+use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\Cache\ArrayStore;
use Hypervel\Cache\Repository;
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Coroutine\Exceptions\ChildCancellationException;
use Hypervel\Engine\Channel;
@@ -28,6 +31,7 @@
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Exceptions\InvalidPoolItemException;
use Hypervel\Saloon\Exceptions\PoolException;
+use Hypervel\Saloon\Facades\Saloon;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Faking\MockClient;
use Hypervel\Saloon\Http\Faking\MockResponse;
@@ -37,7 +41,11 @@
use Hypervel\Saloon\Http\Sender;
use Hypervel\Saloon\RateLimit\Traits\HasRateLimits;
use Hypervel\Saloon\SaloonManager;
-use Hypervel\Tests\TestCase;
+use Hypervel\Saloon\SaloonServiceProvider;
+use Hypervel\Support\Collection;
+use Hypervel\Testbench\TestCase;
+use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
+use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use InvalidArgumentException;
use Mockery as m;
use RuntimeException;
@@ -46,8 +54,386 @@
use function Hypervel\Coroutine\parallel;
+// Pools send through coroutines, so send() returns the successful responses instead of a promise to wait on, and
+// handlers record what they receive for assertions afterwards. Connectors take no mock client, so the global fake
+// stands in for upstream's connector client.
+// REMOVED: Unit/AsyncRequestTest - sendAsync() is not ported. Its success, error-response and connection-error cases
+// map to Feature/PoolTest, and its then() chaining cases only test promises.
class PoolTest extends TestCase
{
+ /**
+ * Get the package providers.
+ */
+ protected function getPackageProviders(ApplicationContract $app): array
+ {
+ return [SaloonServiceProvider::class];
+ }
+
+ public function testAcceptsAnArrayForRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = [
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ ];
+
+ $pool = $connector->pool($requests);
+
+ $pool->setConcurrency(5);
+
+ $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void {
+ $handled[$index] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertSame($requests, $handled);
+ }
+
+ public function testAcceptsAnArrayForAliasedRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = [
+ 'a' => new UserRequest,
+ 'b' => new UserRequest,
+ 'c' => new UserRequest,
+ ];
+
+ $pool = $connector->pool($requests);
+
+ $pool->setConcurrency(5);
+
+ $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void {
+ $handled[$name] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertSame($requests, $handled);
+ }
+
+ public function testAcceptsAGeneratorForRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = new Collection;
+
+ $generatorCallback = function () use ($requests): Generator {
+ for ($i = 0; $i < 3; ++$i) {
+ $request = new UserRequest;
+ $requests->put($i, $request);
+
+ yield $i => $request;
+ }
+ };
+
+ $this->assertIsCallable($generatorCallback);
+ $this->assertInstanceOf(Generator::class, $generatorCallback());
+
+ $pool = $connector->pool($generatorCallback());
+ $pool->setConcurrency(5);
+ $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void {
+ $handled[$index] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertCount(3, $handled);
+ $this->assertSame($requests->all(), $handled);
+ }
+
+ public function testAcceptsAGeneratorForAliasedRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = new Collection;
+
+ $generatorCallback = function () use ($requests): Generator {
+ foreach (['a', 'b', 'c'] as $name) {
+ $request = new UserRequest;
+ $requests->put($name, $request);
+
+ yield $name => $request;
+ }
+ };
+
+ $this->assertIsCallable($generatorCallback);
+ $this->assertInstanceOf(Generator::class, $generatorCallback());
+
+ $pool = $connector->pool($generatorCallback());
+ $pool->setConcurrency(5);
+ $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void {
+ $handled[$name] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertSame(['a', 'b', 'c'], $requests->keys()->all());
+ $this->assertSame($requests->all(), $handled);
+ }
+
+ // Request callbacks run when the pool is sent rather than when it is created, so handlers read the requests the
+ // callback builds by reference.
+ public function testAcceptsACallbackThatReturnsAnArrayForRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = new Collection;
+
+ $arrayCallback = function () use (&$requests): array {
+ $requests = $requests->merge([
+ new UserRequest,
+ new UserRequest,
+ new UserRequest,
+ ]);
+
+ return $requests->all();
+ };
+
+ $this->assertIsCallable($arrayCallback);
+ $this->assertIsArray($requests->all());
+
+ $pool = $connector->pool($arrayCallback);
+
+ $pool->setConcurrency(5);
+
+ $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void {
+ $handled[$index] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertCount(3, $handled);
+ $this->assertSame($requests->all(), $handled);
+ }
+
+ public function testAcceptsACallbackThatReturnsAnArrayForAliasedRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+ $callbackConnector = null;
+
+ $requests = new Collection;
+
+ $arrayCallback = function (Connector $received) use (&$requests, &$callbackConnector): array {
+ $callbackConnector = $received;
+
+ $requests = $requests->merge([
+ 'a' => new UserRequest,
+ 'b' => new UserRequest,
+ 'c' => new UserRequest,
+ ]);
+
+ return $requests->all();
+ };
+
+ $this->assertIsCallable($arrayCallback);
+ $this->assertIsArray($requests->all());
+
+ $pool = $connector->pool($arrayCallback);
+ $pool->setConcurrency(5);
+ $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void {
+ $handled[$name] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertSame($connector, $callbackConnector);
+ $this->assertSame(['a', 'b', 'c'], $requests->keys()->all());
+ $this->assertSame($requests->all(), $handled);
+ }
+
+ public function testAcceptsACallbackThatReturnsAGeneratorForRequests(): void
+ {
+ Saloon::fake([
+ UserRequest::class => MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = new Collection;
+
+ $generatorCallback = function () use ($requests): Generator {
+ for ($i = 0; $i < 3; ++$i) {
+ $request = new UserRequest;
+ $requests->put($i, $request);
+
+ yield $i => $request;
+ }
+ };
+
+ $this->assertIsCallable($generatorCallback);
+ $this->assertInstanceOf(Generator::class, $generatorCallback());
+
+ $pool = $connector->pool($generatorCallback);
+ $pool->setConcurrency(5);
+ $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void {
+ $handled[$index] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertCount(3, $handled);
+ $this->assertSame($requests->all(), $handled);
+
+ // The callback produces a fresh generator on every send, so the pool can be sent again.
+ $firstRequests = $handled;
+ $handled = [];
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertCount(3, $handled);
+ $this->assertSame($requests->all(), $handled);
+ $this->assertNotSame($firstRequests, $handled);
+ }
+
+ public function testAcceptsACallbackThatReturnsAGeneratorForAliasedRequests(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ MockResponse::make(['name' => 'Mantas']),
+ ]);
+
+ $connector = new TestConnector;
+ $handled = [];
+
+ $requests = new Collection;
+
+ $generatorCallback = function () use ($requests): Generator {
+ foreach (['a', 'b', 'c'] as $name) {
+ $request = new UserRequest;
+ $requests->put($name, $request);
+
+ yield $name => $request;
+ }
+ };
+
+ $this->assertIsCallable($generatorCallback);
+ $this->assertInstanceOf(Generator::class, $generatorCallback());
+
+ $pool = $connector->pool($generatorCallback);
+ $pool->setConcurrency(5);
+ $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void {
+ $handled[$name] = $response->request();
+ });
+
+ $pool->send();
+
+ ksort($handled);
+
+ $this->assertSame(['a', 'b', 'c'], $requests->keys()->all());
+ $this->assertSame($requests->all(), $handled);
+ }
+
+ // Invalid items stop scheduling and are reported after the requests already started have settled.
+ public function testThrowsAnExceptionIfAnInvalidItemIsPassedIntoTheIterator(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Charlotte']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $pool = $connector->pool([
+ new UserRequest,
+ new UserRequest,
+ new TestConnector,
+ ]);
+
+ try {
+ $pool->send();
+ $this->fail('The invalid pool item was not rejected.');
+ } catch (PoolException $exception) {
+ $this->assertInstanceOf(InvalidPoolItemException::class, $exception->orchestrationFailure());
+ $this->assertSame([0, 1], array_keys($exception->responses()));
+ }
+ }
+
+ public function testYouCanGetTheRequestsProvidedIntoThePool(): void
+ {
+ $connector = new TestConnector;
+
+ $requests = [
+ new UserRequest,
+ new UserRequest,
+ new TestConnector,
+ ];
+
+ $pool = $connector->pool($requests);
+ $iterable = $pool->requests();
+
+ $this->assertIsIterable($iterable);
+
+ foreach ($iterable as $index => $request) {
+ $this->assertSame($requests[$index], $request);
+ }
+
+ $this->assertSame(2, $index);
+ }
+
public function testPoolBoundsConcurrencyPreservesInputOrderAndPropagatesContext(): void
{
CoroutineContext::set('pool-tenant', 'tenant-a');
@@ -95,77 +481,28 @@ public function testPoolBoundsConcurrencyPreservesInputOrderAndPropagatesContext
], $handlerContexts);
}
- public function testHandledRequestFailuresAreOmittedAfterEveryChildSettles(): void
- {
- $manager = $this->manager();
- $manager->fake([
- PoolFailingRequestStub::class => MockResponse::make()->throw(new RuntimeException('failed')),
- PoolRequestStub::class => MockResponse::make(['ok' => true]),
- ]);
- $handled = [];
- $connector = new PoolConnectorStub($manager);
-
- $responses = $connector->pool([
- 'failed' => new PoolFailingRequestStub,
- 'successful' => new PoolRequestStub(1),
- ])->withExceptionHandler(function (RuntimeException $exception, string $key) use (&$handled): void {
- $handled[$key] = $exception->getMessage();
- })->send();
-
- $this->assertSame(['failed' => 'failed'], $handled);
- $this->assertSame(['successful'], array_keys($responses));
- }
-
- public function testPoolExceptionPreservesRequestCallbackAndPartialResults(): void
+ public function testProducerFailuresTakePrecedenceAndWaitForStartedChildren(): void
{
+ $sendFailure = new RuntimeException('send failed');
+ $producerFailure = new RuntimeException('producer failed');
$manager = $this->manager();
$manager->fake([
- PoolFailingRequestStub::class => MockResponse::make()->throw(new RuntimeException('send failed')),
+ PoolFailingRequestStub::class => MockResponse::make()->throw($sendFailure),
PoolRequestStub::class => MockResponse::make(['ok' => true]),
]);
$connector = new PoolConnectorStub($manager);
try {
- $connector->pool([
- 'failed' => new PoolFailingRequestStub,
- 'successful' => new PoolRequestStub(1),
- ])->withResponseHandler(function (): void {
- throw new RuntimeException('callback failed');
- })->send();
- $this->fail('The pool exception was not thrown.');
- } catch (PoolException $exception) {
- $this->assertSame('send failed', $exception->failures()['failed']->getMessage());
- $this->assertSame('callback failed', $exception->callbackFailures()['successful']->getMessage());
- $this->assertSame(['successful'], array_keys($exception->responses()));
- $this->assertNull($exception->orchestrationFailure());
- }
- }
-
- public function testInvalidItemsAndProducerFailuresWaitForStartedChildren(): void
- {
- $manager = $this->manager();
- $manager->fake([PoolRequestStub::class => MockResponse::make(['ok' => true])]);
- $connector = new PoolConnectorStub($manager);
-
- try {
- $connector->pool((function (): iterable {
+ $connector->pool(function () use ($producerFailure): iterable {
+ yield 'failed' => new PoolFailingRequestStub;
yield 'started' => new PoolRequestStub(1);
- yield 'invalid' => new PoolConnectorStub($this->manager());
- })())->send();
- $this->fail('The invalid pool item was not rejected.');
- } catch (PoolException $exception) {
- $this->assertInstanceOf(InvalidPoolItemException::class, $exception->orchestrationFailure());
- $this->assertSame(['started'], array_keys($exception->responses()));
- }
-
- try {
- $connector->pool(function (): iterable {
- yield 'started' => new PoolRequestStub(1);
- throw new RuntimeException('producer failed');
+ throw $producerFailure;
})->send();
$this->fail('The producer failure was not returned.');
} catch (PoolException $exception) {
- $this->assertSame('producer failed', $exception->orchestrationFailure()?->getMessage());
+ $this->assertSame($producerFailure, $exception->orchestrationFailure());
+ $this->assertSame($producerFailure, $exception->getPrevious());
+ $this->assertSame(['failed' => $sendFailure], $exception->failures());
$this->assertSame(['started'], array_keys($exception->responses()));
}
}
@@ -365,7 +702,7 @@ public function testConcurrentPoolParentsKeepCacheAndRateScopesInTheirChildren()
{
$http = new Factory;
$http->registerConnection('saloon');
- $http->fake(function () {
+ $http->fake(function (): PromiseInterface {
usleep(5000);
return Factory::response(['ok' => true]);
@@ -463,15 +800,24 @@ protected function manager(): SaloonManager
class PoolConnectorStub extends Connector
{
+ /**
+ * Create a connector that sends through the given manager.
+ */
public function __construct(protected SaloonManager $manager)
{
}
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Send a request through the isolated manager.
+ */
public function send(Request $request, ?MockClient $mockClient = null): Response
{
return $this->manager->send($this, $request, $mockClient);
@@ -482,11 +828,17 @@ class PoolRequestStub extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Create a request identified by a query parameter.
+ */
public function __construct(int $id)
{
$this->withQueryParameters(['id' => $id]);
}
+ /**
+ * Define the endpoint for the request.
+ */
public function resolveEndpoint(): string
{
return '/users';
@@ -497,6 +849,9 @@ class PoolFailingRequestStub extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Define the endpoint for the request.
+ */
public function resolveEndpoint(): string
{
return '/failure';
@@ -508,12 +863,19 @@ class ScopedPoolRequestStub extends PoolRequestStub implements Cacheable
use HasCaching;
use HasRateLimits;
+ /**
+ * Define the cache lifetime in seconds.
+ */
public function cacheFor(): DateInterval|DateTimeInterface|int
{
return 60;
}
- /** @return list */
+ /**
+ * Resolve the request's rate limits.
+ *
+ * @return list
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [Limit::perMinute(10)->by((string) $pendingRequest->request()->queryParameters()['id'])];
From 801ba5d81737202994319439720df1a937db780a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:00:43 +0000
Subject: [PATCH 10/26] Reconcile Saloon's Laravel integration with upstream
Ports the Laravel plugin's events, mock request, simulated response
payload and mock client assertion tests (saloonphp/laravel-plugin at
47d06ef8621a) into tests/Saloon/Hypervel on the core fixtures, which
the plugin's fixtures copy. The plugin's pool and request tests repeat
core cases and are covered by the core tests, noted there.
Defects fixed:
- Event::fake() and Event::fakeFor() did not reach Saloon or the HTTP
client once they were resolved, because SaloonManager and the HTTP
client factory keep the dispatcher they were built with. Both
providers now rebind the events service onto the resolved services
(setEventDispatcher(), setDispatcher()), as Laravel's
AuthServiceProvider does, so fakes apply and are removed afterwards.
Both Saloon request events use Dispatchable.
- Saloon::fake() replaced the global mock client, discarding earlier
fakes, recorded responses and settings. It now adds responses to the
existing client, as upstream; passing a MockClient replaces it.
- A null integrations namespace always produced App\Http\Integrations,
whatever integrations path was configured. It now follows the path
beneath the application directory, and a path outside it requires
the namespace unless --target-namespace is given.
- saloon:list garbled endpoints that used interpolation, concatenation,
dots inside literals or escaped quotes, such as SharePoint's
getbytitle('...'), and did not read double-quoted base URLs.
Not ported, recorded in the README with source and test notes: the
plugin's deprecated response recorder, its MockClient subclass and the
deprecated assertSentJson() facade method.
Validation: Saloon suite (ParaTest), tests/Http, the HTTP client
consumers in Integration/Http, Reverb webhooks and Inertia, Data's
Saloon tests, FacadeDocblocksTest, composer analyse and php-cs-fixer.
The private SDK generator was checked by source: it uses neither
global fakes nor the generators.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 6 +-
src/http/src/Client/Factory.php | 11 +
src/http/src/HttpServiceProvider.php | 15 +
src/saloon/README.md | 2 +-
src/saloon/config/saloon.php | 5 +-
.../src/Console/Commands/ListCommand.php | 26 +-
.../src/Console/Commands/MakeCommand.php | 17 +-
.../src/Events/SendingSaloonRequest.php | 3 +
src/saloon/src/Events/SentSaloonRequest.php | 3 +
src/saloon/src/Facades/Saloon.php | 1 +
src/saloon/src/Http/Faking/MockClient.php | 3 +
src/saloon/src/SaloonManager.php | 27 +-
src/saloon/src/SaloonServiceProvider.php | 8 +
src/support/src/Facades/Http.php | 1 +
tests/Http/HttpServiceProviderTest.php | 29 +-
.../Saloon/Feature/RequestTest.php | 1 +
.../Saloon/Console/GeneratorCommandsTest.php | 95 ++++++
tests/Saloon/Console/ListCommandTest.php | 53 ++-
tests/Saloon/Feature/PoolTest.php | 1 +
tests/Saloon/Hypervel/Feature/EventsTest.php | 75 ++++
.../Hypervel/Feature/MockRequestTest.php | 323 ++++++++++++++++++
.../Feature/SimulatedResponsePayloadTest.php | 43 +++
.../Unit/MockClientAssertionsTest.php | 170 +++++++++
tests/Saloon/PackageMetadataTest.php | 24 --
25 files changed, 888 insertions(+), 56 deletions(-)
create mode 100644 tests/Saloon/Hypervel/Feature/EventsTest.php
create mode 100644 tests/Saloon/Hypervel/Feature/MockRequestTest.php
create mode 100644 tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php
create mode 100644 tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 5f5aebfe46..698224537b 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -189,7 +189,7 @@ saloonphp/laravel-plugin:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's HTTP layer is a mock client and middleware, not a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel.
+ notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's HTTP layer is a mock client and middleware, not a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel and use the core fixtures, which the plugin's fixtures copy; the plugin's Feature/PoolTest and Feature/RequestTest repeat core cases and are covered by the core tests.
saloonphp/cache-plugin:
branch: v3
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 331e32e894..54f51c08b1 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -2190,7 +2190,7 @@ Saloon provides a strict mock client, response sequences, fixtures, request reco
### Faking Responses
-Use the `Saloon` facade to replace responses for the current test application:
+Use the `Saloon` facade to fake responses for the current test application:
```php
use App\Http\Integrations\GitHub\Requests\GetUser;
@@ -2205,6 +2205,8 @@ Saloon::fake([
]);
```
+Calling `fake` again adds responses to the same mock client, so a test may extend the fakes registered in its `setUp` method. To replace the global mock client instead, pass a `MockClient` instance to `fake`.
+
Responses may be matched by request class, connector class, or wildcard URL. Request matches take precedence over connector matches, followed by URL matches and sequence responses:
```php
@@ -2361,7 +2363,7 @@ You may publish the generator stubs using the `saloon-stubs` tag:
php artisan vendor:publish --tag=saloon-stubs
```
-Set `saloon.integrations_path` to change where generated files are written. Set `saloon.integrations_namespace` when that path uses a custom Composer namespace. The path and namespace are configured independently; Saloon does not guess a namespace from an arbitrary filesystem path.
+Set `saloon.integrations_path` to change where generated files are written. When the path is inside your application directory, generated classes follow it beneath your application's namespace, so `app_path('Integrations')` produces classes in `App\Integrations`. For a path outside the application directory, also set `saloon.integrations_namespace` to the namespace Composer loads from that path.
## Differences From Saloon
diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php
index b1bb8d71b6..84162e9c2a 100644
--- a/src/http/src/Client/Factory.php
+++ b/src/http/src/Client/Factory.php
@@ -618,6 +618,17 @@ public function getDispatcher(): ?Dispatcher
return $this->dispatcher;
}
+ /**
+ * Set the event dispatcher implementation.
+ *
+ * Boot or tests only. The dispatcher persists on the factory for the
+ * worker lifetime and receives every subsequent HTTP client event.
+ */
+ public function setDispatcher(?Dispatcher $dispatcher): void
+ {
+ $this->dispatcher = $dispatcher;
+ }
+
/**
* Get the array of global middleware.
*/
diff --git a/src/http/src/HttpServiceProvider.php b/src/http/src/HttpServiceProvider.php
index 925b7ff66c..371614a80f 100644
--- a/src/http/src/HttpServiceProvider.php
+++ b/src/http/src/HttpServiceProvider.php
@@ -6,6 +6,8 @@
use Http\Discovery\ClassDiscovery;
use Hypervel\Context\RequestContext;
+use Hypervel\Contracts\Container\Container;
+use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Core\Events\BeforeServerFork;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Discovery\GuzzlePsr18Strategy;
@@ -20,6 +22,7 @@ public function register(): void
{
$this->registerPsr18Discovery();
$this->registerRequestFactory();
+ $this->registerClientEventRebindHandler();
}
/**
@@ -78,4 +81,16 @@ protected function registerRequestFactory(): void
?? Request::create($app->make('config')->get('app.url') ?? 'http://localhost');
});
}
+
+ /**
+ * Pass a replaced event dispatcher, such as an event fake, to the resolved HTTP client factory.
+ */
+ protected function registerClientEventRebindHandler(): void
+ {
+ $this->app->rebinding('events', function (Container $container, Dispatcher $events): void {
+ if ($container->resolved(Factory::class)) {
+ $container->make(Factory::class)->setDispatcher($events);
+ }
+ });
+ }
}
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 6fd335d32c..d45b1b134b 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -12,7 +12,7 @@ There is no process-global `Config` class and no pluggable sender. Global middle
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
-Connectors are read-only, so they have no `withMockClient()`: attach a mock client to the request, pass it to `send()`, or use `Saloon::fake()`, which also backs `MockClient::global()`. Mock clients use the HTTP client's names. `recorded()` returns the recorded responses as a collection, optionally filtered by a callback, in place of `getRecordedResponses()`, `findResponseByRequest()` and `findResponseByRequestUrl()`; `lastRequest()`, `lastPendingRequest()` and `lastResponse()` drop the `get` prefix; and `match()` replaces `guessNextResponse()` and `getNextFromSequence()`. The deprecated `assertSentJson()` is not included. There is no `MockConfig`: fixtures live under the `saloon.fixtures.path` configuration, which tests override with `Saloon::fixturePath()` and `Saloon::throwOnMissingFixtures()`, and are read and written through the framework `Filesystem` rather than upstream's `Storage` helper. `getFixturePath()` returns the absolute path, fixture names separate directories with forward slashes, a missing directory is created when a fixture is first recorded, and recorded fixtures list every header value. See [Testing](https://hypervel.org/docs/saloon#testing).
+Connectors are read-only, so they have no `withMockClient()`: attach a mock client to the request, pass it to `send()`, or use `Saloon::fake()`, which also backs `MockClient::global()`. Mock clients use the HTTP client's names. `recorded()` returns the recorded responses as a collection, optionally filtered by a callback, in place of `getRecordedResponses()`, `findResponseByRequest()` and `findResponseByRequestUrl()`; `lastRequest()`, `lastPendingRequest()` and `lastResponse()` drop the `get` prefix; and `match()` replaces `guessNextResponse()` and `getNextFromSequence()`. The deprecated `assertSentJson()` is not included. `Saloon::fake()` also accepts a `MockClient` to install as the global client, and `Saloon::mockClient()` returns `null` until a fake is active instead of creating one. The plugin's deprecated response recorder (`record()`, `stopRecording()`, `isRecording()`, `recordResponse()`, `getRecordedResponses()` and `getLastRecordedResponse()`) and its `MockClient` subclass are not included: `recorded()` lists the responses recorded by the active mock client, and the `SentSaloonRequest` event observes every sent response. There is no `MockConfig`: fixtures live under the `saloon.fixtures.path` configuration, which tests override with `Saloon::fixturePath()` and `Saloon::throwOnMissingFixtures()`, and are read and written through the framework `Filesystem` rather than upstream's `Storage` helper. `getFixturePath()` returns the absolute path, fixture names separate directories with forward slashes, a missing directory is created when a fixture is first recorded, and recorded fixtures list every header value. See [Testing](https://hypervel.org/docs/saloon#testing).
Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeResponse` and `ResponseMiddleware::__invoke()` returns `?Response`, so a ported middleware class declared `: void` returns `null` instead. A pending request returned from request middleware is ignored; change the one it receives. Pipelines expose `pipes()`, `requestPipeline()`, `responsePipeline()` and `fatalPipeline()` instead of upstream's `get` methods. Connector middleware is registered from `boot()` and runs after global and plugin middleware but before the request's own middleware.
diff --git a/src/saloon/config/saloon.php b/src/saloon/config/saloon.php
index f7bf32bac4..e40eb10176 100644
--- a/src/saloon/config/saloon.php
+++ b/src/saloon/config/saloon.php
@@ -64,8 +64,9 @@
| Generated Integrations
|--------------------------------------------------------------------------
|
- | The path and namespace are independent. A null namespace derives
- | "Http\\Integrations" beneath the application's root namespace.
+ | A null namespace follows the path beneath the application directory,
+ | so "app/Http/Integrations" becomes "App\Http\Integrations". Set the
+ | namespace when the path is outside the application directory.
|
*/
diff --git a/src/saloon/src/Console/Commands/ListCommand.php b/src/saloon/src/Console/Commands/ListCommand.php
index d093faab37..e061983ac4 100644
--- a/src/saloon/src/Console/Commands/ListCommand.php
+++ b/src/saloon/src/Console/Commands/ListCommand.php
@@ -307,19 +307,17 @@ protected function getIntegrationRequestEndpoint(string $request): string
$regex = '/public\s+function\s+resolveEndpoint\(\):\s+string\s*\{\s*return\s+(.*?);/s';
- $match = Str::match($regex, $contents);
- $matchSegments = explode('/', $match);
-
- foreach ($matchSegments as $key => $matchSegment) {
- if (Str::contains($matchSegment, '$this->')) {
- $matchSegments[$key] = '{' . Str::before(
- Str::after(str_replace(' ', '', $matchSegment), '>'),
- '.\''
- ) . '}';
- }
- }
-
- return str_replace('\'', '', implode('/', $matchSegments));
+ // Keep single-quoted literals as written, including escaped characters, show concatenated or interpolated
+ // properties as {name}, and drop the quotes and joining dots.
+ return preg_replace_callback(
+ '/\'((?:[^\'\\\]|\\\.)*)\'|"((?:[^"\\\]|\\\.)*)"|\$this->(\w+)|\s*\.\s*/s',
+ fn (array $matches): string => match (true) {
+ isset($matches[3]) => '{' . $matches[3] . '}',
+ isset($matches[2]) => preg_replace('/\{?\$this->(\w+)\}?/', '{$1}', $matches[2]),
+ default => $matches[1] ?? '',
+ },
+ Str::match($regex, $contents),
+ );
}
/**
@@ -329,7 +327,7 @@ protected function getIntegrationConnectorBaseUrl(string $connector): string
{
$contents = $this->files->get($connector);
- $regex = '/public\s+function\s+resolveBaseUrl\(\):\s+string\s*\{\s*return\s+\'(.*?)\';\s*/s';
+ $regex = '/public\s+function\s+resolveBaseUrl\(\):\s+string\s*\{\s*return\s+[\'"](.*?)[\'"];/s';
$matches = Str::match($regex, $contents);
return Str::after($matches, '://');
diff --git a/src/saloon/src/Console/Commands/MakeCommand.php b/src/saloon/src/Console/Commands/MakeCommand.php
index 5f362f3b13..25e0833785 100644
--- a/src/saloon/src/Console/Commands/MakeCommand.php
+++ b/src/saloon/src/Console/Commands/MakeCommand.php
@@ -97,13 +97,28 @@ protected function qualifyClass(string $name): string
/**
* Get the configured integrations namespace.
+ *
+ * Without a configured namespace, the integrations path must be inside the application directory, whose
+ * subdirectories follow the application's root namespace.
*/
protected function integrationNamespace(): string
{
$namespace = $this->config->get('saloon.integrations_namespace');
if ($namespace === null) {
- return rtrim($this->rootNamespace(), '\\') . '\Http\Integrations';
+ $rootNamespace = rtrim($this->rootNamespace(), '\\');
+ $appPath = rtrim($this->hypervel->path(), '/\\');
+ $integrationsPath = rtrim($this->config->string('saloon.integrations_path'), '/\\');
+
+ if ($integrationsPath === $appPath) {
+ return $rootNamespace;
+ }
+
+ if (! str_starts_with($integrationsPath, $appPath . DIRECTORY_SEPARATOR)) {
+ throw new LogicException('The [saloon.integrations_namespace] configuration value is required when the integrations path is outside the application directory.');
+ }
+
+ return $rootNamespace . '\\' . str_replace(DIRECTORY_SEPARATOR, '\\', substr($integrationsPath, strlen($appPath) + 1));
}
if (! is_string($namespace) || $namespace === '') {
diff --git a/src/saloon/src/Events/SendingSaloonRequest.php b/src/saloon/src/Events/SendingSaloonRequest.php
index 63f3d090ae..2c6b156aa6 100644
--- a/src/saloon/src/Events/SendingSaloonRequest.php
+++ b/src/saloon/src/Events/SendingSaloonRequest.php
@@ -4,10 +4,13 @@
namespace Hypervel\Saloon\Events;
+use Hypervel\Foundation\Events\Dispatchable;
use Hypervel\Saloon\Http\PendingRequest;
class SendingSaloonRequest
{
+ use Dispatchable;
+
/**
* Create a new event instance.
*/
diff --git a/src/saloon/src/Events/SentSaloonRequest.php b/src/saloon/src/Events/SentSaloonRequest.php
index 8e68e477c9..6787cd5f0a 100644
--- a/src/saloon/src/Events/SentSaloonRequest.php
+++ b/src/saloon/src/Events/SentSaloonRequest.php
@@ -4,11 +4,14 @@
namespace Hypervel\Saloon\Events;
+use Hypervel\Foundation\Events\Dispatchable;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Response;
class SentSaloonRequest
{
+ use Dispatchable;
+
/**
* Create a new event instance.
*/
diff --git a/src/saloon/src/Facades/Saloon.php b/src/saloon/src/Facades/Saloon.php
index 9c4ebcc660..72025d68df 100644
--- a/src/saloon/src/Facades/Saloon.php
+++ b/src/saloon/src/Facades/Saloon.php
@@ -27,6 +27,7 @@
* @method static \Hypervel\Saloon\SaloonManager resolveCacheScopeUsing(null|Closure $resolver)
* @method static \Hypervel\Saloon\Http\Response send(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request, \Hypervel\Saloon\Http\Faking\MockClient|null $mockClient = null)
* @method static \Hypervel\Saloon\Http\Sender sender()
+ * @method static void setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events)
* @method static \Hypervel\Saloon\SaloonManager throwOnMissingFixtures(bool $throw = true)
* @method static bool throwsOnMissingFixtures()
*
diff --git a/src/saloon/src/Http/Faking/MockClient.php b/src/saloon/src/Http/Faking/MockClient.php
index 5dbd94c86d..d080712ed8 100644
--- a/src/saloon/src/Http/Faking/MockClient.php
+++ b/src/saloon/src/Http/Faking/MockClient.php
@@ -327,6 +327,9 @@ public function shouldBypassResponseCache(): bool
return $this->withoutResponseCache;
}
+ // The plugin's deprecated MockClient subclass, with startMocking(), isMocking() and resolve(), is not included;
+ // Saloon::fake() and global() manage the global mock client. See the package README.
+
/**
* Register or retrieve the manager-backed global mock client.
*
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index a919ecca9c..e35cc186b0 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -298,6 +298,17 @@ public function rateLimiter(): RateLimiter
return $this->rateLimiter;
}
+ /**
+ * Set the event dispatcher.
+ *
+ * Boot or tests only. The dispatcher persists on the manager for the
+ * worker lifetime and receives every subsequent Saloon request event.
+ */
+ public function setEventDispatcher(Dispatcher $events): void
+ {
+ $this->events = $events;
+ }
+
/**
* Get the global middleware pipeline.
*
@@ -310,7 +321,7 @@ public function middleware(): MiddlewarePipeline
}
/**
- * Replace the global mock client.
+ * Add responses to the global mock client, or replace it with the given client.
*
* Tests only. The client persists on the manager until the test
* application is destroyed or `clearFake()` is called.
@@ -319,9 +330,13 @@ public function middleware(): MiddlewarePipeline
*/
public function fake(array|MockClient $responses = []): MockClient
{
- return $this->mockClient = $responses instanceof MockClient
- ? $responses
- : new MockClient($responses);
+ if ($responses instanceof MockClient) {
+ return $this->mockClient = $responses;
+ }
+
+ ($this->mockClient ??= new MockClient)->addResponses($responses);
+
+ return $this->mockClient;
}
/**
@@ -391,6 +406,10 @@ public function assertSentCount(int $count, ?string $requestClass = null): void
($this->mockClient ?? new MockClient)->assertSentCount($count, $requestClass);
}
+ // The plugin's deprecated record(), stopRecording(), isRecording(), recordResponse(), getRecordedResponses() and
+ // getLastRecordedResponse() are not included: recorded() lists the responses recorded by the active mock client,
+ // and the SentSaloonRequest event observes every sent response. See the package README.
+
/**
* Register the cache scope resolver.
*
diff --git a/src/saloon/src/SaloonServiceProvider.php b/src/saloon/src/SaloonServiceProvider.php
index 4208bf20aa..8a2e6d3bf1 100644
--- a/src/saloon/src/SaloonServiceProvider.php
+++ b/src/saloon/src/SaloonServiceProvider.php
@@ -6,6 +6,7 @@
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
+use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Http\Client\Factory as HttpFactory;
use Hypervel\RateLimiter\RateLimiter;
@@ -37,6 +38,13 @@ public function register(): void
));
$this->app->alias('saloon', SaloonManager::class);
+
+ // Event fakes replace the dispatcher after the manager may already hold the original.
+ $this->app->rebinding('events', function (Container $container, Dispatcher $events): void {
+ if ($container->resolved('saloon')) {
+ $container->make('saloon')->setEventDispatcher($events);
+ }
+ });
}
/**
diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php
index f1796263ef..3feb759bb3 100644
--- a/src/support/src/Facades/Http.php
+++ b/src/support/src/Facades/Http.php
@@ -48,6 +48,7 @@
* @method static \GuzzleHttp\Promise\PromiseInterface response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = [])
* @method static \Hypervel\Http\Client\ResponseSequence sequence(array $responses = [])
* @method static \Hypervel\Http\Client\Factory setConnectionConfig(string $name, array $config)
+ * @method static void setDispatcher(\Hypervel\Contracts\Events\Dispatcher|null $dispatcher)
* @method static mixed withoutGlobalConfiguration(\Closure $callback)
* @method static \Hypervel\Http\Client\PendingRequest accept(string $contentType)
* @method static \Hypervel\Http\Client\PendingRequest acceptJson()
diff --git a/tests/Http/HttpServiceProviderTest.php b/tests/Http/HttpServiceProviderTest.php
index d5e3a9c106..2915665243 100644
--- a/tests/Http/HttpServiceProviderTest.php
+++ b/tests/Http/HttpServiceProviderTest.php
@@ -7,9 +7,12 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Foundation\Application;
use Hypervel\Core\Events\BeforeServerFork;
+use Hypervel\Http\Client\Events\ResponseReceived;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\HttpServiceProvider;
-use Hypervel\Tests\TestCase;
+use Hypervel\Support\Facades\Event;
+use Hypervel\Support\Facades\Http;
+use Hypervel\Testbench\TestCase;
use Mockery as m;
use Swoole\Server;
@@ -53,4 +56,28 @@ public function testBeforeForkDoesNotResolveAnUnusedFactory(): void
$listeners[BeforeServerFork::class](new BeforeServerFork(m::mock(Server::class)));
}
+
+ public function testAScopedEventFakeReachesTheResolvedFactoryAndIsRemovedAfterwards(): void
+ {
+ Http::fake(['*' => Http::response()]);
+ $received = [];
+ Event::listen(ResponseReceived::class, function (ResponseReceived $event) use (&$received): void {
+ $received[] = $event->request->url();
+ });
+
+ Http::get('https://example.com/before');
+
+ Event::fakeFor(function (): void {
+ Http::get('https://example.com/inside');
+
+ Event::assertDispatched(
+ ResponseReceived::class,
+ fn (ResponseReceived $event): bool => $event->request->url() === 'https://example.com/inside',
+ );
+ });
+
+ Http::get('https://example.com/after');
+
+ $this->assertSame(['https://example.com/before', 'https://example.com/after'], $received);
+ }
}
diff --git a/tests/Integration/Saloon/Feature/RequestTest.php b/tests/Integration/Saloon/Feature/RequestTest.php
index b553f78cdd..412e178d26 100644
--- a/tests/Integration/Saloon/Feature/RequestTest.php
+++ b/tests/Integration/Saloon/Feature/RequestTest.php
@@ -16,6 +16,7 @@
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
// Upstream's live test API is replaced by the engine test server's /user and /error routes.
+// laravel-plugin's Feature/RequestTest repeats the first two cases, so they cover it too.
class RequestTest extends TestCase
{
use InteractsWithServer;
diff --git a/tests/Saloon/Console/GeneratorCommandsTest.php b/tests/Saloon/Console/GeneratorCommandsTest.php
index 406a1f9eb3..1c0b728526 100644
--- a/tests/Saloon/Console/GeneratorCommandsTest.php
+++ b/tests/Saloon/Console/GeneratorCommandsTest.php
@@ -8,7 +8,10 @@
use Hypervel\Filesystem\Filesystem;
use Hypervel\Saloon\SaloonServiceProvider;
use Hypervel\Testbench\TestCase;
+use Hypervel\Testing\ParallelTesting;
use InvalidArgumentException;
+use LogicException;
+use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
class GeneratorCommandsTest extends TestCase
@@ -20,6 +23,13 @@ class GeneratorCommandsTest extends TestCase
*/
protected array $generatedFiles = [];
+ /**
+ * The generated directories that the test owns.
+ *
+ * @var list
+ */
+ protected array $generatedDirectories = [];
+
/**
* Get the package providers.
*/
@@ -39,6 +49,10 @@ protected function tearDown(): void
$files->delete($generatedFile);
}
+ foreach ($this->generatedDirectories as $generatedDirectory) {
+ $files->deleteDirectory($generatedDirectory);
+ }
+
parent::tearDown();
}
@@ -104,6 +118,33 @@ public function testUnsupportedRequestMethodIsRejected(): void
]);
}
+ public function testMissingArgumentsArePromptedWithExistingIntegrations(): void
+ {
+ $integrationsPath = ParallelTesting::tempDir('SaloonGeneratorCommandsTest');
+ $files = new Filesystem;
+ $files->deleteDirectory($integrationsPath);
+ $files->ensureDirectoryExists($integrationsPath . '/GitHub');
+ $files->ensureDirectoryExists($integrationsPath . '/Stripe');
+ $this->generatedDirectories[] = $integrationsPath;
+ config()->set('saloon.integrations_path', $integrationsPath);
+ config()->set('saloon.integrations_namespace', 'Domain\Integrations');
+
+ $this->artisan('saloon:request')
+ ->expectsChoice('What is the related integration?', 'Stripe', ['GitHub', 'Stripe'])
+ ->expectsQuestion('What should the Saloon request be named?', 'CreatePayment')
+ ->expectsChoice(
+ 'What method should the Saloon request send?',
+ 'POST',
+ ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE', 'QUERY'],
+ )
+ ->assertSuccessful();
+
+ $contents = $this->generatedFile($integrationsPath . '/Stripe/Requests/CreatePayment.php');
+
+ $this->assertStringContainsString('namespace Domain\Integrations\Stripe\Requests;', $contents);
+ $this->assertStringContainsString('protected Method $method = Method::POST;', $contents);
+ }
+
public function testAllSupportingTypesCanBeGenerated(): void
{
$commands = [
@@ -142,6 +183,60 @@ public function testConfiguredPathAndNamespaceAreIndependentDefaults(): void
$this->assertStringContainsString('namespace Domain\Integrations\Stripe;', $contents);
}
+ #[DataProvider('integrationsPathsInsideTheAppDirectory')]
+ public function testAnIntegrationsPathInsideTheAppDirectoryDeterminesTheNamespace(string $relativePath, string $namespace): void
+ {
+ $integrationsPath = app_path($relativePath);
+ config()->set('saloon.integrations_path', $integrationsPath);
+ $this->generatedDirectories[] = $integrationsPath . '/Stripe';
+
+ $this->artisan('saloon:connector', [
+ 'integration' => 'Stripe',
+ 'name' => 'StripeConnector',
+ '--no-interaction' => true,
+ ])->assertSuccessful();
+
+ $contents = $this->generatedFile($integrationsPath . '/Stripe/StripeConnector.php');
+
+ $this->assertStringContainsString("namespace {$namespace};", $contents);
+ }
+
+ /**
+ * Get integrations paths inside the app directory and their generated namespaces.
+ *
+ * @return array
+ */
+ public static function integrationsPathsInsideTheAppDirectory(): array
+ {
+ return [
+ 'application directory' => ['', 'App\Stripe'],
+ 'subdirectory' => ['Integrations', 'App\Integrations\Stripe'],
+ ];
+ }
+
+ public function testAnIntegrationsPathOutsideTheAppDirectoryRequiresANamespace(): void
+ {
+ // A sibling whose name starts with the app directory's name is still outside it.
+ config()->set('saloon.integrations_path', base_path('app-other/Integrations'));
+ $this->generatedDirectories[] = base_path('app-other');
+
+ try {
+ $this->artisan('saloon:connector', [
+ 'integration' => 'Stripe',
+ 'name' => 'StripeConnector',
+ '--no-interaction' => true,
+ ])->run();
+ $this->fail('The missing integrations namespace was not rejected.');
+ } catch (LogicException $exception) {
+ $this->assertSame(
+ 'The [saloon.integrations_namespace] configuration value is required when the integrations path is outside the application directory.',
+ $exception->getMessage(),
+ );
+ }
+
+ $this->assertDirectoryDoesNotExist(base_path('app-other'));
+ }
+
public function testCommandOptionsOverrideConfiguredPathAndNamespace(): void
{
config()->set('saloon.integrations_path', base_path('ignored/integrations'));
diff --git a/tests/Saloon/Console/ListCommandTest.php b/tests/Saloon/Console/ListCommandTest.php
index edab98653d..78fac2f936 100644
--- a/tests/Saloon/Console/ListCommandTest.php
+++ b/tests/Saloon/Console/ListCommandTest.php
@@ -69,7 +69,7 @@ class GitHubConnector
{
public function resolveBaseUrl(): string
{
- return 'https://api.github.com';
+ return "https://api.github.com";
}
}
PHP);
@@ -84,7 +84,46 @@ class GetUser
public function resolveEndpoint(): string
{
- return '/users/{$this->user}';
+ return "/users/{$this->user}";
+ }
+}
+PHP);
+ $files->put($this->integrationsPath . '/GitHub/Requests/Users/StarRepository.php', <<<'PHP'
+user . '/stars.json';
+ }
+}
+PHP);
+ $files->put($this->integrationsPath . '/GitHub/Requests/Users/GetStarred.php', <<<'PHP'
+user/starred';
+ }
+}
+PHP);
+ $files->put($this->integrationsPath . '/GitHub/Requests/GetListItems.php', <<<'PHP'
+assertStringContainsString('GitHub', $output);
$this->assertStringContainsString('Authenticators: 1', $output);
$this->assertStringContainsString('Connectors: 1', $output);
- $this->assertStringContainsString('Requests: 1', $output);
+ $this->assertStringContainsString('Requests: 4', $output);
$this->assertStringContainsString('Plugins: 1', $output);
$this->assertStringContainsString('Responses: 1', $output);
- $this->assertStringContainsString('api.github.com', $output);
- $this->assertStringContainsString('/users/{user}', $output);
- $this->assertStringContainsString('GET', $output);
+ $this->assertStringContainsString(' api.github.com', $output);
+ $this->assertStringContainsString(' /users/{user} GET', $output);
+ $this->assertStringContainsString(' /v1.0/users/{user}/stars.json PUT', $output);
+ $this->assertStringContainsString(' /users/$this->user/starred GET', $output);
+ $this->assertStringContainsString(" /_api/web/lists/getbytitle(\\'Docs.v2\\')/items GET", $output);
}
}
diff --git a/tests/Saloon/Feature/PoolTest.php b/tests/Saloon/Feature/PoolTest.php
index d280642f18..9f87977ed9 100644
--- a/tests/Saloon/Feature/PoolTest.php
+++ b/tests/Saloon/Feature/PoolTest.php
@@ -28,6 +28,7 @@
// Pools send through coroutines, so send() returns the successful responses instead of a promise to wait on.
// Connectors take no mock client, so the global fake stands in for upstream's connector client.
+// laravel-plugin's Feature/PoolTest repeats these cases, so this file and Integration/Saloon/Feature/PoolTest cover it.
// REMOVED: Feature/AsyncRequestTest - sendAsync() is not ported. Its live success and error cases map to
// Integration/Saloon/Feature/PoolTest, and its response middleware, custom response and send-time middleware cases to
// testPooledRequestsRunTheirMiddlewareWhenSentAndReturnCustomResponses.
diff --git a/tests/Saloon/Hypervel/Feature/EventsTest.php b/tests/Saloon/Hypervel/Feature/EventsTest.php
new file mode 100644
index 0000000000..91f0c89211
--- /dev/null
+++ b/tests/Saloon/Hypervel/Feature/EventsTest.php
@@ -0,0 +1,75 @@
+ 'Sam'], 200),
+ ]);
+
+ Event::fake();
+
+ $response = TestConnector::make()->send(new UserRequest);
+
+ Event::assertDispatched(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use ($response): bool {
+ return $response->pendingRequest() === $event->pendingRequest;
+ });
+
+ Event::assertDispatched(SentSaloonRequest::class, function (SentSaloonRequest $event) use ($response): bool {
+ return $response === $event->response && $response->pendingRequest() === $event->pendingRequest;
+ });
+ }
+
+ public function testAScopedEventFakeIsRemovedFromTheResolvedManagerAfterwards(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Alex']),
+ MockResponse::make(['name' => 'Taylor']),
+ ]);
+ $received = [];
+ Event::listen(SentSaloonRequest::class, function (SentSaloonRequest $event) use (&$received): void {
+ $received[] = $event->response->json('name');
+ });
+ $connector = new TestConnector;
+
+ $connector->send(new UserRequest);
+
+ Event::fakeFor(function () use ($connector): void {
+ $response = $connector->send(new UserRequest);
+
+ Event::assertDispatched(
+ SentSaloonRequest::class,
+ fn (SentSaloonRequest $event): bool => $event->response === $response,
+ );
+ });
+
+ $connector->send(new UserRequest);
+
+ $this->assertSame(['Sam', 'Taylor'], $received);
+ }
+}
diff --git a/tests/Saloon/Hypervel/Feature/MockRequestTest.php b/tests/Saloon/Hypervel/Feature/MockRequestTest.php
new file mode 100644
index 0000000000..b06a6c65e1
--- /dev/null
+++ b/tests/Saloon/Hypervel/Feature/MockRequestTest.php
@@ -0,0 +1,323 @@
+ 'Sam'], 200),
+ new MockResponse(['name' => 'Alex'], 200),
+ new MockResponse(['error' => 'Server Unavailable'], 500),
+ ]);
+
+ $connector = new TestConnector;
+
+ $responseA = $connector->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connector->send(new UserRequest);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $responseC = $connector->send(new UserRequest);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(['error' => 'Server Unavailable'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+
+ $this->expectException(NoMockResponseFoundException::class);
+
+ $connector->send(new UserRequest);
+ }
+
+ public function testARequestCanBeMockedWithASequenceUsingAClosure(): void
+ {
+ Saloon::fake([
+ function (PendingRequest $request): MockResponse {
+ return new MockResponse(['request' => (string) $request->uri()]);
+ },
+ ]);
+
+ $responseA = TestConnector::make()->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+ }
+
+ public function testARequestCanBeMockedWithAConnectorDefined(): void
+ {
+ $responseA = new MockResponse(['name' => 'Sammyjo20']);
+ $responseB = new MockResponse(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $connectorARequest = new UserRequest;
+ $connectorBRequest = new QueryParameterConnectorRequest;
+
+ Saloon::fake([
+ TestConnector::class => $responseA,
+ QueryParameterConnector::class => $responseB,
+ ]);
+
+ $responseA = $connectorA->send($connectorARequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorB->send($connectorBRequest);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ }
+
+ public function testARequestCanBeMockedWithAConnectorDefinedUsingAClosure(): void
+ {
+ Saloon::fake([
+ TestConnector::class => function (PendingRequest $request): MockResponse {
+ return new MockResponse(['request' => (string) $request->uri()]);
+ },
+ ]);
+
+ $responseA = TestConnector::make()->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+ }
+
+ public function testARequestCanBeMockedWithARequestDefined(): void
+ {
+ $responseA = new MockResponse(['name' => 'Sammyjo20']);
+ $responseB = new MockResponse(['name' => 'Alex']);
+
+ $connectorA = new TestConnector;
+ $connectorB = new QueryParameterConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new QueryParameterConnectorRequest;
+
+ Saloon::fake([
+ UserRequest::class => $responseA,
+ QueryParameterConnectorRequest::class => $responseB,
+ ]);
+
+ $responseA = $connectorA->send($requestA);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorB->send($requestB);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ }
+
+ public function testARequestCanBeMockedWithARequestDefinedUsingAClosure(): void
+ {
+ Saloon::fake([
+ UserRequest::class => function (PendingRequest $request): MockResponse {
+ return new MockResponse(['request' => (string) $request->uri()]);
+ },
+ ]);
+
+ $responseA = TestConnector::make()->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+ }
+
+ public function testARequestCanBeMockedWithAUrlDefined(): void
+ {
+ $responseA = new MockResponse(['name' => 'Sammyjo20']);
+ $responseB = new MockResponse(['name' => 'Alex']);
+ $responseC = new MockResponse(['error' => 'Server Broken'], 500);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ Saloon::fake([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ 'google.com/*' => $responseC, // Test Different Route,
+ ]);
+
+ $responseA = $connectorA->send($requestA);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorA->send($requestB);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $responseC = $connectorB->send($requestC);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(['error' => 'Server Broken'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+ }
+
+ public function testYouCanCreateWildcardUrlMocks(): void
+ {
+ $responseA = new MockResponse(['name' => 'Sammyjo20']);
+ $responseB = new MockResponse(['name' => 'Alex']);
+ $responseC = new MockResponse(['error' => 'Server Broken'], 500);
+
+ $connectorA = new TestConnector;
+ $connectorB = new DifferentServiceConnector;
+
+ $requestA = new UserRequest;
+ $requestB = new ErrorRequest;
+ $requestC = new DifferentServiceUserRequest;
+
+ Saloon::fake([
+ 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route
+ 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes
+ '*' => $responseC,
+ ]);
+
+ $responseA = $connectorA->send($requestA);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['name' => 'Sammyjo20'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $responseB = $connectorA->send($requestB);
+
+ $this->assertTrue($responseB->isMocked());
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $responseC = $connectorB->send($requestC);
+
+ $this->assertTrue($responseC->isMocked());
+ $this->assertSame(['error' => 'Server Broken'], $responseC->json());
+ $this->assertSame(500, $responseC->status());
+ }
+
+ public function testARequestCanBeMockedWithAUrlDefinedUsingAClosure(): void
+ {
+ Saloon::fake([
+ 'tests.saloon.dev/*' => function (PendingRequest $request): MockResponse {
+ return new MockResponse(['request' => (string) $request->uri()]);
+ },
+ ]);
+
+ $responseA = TestConnector::make()->send(new UserRequest);
+
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+ }
+
+ public function testTheSameGlobalMockClientIsReusedOnFurtherCalls(): void
+ {
+ $mockClientA = Saloon::fake([
+ new MockResponse(['name' => 'Sam'], 200),
+ ]);
+
+ $mockClientB = Saloon::fake([
+ new MockResponse(['name' => 'Alex'], 200),
+ ]);
+
+ $this->assertEquals(new MockClient([
+ new MockResponse(['name' => 'Sam'], 200),
+ new MockResponse(['name' => 'Alex'], 200),
+ ]), MockClient::getGlobal());
+
+ $this->assertSame($mockClientA, $mockClientB);
+ $this->assertSame($mockClientA, Saloon::mockClient());
+ $this->assertSame($mockClientB, Saloon::mockClient());
+ }
+
+ public function testFakingAgainKeepsTheGlobalMockClientsRecordsAndSettings(): void
+ {
+ $mockClient = Saloon::fake([
+ new MockResponse(['name' => 'Sam']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new UserRequest);
+ $mockClient->withoutCache();
+
+ Saloon::fake([
+ new MockResponse(['name' => 'Alex']),
+ ]);
+
+ $responseB = TestConnector::make()->send(new UserRequest);
+
+ $this->assertSame(['name' => 'Alex'], $responseB->json());
+ $this->assertSame($mockClient, Saloon::mockClient());
+ $this->assertTrue($mockClient->shouldBypassResponseCache());
+ $this->assertSame([$responseA, $responseB], $mockClient->recorded()->all());
+ }
+
+ public function testAnExplicitMockClientReplacesTheGlobalMockClient(): void
+ {
+ $originalClient = Saloon::fake([
+ new MockResponse(['name' => 'Sam']),
+ ]);
+ $replacementClient = new MockClient([
+ new MockResponse(['name' => 'Alex']),
+ ]);
+
+ $this->assertSame($replacementClient, Saloon::fake($replacementClient));
+ $this->assertSame($replacementClient, Saloon::mockClient());
+
+ $response = TestConnector::make()->send(new UserRequest);
+
+ $this->assertSame(['name' => 'Alex'], $response->json());
+ $this->assertCount(1, $replacementClient->recorded());
+ $this->assertCount(0, $originalClient->recorded());
+ }
+}
diff --git a/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php b/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php
new file mode 100644
index 0000000000..a8514ac9aa
--- /dev/null
+++ b/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php
@@ -0,0 +1,43 @@
+ 'Sam'], 200, ['X-Greeting' => 'Howdy']),
+ ]);
+
+ $fakeResponse = new FakeResponse(['name' => 'Gareth'], 201, ['X-Greeting' => 'Hello']);
+
+ $request = new UserRequest;
+ $request->middleware()->onRequest(fn (): FakeResponse => $fakeResponse);
+
+ $response = TestConnector::make()->send($request);
+
+ $this->assertSame(['name' => 'Gareth'], $response->json());
+ $this->assertSame(201, $response->status());
+ $this->assertSame('Hello', $response->header('X-Greeting'));
+ }
+}
diff --git a/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
new file mode 100644
index 0000000000..2fe82b2b87
--- /dev/null
+++ b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
@@ -0,0 +1,170 @@
+ new MockResponse(['name' => 'Sam'], 200),
+ ]);
+
+ TestConnector::make()->send(new UserRequest);
+
+ Saloon::assertSent(UserRequest::class);
+ }
+
+ public function testThatAssertSentWorksWithAClosure(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500),
+ ]);
+
+ $originalRequest = new UserRequest;
+ $originalResponse = TestConnector::make()->send($originalRequest);
+
+ Saloon::assertSent(function (Request $request, Response $response) use ($originalRequest, $originalResponse): bool {
+ return $request === $originalRequest && $response === $originalResponse;
+ });
+
+ $newRequest = new ErrorRequest;
+ $newResponse = TestConnector::make()->send($newRequest);
+
+ Saloon::assertSent(function (Request $request, Response $response) use ($newRequest, $newResponse): bool {
+ return $request === $newRequest && $response === $newResponse;
+ });
+ }
+
+ public function testThatAssertSentWorksWithAUrl(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ]);
+
+ TestConnector::make()->send(new UserRequest);
+
+ Saloon::assertSent('saloon.dev/*');
+ Saloon::assertSent('/user');
+ Saloon::assertSent('api/user');
+ }
+
+ public function testThatAssertNotSentWorksWithARequest(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500),
+ ]);
+
+ TestConnector::make()->send(new ErrorRequest);
+
+ Saloon::assertNotSent(UserRequest::class);
+ }
+
+ public function testThatAssertNotSentWorksWithAClosure(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500),
+ ]);
+
+ TestConnector::make()->send(new ErrorRequest);
+
+ Saloon::assertNotSent(function (Request $request): bool {
+ return $request instanceof UserRequest;
+ });
+ }
+
+ public function testThatAssertNotSentWorksWithAUrl(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ]);
+
+ TestConnector::make()->send(new UserRequest);
+
+ Saloon::assertNotSent('google.com/*');
+ Saloon::assertNotSent('/error');
+ }
+
+ // REMOVED: that assertSentJson works properly - the deprecated assertSentJson() is not ported.
+
+ public function testAssertNothingSentWorksProperly(): void
+ {
+ Saloon::fake([
+ UserRequest::class => new MockResponse(['name' => 'Sam'], 200),
+ ]);
+
+ Saloon::assertNothingSent();
+ }
+
+ public function testAssertSentCountWorksProperly(): void
+ {
+ Saloon::fake([
+ new MockResponse(['name' => 'Sam'], 200),
+ new MockResponse(['name' => 'Taylor'], 200),
+ new MockResponse(['name' => 'Marcel'], 200),
+ ]);
+
+ TestConnector::make()->send(new UserRequest);
+ TestConnector::make()->send(new UserRequest);
+ TestConnector::make()->send(new UserRequest);
+
+ Saloon::assertSentCount(3);
+ }
+
+ public function testYouCanMockExceptions(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Patrick'])->throw(fn (PendingRequest $pendingRequest): Exception => new Exception('Unable to connect!')),
+ ]);
+
+ $okResponse = TestConnector::make()->send(new UserRequest);
+
+ $this->assertSame(['name' => 'Sam'], $okResponse->json());
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Unable to connect!');
+
+ TestConnector::make()->send(new UserRequest);
+ }
+
+ public function testYouCanMockNormalExceptions(): void
+ {
+ Saloon::fake([
+ MockResponse::make(['name' => 'Michael'])->throw(new Exception('Custom Exception!')),
+ ]);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Custom Exception!');
+
+ TestConnector::make()->send(new UserRequest);
+ }
+}
diff --git a/tests/Saloon/PackageMetadataTest.php b/tests/Saloon/PackageMetadataTest.php
index 11e1385dfb..798856ab94 100644
--- a/tests/Saloon/PackageMetadataTest.php
+++ b/tests/Saloon/PackageMetadataTest.php
@@ -9,7 +9,6 @@
use Hypervel\Support\DefaultProviders;
use Hypervel\Tests\TestCase;
use JsonException;
-use ReflectionClass;
class PackageMetadataTest extends TestCase
{
@@ -90,29 +89,6 @@ public function testProviderAndFacadeAreDiscoverable(): void
$this->assertNotContains(SaloonServiceProvider::class, (new DefaultProviders)->toArray());
}
- public function testFacadeDocumentsTheManagerSurface(): void
- {
- $docblock = (new ReflectionClass(Saloon::class))->getDocComment();
- $this->assertIsString($docblock);
-
- foreach ([
- 'middleware',
- 'fake',
- 'mockClient',
- 'clearFake',
- 'assertSent',
- 'assertNotSent',
- 'assertSentInOrder',
- 'assertNothingSent',
- 'assertSentCount',
- 'resolveCacheScopeUsing',
- 'fixturePath',
- 'throwOnMissingFixtures',
- ] as $method) {
- $this->assertStringContainsString(" {$method}(", $docblock);
- }
- }
-
/**
* Read the split-package Composer manifest.
*
From 9741076cc73540c91b044e04c207f8dfd84def1a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:17:03 +0000
Subject: [PATCH 11/26] Map Saloon's observability middleware onto native
integrations
The Laravel plugin (saloonphp/laravel-plugin at 47d06ef8621a) adds
Telescope, Pulse and Nightwatch recording middleware to its own Guzzle
sender. Saloon here sends through the HTTP client, so Telescope's
ClientRequestWatcher already records its requests with the saloon,
connection and request tags, and the plugin's Telescope middleware
would duplicate it. Hypervel has no Pulse or Nightwatch package. The
README, service provider and sender test record these exclusions.
Coverage added for the plugin's cases the existing tests did not
assert:
- ClientRequestWatcherTest decodes hal+json (with and without a
charset) and vnd.api+json request bodies, and decodes and masks a
hal+json response.
- GuzzleSenderTest checks that Http::globalMiddleware() runs on Saloon
requests, which OpenTelemetry's HTTP client instrumentation relies on.
- TelescopeTagTest asserts the Turbopuffer tag value.
Validation: ClientRequestWatcherTest, GuzzleSenderTest, SenderTest,
TelescopeTagTest, the Saloon suite (ParaTest) and php-cs-fixer.
---
src/saloon/README.md | 2 +-
src/saloon/src/SaloonServiceProvider.php | 3 +
.../Contracts/Telescope/TelescopeTagTest.php | 1 +
tests/Saloon/Feature/GuzzleSenderTest.php | 21 ++++++-
tests/Saloon/Unit/SenderTest.php | 6 ++
.../Watchers/ClientRequestWatcherTest.php | 59 +++++++++++++++++++
6 files changed, 90 insertions(+), 2 deletions(-)
diff --git a/src/saloon/README.md b/src/saloon/README.md
index d45b1b134b..d5e4ad5bfd 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -8,7 +8,7 @@ Requests and pending requests use fluent methods in the style of Hypervel's HTTP
Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
-There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `defaultSender()`, sender resolvers and handler stacks are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Prevent stray requests with `Http::preventStrayRequests()`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
+There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `defaultSender()`, sender resolvers and handler stacks are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
diff --git a/src/saloon/src/SaloonServiceProvider.php b/src/saloon/src/SaloonServiceProvider.php
index 8a2e6d3bf1..8d45579796 100644
--- a/src/saloon/src/SaloonServiceProvider.php
+++ b/src/saloon/src/SaloonServiceProvider.php
@@ -62,6 +62,9 @@ public function boot(HttpFactory $httpFactory, ConfigRepository $config): void
$options,
);
+ // The plugin's Telescope, Pulse and Nightwatch middleware are not included: Saloon requests go through the
+ // HTTP client, which Telescope's watcher records, and Hypervel has no Pulse or Nightwatch package.
+
$this->registerConsoleResources();
}
diff --git a/tests/Contracts/Telescope/TelescopeTagTest.php b/tests/Contracts/Telescope/TelescopeTagTest.php
index 4b791debe1..06808e559b 100644
--- a/tests/Contracts/Telescope/TelescopeTagTest.php
+++ b/tests/Contracts/Telescope/TelescopeTagTest.php
@@ -18,6 +18,7 @@ public function testCasesMapToExpectedWireValues(): void
$this->assertSame('algolia', TelescopeTag::Algolia->value);
$this->assertSame('meilisearch', TelescopeTag::Meilisearch->value);
$this->assertSame('saloon', TelescopeTag::Saloon->value);
+ $this->assertSame('turbopuffer', TelescopeTag::Turbopuffer->value);
$this->assertSame('typesense', TelescopeTag::Typesense->value);
}
diff --git a/tests/Saloon/Feature/GuzzleSenderTest.php b/tests/Saloon/Feature/GuzzleSenderTest.php
index eac5c1a569..c3e82db9c2 100644
--- a/tests/Saloon/Feature/GuzzleSenderTest.php
+++ b/tests/Saloon/Feature/GuzzleSenderTest.php
@@ -12,6 +12,7 @@
use Hypervel\Testbench\TestCase;
use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
+use Psr\Http\Message\RequestInterface;
// Saloon sends through Hypervel's HTTP client rather than its own Guzzle sender, so these cases capture the request
// and options that reach the HTTP client.
@@ -72,7 +73,25 @@ public function testTheGuzzleSenderWillSendAllHeadersQueryParametersAndConfig():
}
// REMOVED: the default handler stack cases. Registered HTTP connections own the transport handler; Guzzle-level
- // hooks are Http::globalMiddleware() and Saloon's PSR request hooks and middleware.
+ // hooks are Http::globalMiddleware(), which the next case covers, and Saloon's PSR request hooks and middleware.
+
+ public function testHttpGlobalMiddlewareRunsOnSaloonRequests(): void
+ {
+ Http::globalRequestMiddleware(
+ fn (RequestInterface $request): RequestInterface => $request->withHeader('X-Global', 'applied'),
+ );
+
+ $sent = null;
+ Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface {
+ $sent = $request->toPsrRequest();
+
+ return Http::response();
+ });
+
+ (new TestConnector)->send(new UserRequest);
+
+ $this->assertSame('applied', $sent->getHeaderLine('X-Global'));
+ }
// Saloon decides when a response has failed, so the HTTP client never throws for an error status.
public function testTheGuzzleSenderHasDefaultOptionsConfigured(): void
diff --git a/tests/Saloon/Unit/SenderTest.php b/tests/Saloon/Unit/SenderTest.php
index 4b5eb7f525..6c7838bfa9 100644
--- a/tests/Saloon/Unit/SenderTest.php
+++ b/tests/Saloon/Unit/SenderTest.php
@@ -68,6 +68,12 @@ public function testYouCanOverwriteTheConnectionOnAConnectorUsingTheResolveHttpC
// REMOVED: the case that sets a class which does not implement the sender contract. There is no sender contract.
+ // laravel-plugin's Feature/TelescopeMiddlewareTest covers the plugin's own Telescope recording middleware. Saloon
+ // requests reach Telescope's HTTP client watcher instead, so these cases check the tags and opt-out passed to it,
+ // and Telescope's ClientRequestWatcherTest covers formatting and masking.
+ // REMOVED: laravel-plugin's Feature/PulseMiddlewareTest and Feature/NightwatchMiddlewareTest. Hypervel has no Pulse
+ // or Nightwatch package.
+
public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection(): void
{
$http = new Factory;
diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php
index 5832af4a71..d283eab553 100644
--- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php
+++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php
@@ -211,6 +211,41 @@ public static function jsonContentTypeProvider(): array
];
}
+ #[DataProvider('jsonMediaTypeRequestProvider')]
+ public function testValidJsonMediaTypeRequestIsDecoded(string $contentType, string $body, array $payload): void
+ {
+ $client = $this->makeClient([new Response(204)]);
+
+ $this->executeTransfer(
+ $client,
+ new Request('POST', 'https://hypervel.org/raw', ['Content-Type' => $contentType], $body),
+ );
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame($payload, $entry->content['payload']);
+ }
+
+ /**
+ * Provide JSON media types and payloads.
+ */
+ public static function jsonMediaTypeRequestProvider(): array
+ {
+ return [
+ 'hal+json' => [
+ 'application/hal+json',
+ '{"_links":{"self":{"href":"/api/users/1"}},"name":"Test"}',
+ ['_links' => ['self' => ['href' => '/api/users/1']], 'name' => 'Test'],
+ ],
+ 'hal+json with charset' => ['application/hal+json; charset=utf-8', '{"id":1}', ['id' => 1]],
+ 'vnd.api+json' => [
+ 'application/vnd.api+json',
+ '{"data":{"type":"users","id":"1"}}',
+ ['data' => ['type' => 'users', 'id' => '1']],
+ ],
+ ];
+ }
+
public function testHeaderlessJsonRequestIsMaskedAndDeepHeaderlessJsonIsPurged(): void
{
Telescope::hideRequestParameters(['password']);
@@ -336,6 +371,30 @@ public function testClientRequestWatcherPlainTextResponse()
$this->assertSame('plain telescope response', $entry->content['response']);
}
+ public function testHalJsonResponseIsDecodedAndMasked(): void
+ {
+ Telescope::hideResponseParameters(['access_token']);
+ $client = $this->makeClient([
+ new Response(200, ['Content-Type' => 'application/hal+json'], json_encode([
+ '_links' => ['self' => ['href' => '/api/users/1']],
+ 'name' => 'Test',
+ 'access_token' => 'secret-token-value',
+ 'expires_in' => 3600,
+ ])),
+ ]);
+
+ $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/hal'));
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame([
+ '_links' => ['self' => ['href' => '/api/users/1']],
+ 'name' => 'Test',
+ 'access_token' => '********',
+ 'expires_in' => 3600,
+ ], $entry->content['response']);
+ }
+
public function testClientRequestWatcherRegistersServerErrorResponse()
{
$client = $this->makeClient([
From 769472631379f71e03c25a00fff5a4af753e20fa Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 22:55:41 +0000
Subject: [PATCH 12/26] Reconcile Saloon response caching with the cache plugin
The cache plugin (saloonphp/cache-plugin at 5a036bae401d) reads the
cache before mock responses are matched, lets MockClient::withoutCache()
skip the cache (#18), and adds clearCache() to forget a cached response
without sending the request (#17).
- SaloonManager::send() now reads or invalidates the cache before
matching the mock client, so a hit consumes no mock response or
fixture. Mock, fixture and transport responses share one write path,
and withoutCache() bypasses reads, writes and invalidation. A fake
supplied by request middleware still bypasses the cache.
- Responses are written unless failed(), as upstream, so request and
connector failure hooks apply. Previously a hook-rejected 200 was
cached and replayed to every retry.
- SaloonManager::clearCache() prepares the request as send() does and
forgets the key from the same store, key and scope. Connector and
HasCaching delegate to it; native parameter types replace upstream's
wrong-counterpart exceptions.
- The service provider allows CachedResponse through the cache's
serializable-class policy. Without it, every serializing store
(file, database, Redis) missed silently.
- Cache hits are no longer recorded on the mock client, matching
upstream, since nothing was sent.
Cache stores replace the plugin's drivers, so its PSR adapter test is
REMOVED and its assertions are covered by the native-store tests, and
the Laravel driver test is a file-store round trip. Upstream's
real-request case runs against the engine test server.
The laravel plugin (#76) fixed its Telescope middleware's return type
for scalar JSON response bodies; its body sanitizing still records them
as HTML or empty, as Telescope does. ClientRequestWatcher and
RequestWatcher now record valid scalar JSON under a JSON media type as
sent, within size limits, and label only an empty body as empty.
Validation: the Saloon and Telescope suites (ParaTest),
tests/Integration/Saloon on the engine test servers, the changed test
files after review corrections, FacadeDocblocksTest, composer analyse
and php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 22 +-
src/saloon/README.md | 2 +
src/saloon/src/Cache/Traits/HasCaching.php | 9 +
src/saloon/src/Facades/Saloon.php | 1 +
src/saloon/src/Http/Connector.php | 11 +
src/saloon/src/SaloonManager.php | 56 +-
src/saloon/src/SaloonServiceProvider.php | 7 +-
.../src/Watchers/ClientRequestWatcher.php | 9 +-
src/telescope/src/Watchers/RequestWatcher.php | 9 +-
.../Saloon/Cache/Feature/CacheTest.php | 69 ++
tests/Saloon/Cache/CacheTest.php | 530 ----------
tests/Saloon/Cache/Feature/CacheTest.php | 930 ++++++++++++++++++
tests/Saloon/Cache/Feature/ClearCacheTest.php | 218 ++++
.../Hypervel/HypervelCacheDriverTest.php | 92 ++
.../Feature/ResponseCacheMockFixtureTest.php | 118 +++
.../Fixtures/Connectors/CachedConnector.php | 37 +
.../Requests/AllowedCachedPostRequest.php | 60 ++
.../Fixtures/Requests/BodyCacheKeyRequest.php | 47 +
.../Requests/CachedConnectorRequest.php | 24 +
.../Fixtures/Requests/CachedPostRequest.php | 50 +
.../Fixtures/Requests/CachedUserRequest.php | 36 +
.../CachedUserRequestOnCachedConnector.php | 44 +
.../CachedUserRequestWithoutCacheable.php | 35 +
.../Requests/CustomKeyCachedUserRequest.php | 45 +
.../Requests/HypervelCachedUserRequest.php | 44 +
.../Requests/ShortLivedCachedUserRequest.php | 36 +
tests/Saloon/RateLimit/RateLimitTest.php | 77 +-
.../Watchers/ClientRequestWatcherTest.php | 65 ++
.../Watchers/RequestWatchersTest.php | 48 +
30 files changed, 2172 insertions(+), 561 deletions(-)
create mode 100644 tests/Integration/Saloon/Cache/Feature/CacheTest.php
delete mode 100644 tests/Saloon/Cache/CacheTest.php
create mode 100644 tests/Saloon/Cache/Feature/CacheTest.php
create mode 100644 tests/Saloon/Cache/Feature/ClearCacheTest.php
create mode 100644 tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php
create mode 100644 tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/AllowedCachedPostRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CachedPostRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CachedUserRequestOnCachedConnector.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CachedUserRequestWithoutCacheable.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/CustomKeyCachedUserRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/HypervelCachedUserRequest.php
create mode 100644 tests/Saloon/Cache/Fixtures/Requests/ShortLivedCachedUserRequest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 698224537b..93e4fa002e 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -196,7 +196,7 @@ saloonphp/cache-plugin:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package. Upstream caching maps to Cache and SaloonManager using Hypervel's cache services; assess behavior against that integration instead of duplicating upstream drivers. Tests map to tests/Saloon/Cache.
+ notes: Part of the unified src/saloon package. Upstream's CacheMiddleware and CacheRecorderMiddleware map to SaloonManager::send(), which reads the cache before matching the mock client and writes after the response, and clearCache() maps to SaloonManager::clearCache(); keys come from Cache/CacheKey. Drivers map to framework cache stores selected by Cacheable::cacheStore(); assess driver changes against that integration instead of duplicating them. Connectors implement Cacheable and override cacheKey() and cacheableMethods() rather than using HasCaching. SaloonServiceProvider allows CachedResponse through the cache's serializable-class policy. Tests map to tests/Saloon/Cache/{Feature,Fixtures}, with the real-request case in tests/Integration/Saloon/Cache.
saloonphp/pagination-plugin:
branch: v2
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 54f51c08b1..15892bb4b5 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1646,7 +1646,7 @@ A null store uses `saloon.cache.store`, which itself falls back to Hypervel's de
An integer cache duration is measured in seconds. You may also return a `DateInterval` or `DateTimeInterface` instance.
-GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. Only successful network responses are written. Saloon fakes do not read or populate the response cache.
+GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. A response is written unless it failed, including failures defined by a request or connector's [failure check](#error-handling). Mock responses and fixtures are cached like network responses, so a cached response is returned before the mock client is consulted. See [Fixtures](#fixtures) for keeping tests out of the cache.
The default cache key includes the connector and request classes, method, final URI, headers, cookies, authentication and certificate state, prepared body, and response-affecting transport options. This means a QUERY request's body is part of its identity. Responses cached from streaming bodies are buffered because the cache must retain their bytes.
@@ -1676,7 +1676,17 @@ $request->disableCaching();
$request->invalidateCache();
```
-The request or its connector must implement `Cacheable` before these controls are used. `invalidateCache` removes the matching value and refreshes it from the network.
+The request or its connector must implement `Cacheable` before these controls are used. `invalidateCache` removes the matching value and caches the new response.
+
+To remove a cached response without sending the request, pass the connector to the request's `clearCache` method. A connector's `clearCache` method accepts any request, including one without the `HasCaching` trait:
+
+```php
+$request->clearCache($connector);
+
+$connector->clearCache($request);
+```
+
+Clearing ignores `disableCaching` and the cacheable methods. It prepares the request as sending does, running its plugins, boot methods, and middleware, so it removes the value the request would read. `SendingSaloonRequest` listeners do not run, so any change a listener makes to the request's identity must also be made by a hook or middleware, or derived by a [custom cache key](#custom-cache-keys).
To always keep a request type out of connector caching, override `cachingEnabled` on a request without the `HasCaching` trait. This is useful for live streams that must not be buffered:
@@ -1734,7 +1744,7 @@ public function boot(): void
The callback runs when a cacheable operation resolves its key. It must resolve the current tenant at invocation time; do not capture one tenant while the worker boots. Returning null deliberately allows sharing for connectors where a separate scope is not needed.
-Authentication state already separates tenant-owned credentials, and the final URI separates tenant-specific endpoints. A cache scope expresses an additional application policy, such as preventing tenants with shared platform credentials from sharing provider responses. It is applied to reads, writes, invalidation, and custom keys.
+Authentication state already separates tenant-owned credentials, and the final URI separates tenant-specific endpoints. A cache scope expresses an additional application policy, such as preventing tenants with shared platform credentials from sharing provider responses. It is applied to reads, writes, invalidation, clearing, and custom keys.
## API Pagination
@@ -2185,7 +2195,7 @@ Macros remain registered for the worker lifetime. Hypervel automatically clears
## Testing
-Saloon provides a strict mock client, response sequences, fixtures, request recording, and PHPUnit assertions. Saloon fakes operate at the integration layer, before caching, admission policies, and Hypervel HTTP transport.
+Saloon provides a strict mock client, response sequences, fixtures, request recording, and PHPUnit assertions. Saloon fakes operate at the integration layer, after the response cache is checked and before admission policies and Hypervel HTTP transport.
### Faking Responses
@@ -2269,7 +2279,7 @@ When no class or URL response matches, Saloon consumes the next sequence respons
### Assertions
-The facade provides assertions for recorded Saloon requests:
+The facade provides assertions for recorded Saloon requests. Responses served from the [response cache](#caching) are not recorded, because nothing was sent:
```php
Saloon::assertSent(GetUser::class);
@@ -2329,7 +2339,7 @@ class GitHubFixture extends Fixture
Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata.
-Mock responses and existing fixtures never use the response cache, but a request that reaches the network through a mock client, such as a missing fixture being recorded or an allowed stray request, still does. Call `withoutCache` on the mock client to keep those requests out of the cache, so a recorded fixture always comes from the network:
+Mock responses and fixtures are cached like network responses. Once a cacheable request has been cached, later sends return the cached response instead of the next mock response or an updated fixture file, and a missing fixture is not recorded. Call `withoutCache` on the mock client to keep its requests from reading, writing, or invalidating the cache:
```php
Saloon::fake([
diff --git a/src/saloon/README.md b/src/saloon/README.md
index d5e4ad5bfd..4c42f652fe 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -20,6 +20,8 @@ Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRet
Pools send requests concurrently through coroutines instead of promises, so there is no `sendAsync()`, pending requests have no `isAsynchronous()` or `setAsynchronous()`, and pools accept requests but not promises. A pool's `send()` waits for every request and returns the successful responses keyed and ordered as the requests were given; `process()` sends them without keeping the responses. Concurrency is a positive integer: upstream's callable and unlimited `0` forms are not supported, because the coroutine pool has a fixed number of slots. Handlers receive the response or exception and the request's key, without upstream's aggregate promise. Ending a request generator stops further requests, but a request already waiting for a slot still starts and running requests are not canceled. Every response that `toException()` treats as failed, including through custom failure hooks, goes to the exception handler; upstream's real asynchronous path checks them only after Guzzle rejects a transfer. Unhandled request failures, handler failures and invalid items throw `PoolException` once every started request has finished, instead of being ignored or ending the pool early. `requests()` replaces `getRequests()` and calls a request callback each time the pool is sent, so a callback that returns a new iterable lets the pool be sent again. See [Concurrent Requests](https://hypervel.org/docs/saloon#concurrent-requests).
+Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cacheStore()` instead of upstream's `resolveCacheDriver()` and `cacheExpiryInSeconds()`, so there is no `Driver` contract and no Flysystem, PSR or Laravel driver; add other caches as framework cache stores. `CachingException` replaces `HasCachingException`. Connectors are read-only, so they implement `Cacheable` without the `HasCaching` request controls and override `cacheKey()` and `cacheableMethods()`, which replaces `getCacheableMethods()`. GET, HEAD, OPTIONS and QUERY requests are cacheable by default. See [Caching](https://hypervel.org/docs/saloon#caching).
+
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
diff --git a/src/saloon/src/Cache/Traits/HasCaching.php b/src/saloon/src/Cache/Traits/HasCaching.php
index 6467813e73..4ef09c89f6 100644
--- a/src/saloon/src/Cache/Traits/HasCaching.php
+++ b/src/saloon/src/Cache/Traits/HasCaching.php
@@ -5,6 +5,7 @@
namespace Hypervel\Saloon\Cache\Traits;
use Hypervel\Saloon\Enums\Method;
+use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
use UnitEnum;
@@ -56,6 +57,14 @@ public function invalidateCache(): static
return $this;
}
+ /**
+ * Clear the cached response without sending the request.
+ */
+ public function clearCache(Connector $counterpart): void
+ {
+ $counterpart->clearCache($this);
+ }
+
/**
* Get the cache store name.
*/
diff --git a/src/saloon/src/Facades/Saloon.php b/src/saloon/src/Facades/Saloon.php
index 72025d68df..d008c29e78 100644
--- a/src/saloon/src/Facades/Saloon.php
+++ b/src/saloon/src/Facades/Saloon.php
@@ -15,6 +15,7 @@
* @method static void assertSentCount(int $count, null|string $requestClass = null)
* @method static void assertSentInOrder(array $callbacks)
* @method static \Hypervel\Contracts\Cache\Factory cache()
+ * @method static void clearCache(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request)
* @method static \Hypervel\Saloon\SaloonManager clearFake()
* @method static \Hypervel\Saloon\Http\PendingRequest createPendingRequest(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request)
* @method static \Hypervel\Saloon\Http\Faking\MockClient fake(array|\Hypervel\Saloon\Http\Faking\MockClient $responses = [])
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index 7fa32cd4a6..b97980854a 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -77,6 +77,17 @@ public function createPendingRequest(Request $request): PendingRequest
return $manager->createPendingRequest($this, $request);
}
+ /**
+ * Clear the cached response for a request without sending it.
+ */
+ public function clearCache(Request $counterpart): void
+ {
+ /** @var SaloonManager $manager */
+ $manager = Container::getInstance()->make('saloon');
+
+ $manager->clearCache($this, $counterpart);
+ }
+
/**
* Create a bounded request pool.
*
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index e35cc186b0..c0fd86b736 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -112,24 +112,14 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
// its URI, headers, cookies, and prepared body directly.
$transport = $this->sender->resolveTransport($pendingRequest);
- $fixture = null;
-
- if ($pendingRequest->fakeResponse() === null && $mockClient !== null) {
- $matchedResponse = $mockClient->match($pendingRequest);
-
- if ($matchedResponse instanceof Fixture) {
- $fixture = $matchedResponse;
- $matchedResponse = $matchedResponse->getMockResponse();
- }
-
- $pendingRequest->setFakeResponse($matchedResponse);
- }
-
$response = null;
$responseFromSender = false;
$cacheRepository = null;
$cacheKey = null;
+ $fixture = null;
+ // The cache is read before the mock client is matched, so a hit consumes no mock response or fixture. A
+ // fake supplied by request middleware bypasses the cache.
if ($pendingRequest->fakeResponse() === null
&& $mockClient?->shouldBypassResponseCache() !== true
&& $pendingRequest->isCacheable()) {
@@ -149,6 +139,17 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
}
}
+ if ($response === null && $pendingRequest->fakeResponse() === null && $mockClient !== null) {
+ $matchedResponse = $mockClient->match($pendingRequest);
+
+ if ($matchedResponse instanceof Fixture) {
+ $fixture = $matchedResponse;
+ $matchedResponse = $matchedResponse->getMockResponse();
+ }
+
+ $pendingRequest->setFakeResponse($matchedResponse);
+ }
+
if ($response === null && $pendingRequest->fakeResponse() === null) {
$this->enforceRateLimits($pendingRequest);
}
@@ -184,10 +185,10 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
$this->recordRateLimitCooldowns($pendingRequest, $response);
}
- if ($responseFromSender
- && $cacheRepository !== null
+ if ($cacheRepository !== null
&& $cacheKey !== null
- && $response->successful()) {
+ && ! $response->isCached()
+ && ! $response->failed()) {
$cacheRepository->put(
$cacheKey,
CachedResponse::fromResponse($response),
@@ -195,7 +196,10 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
);
}
- $mockClient?->recordResponse($response);
+ // A cache hit sends nothing, so mock client assertions do not count it.
+ if (! $response->isCached()) {
+ $mockClient?->recordResponse($response);
+ }
if ($this->events->hasListeners(SentSaloonRequest::class)) {
$this->events->dispatch(new SentSaloonRequest($pendingRequest, $response));
@@ -435,6 +439,24 @@ public function resolveCacheScope(PendingRequest $pendingRequest): ?string
: null;
}
+ /**
+ * Clear the cached response for a request without sending it.
+ *
+ * The request is prepared as it is for sending, through its plugins, boot methods and middleware, so the same
+ * store, key and scope are resolved. Sending listeners do not run.
+ */
+ public function clearCache(Connector $connector, Request $request): void
+ {
+ $pendingRequest = $this->createPendingRequest($connector, $request)
+ ->finalizeUri()
+ ->prepareBody()
+ ->validateCachingConfiguration();
+
+ [$repository, $key] = $this->resolveCache($pendingRequest, $this->sender->resolveTransport($pendingRequest));
+
+ $repository->forget($key);
+ }
+
/**
* Set the fixture storage path.
*
diff --git a/src/saloon/src/SaloonServiceProvider.php b/src/saloon/src/SaloonServiceProvider.php
index 8d45579796..76660901d5 100644
--- a/src/saloon/src/SaloonServiceProvider.php
+++ b/src/saloon/src/SaloonServiceProvider.php
@@ -4,12 +4,14 @@
namespace Hypervel\Saloon;
+use Hypervel\Cache\CacheManager;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Http\Client\Factory as HttpFactory;
use Hypervel\RateLimiter\RateLimiter;
+use Hypervel\Saloon\Cache\Data\CachedResponse;
use Hypervel\Saloon\Console\Commands\ListCommand;
use Hypervel\Saloon\Console\Commands\MakeAuthenticator;
use Hypervel\Saloon\Console\Commands\MakeConnector;
@@ -50,8 +52,11 @@ public function register(): void
/**
* Bootstrap the package services.
*/
- public function boot(HttpFactory $httpFactory, ConfigRepository $config): void
+ public function boot(HttpFactory $httpFactory, ConfigRepository $config, CacheManager $cache): void
{
+ // Cached responses are stored as CachedResponse objects, which serializing stores must be allowed to restore.
+ $cache->allowSerializableClassesUsing(static fn (): array => [CachedResponse::class]);
+
$connection = $config->string('saloon.connection.name');
$options = $config->array('saloon.connection.options');
diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php
index 0eb80c1f56..cbbd58497d 100644
--- a/src/telescope/src/Watchers/ClientRequestWatcher.php
+++ b/src/telescope/src/Watchers/ClientRequestWatcher.php
@@ -377,7 +377,12 @@ protected function getResponsePayload(ResponseInterface $response): array|string
return Telescope::PURGED_VALUE;
}
- if (Str::startsWith(strtolower($response->getHeaderLine('content-type') ?: ''), 'text/plain')) {
+ $contentType = strtolower($response->getHeaderLine('content-type'));
+
+ // Scalar JSON such as 0, false or null is shown as sent instead of being labeled empty or HTML.
+ if (Str::startsWith($contentType, 'text/plain')
+ || ($jsonError === JSON_ERROR_NONE && Str::contains($contentType, ['/json', '+json']))
+ ) {
if (strlen($content) >= $sizeLimit) {
return $truncate
? substr($content, 0, $sizeLimit) . ' (truncated...)'
@@ -387,7 +392,7 @@ protected function getResponsePayload(ResponseInterface $response): array|string
return $content;
}
- if (empty($content)) {
+ if ($content === '') {
return 'Empty Response';
}
} catch (Throwable $e) {
diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php
index f846cd14e2..4941f29cec 100644
--- a/src/telescope/src/Watchers/RequestWatcher.php
+++ b/src/telescope/src/Watchers/RequestWatcher.php
@@ -189,7 +189,12 @@ protected function response(Response $response): array|string
return Telescope::PURGED_VALUE;
}
- if (Str::startsWith(strtolower($response->headers->get('Content-Type') ?? ''), 'text/plain')) {
+ $contentType = strtolower($response->headers->get('Content-Type') ?? '');
+
+ // Scalar JSON such as 0, false or null is shown as sent instead of being labeled empty or HTML.
+ if (Str::startsWith($contentType, 'text/plain')
+ || ($jsonError === JSON_ERROR_NONE && Str::contains($contentType, ['/json', '+json']))
+ ) {
return $this->contentWithinLimits($content) ? $content : Telescope::PURGED_VALUE;
}
}
@@ -205,7 +210,7 @@ protected function response(Response $response): array|string
];
}
- if (is_string($content) && empty($content)) {
+ if ($content === '') {
return 'Empty Response';
}
diff --git a/tests/Integration/Saloon/Cache/Feature/CacheTest.php b/tests/Integration/Saloon/Cache/Feature/CacheTest.php
new file mode 100644
index 0000000000..f4b456a3f2
--- /dev/null
+++ b/tests/Integration/Saloon/Cache/Feature/CacheTest.php
@@ -0,0 +1,69 @@
+setUpInteractsWithServer();
+ }
+
+ public function testARequestWithTheHasCachingTraitWillCacheTheResponseWithARealRequest(): void
+ {
+ $responseA = TestConnector::make($this->serverUrl())->send(new CachedUserRequest);
+
+ $responseBody = [
+ 'name' => 'Sammyjo20',
+ 'actual_name' => 'Sam',
+ 'twitter' => '@carre_sam',
+ ];
+
+ $this->assertFalse($responseA->isFaked());
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame($responseBody, $responseA->json());
+
+ // Now send a response without the mock middleware, and it should be cached!
+
+ $responseB = TestConnector::make($this->serverUrl())->send(new CachedUserRequest);
+
+ $this->assertTrue($responseB->isFaked());
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame($responseBody, $responseB->json());
+ }
+
+ /**
+ * Get the test server URL.
+ */
+ protected function serverUrl(): string
+ {
+ return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort());
+ }
+}
diff --git a/tests/Saloon/Cache/CacheTest.php b/tests/Saloon/Cache/CacheTest.php
deleted file mode 100644
index c854e95ece..0000000000
--- a/tests/Saloon/Cache/CacheTest.php
+++ /dev/null
@@ -1,530 +0,0 @@
-http();
- $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]);
- $manager = $this->manager($http);
- $connector = new CacheConnectorStub;
-
- $first = $manager->send($connector, new CacheRequestStub);
- $second = $manager->send($connector, new CacheRequestStub);
- $refreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache());
-
- $this->assertFalse($first->isCached());
- $this->assertSame(['version' => 1], $first->json());
- $this->assertTrue($second->isCached());
- $this->assertTrue($second->isFaked());
- $this->assertFalse($second->isMocked());
- $this->assertSame(['version' => 1], $second->json());
- $this->assertFalse($refreshed->isCached());
- $this->assertSame(['version' => 2], $refreshed->json());
- $http->assertSentCount(2);
- }
-
- public function testSendingAndSentEventsArePairedForCacheHits(): void
- {
- $http = $this->http();
- $http->fake(['*' => Factory::response(['cached' => true])]);
- $events = new Dispatcher;
- $dispatched = [];
- $events->listen(SendingSaloonRequest::class, function () use (&$dispatched): void {
- $dispatched[] = 'sending';
- });
- $events->listen(SentSaloonRequest::class, function () use (&$dispatched): void {
- $dispatched[] = 'sent';
- });
- $manager = $this->manager($http, events: $events);
-
- $manager->send(new CacheConnectorStub, new CacheRequestStub);
- $cached = $manager->send(new CacheConnectorStub, new CacheRequestStub);
-
- $this->assertTrue($cached->isCached());
- $this->assertSame(['sending', 'sent', 'sending', 'sent'], $dispatched);
- $http->assertSentCount(1);
- }
-
- public function testSendingListenerMutationsAreIncludedInTheCacheIdentity(): void
- {
- $http = $this->http();
- $requests = [];
- $http->fake(function (HttpRequest $request) use (&$requests) {
- $requests[] = [$request->url(), $request->header('X-Variant'), $request->body()];
-
- return Factory::response(['version' => count($requests)]);
- });
- $events = new Dispatcher;
- $variant = 'a';
- $events->listen(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use (&$variant): void {
- $event->pendingRequest
- ->withHeader('X-Variant', $variant)
- ->withQueryParameters(['variant' => $variant])
- ->withData(['variant' => $variant]);
- });
- $manager = $this->manager($http, events: $events);
- $connector = new CacheConnectorStub;
-
- $first = $manager->send($connector, new CacheRequestStub);
- $variant = 'b';
- $second = $manager->send($connector, new CacheRequestStub);
- $variant = 'a';
- $cached = $manager->send($connector, new CacheRequestStub);
-
- $this->assertSame(1, $first->json('version'));
- $this->assertSame(2, $second->json('version'));
- $this->assertSame(1, $cached->json('version'));
- $this->assertTrue($cached->isCached());
- $this->assertSame([
- ['https://api.example.com/users?variant=a', ['a'], '{"variant":"a"}'],
- ['https://api.example.com/users?variant=b', ['b'], '{"variant":"b"}'],
- ], $requests);
- $http->assertSentCount(2);
- }
-
- public function testCacheMissResolvesTransportConfigurationOnce(): void
- {
- $http = new Factory;
- $http->registerConnection('saloon');
- $http->fake(['*' => Factory::response(['ok' => true])]);
- $senderConfig = m::mock(ConfigRepository::class);
- $senderConfig->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
- $sender = new CountingCacheSender($http, $senderConfig);
-
- $this->manager($http, sender: $sender)->send(new CacheConnectorStub, new CacheRequestStub);
-
- $this->assertSame(1, $sender->transportResolutions);
- }
-
- public function testCacheScopeAppliesToReadsWritesAndInvalidation(): void
- {
- $http = $this->http();
- $http->fake(['*' => $http->sequence()
- ->push(['version' => 'tenant-a-1'])
- ->push(['version' => 'tenant-b-1'])
- ->push(['version' => 'tenant-a-2'])]);
- $manager = $this->manager($http);
- $scope = 'tenant-a';
- $manager->resolveCacheScopeUsing(static function () use (&$scope): string {
- return $scope;
- });
- $connector = new CacheConnectorStub;
-
- $tenantA = $manager->send($connector, new CacheRequestStub);
- $tenantACached = $manager->send($connector, new CacheRequestStub);
- $scope = 'tenant-b';
- $tenantB = $manager->send($connector, new CacheRequestStub);
- $tenantBCached = $manager->send($connector, new CacheRequestStub);
- $scope = 'tenant-a';
- $tenantARefreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache());
- $scope = 'tenant-b';
- $tenantBStillCached = $manager->send($connector, new CacheRequestStub);
-
- $this->assertSame('tenant-a-1', $tenantA->json('version'));
- $this->assertTrue($tenantACached->isCached());
- $this->assertSame('tenant-b-1', $tenantB->json('version'));
- $this->assertTrue($tenantBCached->isCached());
- $this->assertSame('tenant-a-2', $tenantARefreshed->json('version'));
- $this->assertFalse($tenantARefreshed->isCached());
- $this->assertSame('tenant-b-1', $tenantBStillCached->json('version'));
- $this->assertTrue($tenantBStillCached->isCached());
- $http->assertSentCount(3);
- }
-
- public function testCacheScopeResolverIsSkippedWhenCachingIsDisabledAndNullAllowsSharing(): void
- {
- $http = $this->http();
- $http->fake(['*' => $http->sequence()
- ->push(['version' => 1])
- ->push(['version' => 2])
- ->push(['version' => 3])]);
- $manager = $this->manager($http);
- $scope = null;
- $resolutions = 0;
- $manager->resolveCacheScopeUsing(static function () use (&$scope, &$resolutions): ?string {
- ++$resolutions;
-
- return $scope;
- });
- $connector = new CacheConnectorStub;
-
- $first = $manager->send($connector, new CacheRequestStub);
- $shared = $manager->send($connector, new CacheRequestStub);
- $manager->send($connector, (new CacheRequestStub)->disableCaching());
- $manager->send($connector, (new CacheRequestStub)->disableCaching());
-
- $this->assertSame(1, $first->json('version'));
- $this->assertSame(1, $shared->json('version'));
- $this->assertTrue($shared->isCached());
- $this->assertSame(2, $resolutions);
- $http->assertSentCount(3);
- }
-
- public function testRequestTypeCanOptOutOfConnectorCachingWithoutCacheControls(): void
- {
- $http = $this->http();
- $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]);
- $manager = $this->manager($http);
- $connector = new CacheableConnectorStub;
- $request = new class extends Request {
- protected Method $method = Method::GET;
-
- /**
- * Resolve the uncached endpoint.
- */
- public function resolveEndpoint(): string
- {
- return '/live';
- }
-
- /**
- * Keep this request type out of connector caching.
- */
- public function cachingEnabled(): bool
- {
- return false;
- }
- };
-
- $first = $manager->send($connector, $request);
- $second = $manager->send($connector, $request);
-
- $this->assertSame(1, $first->json('version'));
- $this->assertSame(2, $second->json('version'));
- $this->assertFalse($first->isCached());
- $this->assertFalse($second->isCached());
- $http->assertSentCount(2);
- }
-
- public function testSaloonFakesDoNotReadOrPopulateTheCache(): void
- {
- $http = $this->http();
- $http->fake(['*' => Factory::response(['network' => true])]);
- $manager = $this->manager($http);
- $connector = new CacheConnectorStub;
-
- $manager->fake([MockResponse::make(['fake' => true])]);
- $fake = $manager->send($connector, new CacheRequestStub);
- $manager->clearFake();
- $network = $manager->send($connector, new CacheRequestStub);
- $cached = $manager->send($connector, new CacheRequestStub);
-
- $this->assertSame(['fake' => true], $fake->json());
- $this->assertTrue($fake->isMocked());
- $this->assertSame(['network' => true], $network->json());
- $this->assertFalse($network->isCached());
- $this->assertSame(['network' => true], $cached->json());
- $this->assertTrue($cached->isCached());
- $http->assertSentCount(1);
- }
-
- public function testFailuresAndDisabledOrUnsupportedMethodsAreNotCached(): void
- {
- $http = $this->http();
- $http->fake(['*' => $http->sequence()
- ->push(['attempt' => 1], 500)
- ->push(['attempt' => 2], 500)
- ->push(['attempt' => 3])
- ->push(['attempt' => 4])
- ->push(['attempt' => 5])
- ->push(['attempt' => 6])]);
- $manager = $this->manager($http);
- $connector = new CacheConnectorStub;
-
- $this->assertSame(1, $manager->send($connector, new CacheRequestStub)->json('attempt'));
- $this->assertSame(2, $manager->send($connector, new CacheRequestStub)->json('attempt'));
- $this->assertSame(3, $manager->send($connector, (new CacheRequestStub)->disableCaching())->json('attempt'));
- $this->assertSame(4, $manager->send($connector, (new CacheRequestStub)->disableCaching())->json('attempt'));
- $this->assertSame(5, $manager->send($connector, new CachePostRequestStub)->json('attempt'));
- $this->assertSame(6, $manager->send($connector, new CachePostRequestStub)->json('attempt'));
- $http->assertSentCount(6);
- }
-
- public function testRequestCacheSettingsOverrideConnectorSettings(): void
- {
- $defaultRepository = new Repository(new ArrayStore);
- $requestRepository = new Repository(new ArrayStore);
- $cache = m::mock(CacheFactory::class);
- $cache->shouldReceive('store')->with('request-store')->andReturn($requestRepository);
- $cache->shouldReceive('store')->with('connector-store')->andReturn($defaultRepository);
- $http = $this->http();
- $http->fake(['*' => Factory::response(['ok' => true])]);
- $manager = $this->manager($http, $cache);
- $connector = new CacheableConnectorStub;
-
- $first = $manager->send($connector, new CacheStoreRequestStub);
- $second = $manager->send($connector, new CacheStoreRequestStub);
-
- $this->assertFalse($first->isCached());
- $this->assertTrue($second->isCached());
- $http->assertSentCount(1);
- }
-
- public function testCacheControlsRequireACacheableRequestOrConnector(): void
- {
- $this->expectException(CachingException::class);
-
- $this->manager($this->http())->send(new CacheConnectorStub, new CacheControlsOnlyRequestStub);
- }
-
- public function testDefaultKeyCanonicalizesMapsAndSeparatesResponseIdentity(): void
- {
- $key = new CacheKey;
- $connector = new CacheConnectorStub;
- $first = $this->pending($connector, (new CacheRequestStub)
- ->withHeader('X-Order', ['a', 'b']));
- $same = $this->pending($connector, (new CacheRequestStub)
- ->withHeader('x-order', ['a', 'b']));
- $different = $this->pending($connector, (new CacheRequestStub)
- ->withHeader('X-Order', ['b', 'a']));
-
- $firstKey = $key->make($first, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]);
-
- $this->assertSame($firstKey, $key->make($same, ['curl' => [1 => 'a', 2 => 'b'], 'verify' => true]));
- $this->assertNotSame($firstKey, $key->make($different, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]));
- $this->assertNotSame($firstKey, $key->make($first, ['verify' => false, 'curl' => [2 => 'b', 1 => 'a']]));
- $this->assertMatchesRegularExpression('/^saloon:[a-f0-9]{64}$/', $firstKey);
-
- $firstCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([
- 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/',
- ])));
- $differentCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([
- 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/admin',
- ])));
-
- $this->assertNotSame($key->make($firstCookie, []), $key->make($differentCookie, []));
- }
-
- public function testCustomKeysRemainBoundedAndCacheScopesStayDistinct(): void
- {
- $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub);
- $key = new CacheKey;
- $custom = str_repeat('secret-account-', 100);
-
- $tenantA = $key->make($pendingRequest, [], $custom, 'tenant-a');
- $tenantB = $key->make($pendingRequest, [], $custom, 'tenant-b');
-
- $this->assertNotSame($tenantA, $tenantB);
- $this->assertSame(71, strlen($tenantA));
- $this->assertStringNotContainsString('secret-account', $tenantA);
- $this->assertStringNotContainsString('tenant-a', $tenantA);
- }
-
- public function testDefaultBodyIdentityRestoresSeekableStreamsAndRejectsUnsafeInputs(): void
- {
- $request = new CacheStreamRequestStub(Utils::streamFor('payload'));
- $pendingRequest = $this->pending(new CacheConnectorStub, $request);
- $pendingRequest->preparedBody()?->seek(2);
-
- (new CacheKey)->make($pendingRequest, []);
-
- $this->assertSame(2, $pendingRequest->preparedBody()?->tell());
-
- $nonSeekable = m::mock(StreamInterface::class);
- $nonSeekable->shouldReceive('isSeekable')->andReturn(false);
- $unsafe = $this->pending(new CacheConnectorStub, new CacheStreamRequestStub($nonSeekable));
-
- try {
- (new CacheKey)->make($unsafe, []);
- $this->fail('A non-seekable default cache body was accepted.');
- } catch (BodyException) {
- $this->addToAssertionCount(1);
- }
-
- $this->assertMatchesRegularExpression(
- '/^saloon:[a-f0-9]{64}$/',
- (new CacheKey)->make($unsafe, [], 'custom-key'),
- );
- }
-
- public function testSinkAndUnrepresentableIdentityOptionsAreRejected(): void
- {
- $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub);
-
- foreach ([['sink' => '/tmp/result'], ['curl' => [1 => new stdClass]]] as $options) {
- try {
- (new CacheKey)->make($pendingRequest, $options);
- $this->fail('An unsafe cache identity option was accepted.');
- } catch (CachingException) {
- $this->addToAssertionCount(1);
- }
- }
-
- try {
- (new CacheKey)->make($pendingRequest, ['sink' => '/tmp/result'], 'custom-key');
- $this->fail('A cached sink was accepted with a custom cache key.');
- } catch (CachingException) {
- $this->addToAssertionCount(1);
- }
- }
-
- /**
- * Create an isolated HTTP factory.
- */
- protected function http(): Factory
- {
- $http = new Factory;
- $http->registerConnection('saloon');
-
- return $http;
- }
-
- /**
- * Create a Saloon manager with an array cache store.
- */
- protected function manager(
- Factory $http,
- ?CacheFactory $cache = null,
- ?Dispatcher $events = null,
- ?Sender $sender = null,
- ): SaloonManager {
- $cache ??= m::mock(CacheFactory::class);
-
- $repository = new Repository(new ArrayStore);
- $cache->shouldReceive('store')->with(null)->andReturn($repository)->byDefault();
- $config = m::mock(ConfigRepository::class);
- $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
- $config->shouldReceive('get')->with('saloon.cache.store')->andReturn(null);
-
- return new SaloonManager(
- $sender ?? new Sender($http, $config),
- $cache,
- m::mock(RateLimiter::class),
- $config,
- $events ?? new Dispatcher,
- );
- }
-
- /**
- * Create a finalized pending request for cache-key tests.
- */
- protected function pending(Connector $connector, Request $request): PendingRequest
- {
- return (new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- ))->applyAuthentication()->finalizeUri()->prepareBody();
- }
-}
-
-class CacheConnectorStub extends Connector
-{
- public function resolveBaseUrl(): string
- {
- return 'https://api.example.com';
- }
-}
-
-class CacheableConnectorStub extends CacheConnectorStub implements Cacheable
-{
- public function cacheFor(): int
- {
- return 60;
- }
-
- public function cacheStore(): string
- {
- return 'connector-store';
- }
-}
-
-class CacheRequestStub extends Request implements Cacheable
-{
- use HasCaching;
-
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-
- public function cacheFor(): DateInterval|DateTimeInterface|int
- {
- return 60;
- }
-}
-
-class CacheStoreRequestStub extends CacheRequestStub
-{
- public function cacheStore(): UnitEnum|string|null
- {
- return 'request-store';
- }
-}
-
-class CachePostRequestStub extends CacheRequestStub
-{
- protected Method $method = Method::POST;
-}
-
-class CacheControlsOnlyRequestStub extends Request
-{
- use HasCaching;
-
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return '/users';
- }
-}
-
-class CacheStreamRequestStub extends CacheRequestStub
-{
- public function __construct(protected StreamInterface $stream)
- {
- $this->withBody($stream);
- }
-}
-
-class CountingCacheSender extends Sender
-{
- public int $transportResolutions = 0;
-
- public function resolveTransport(PendingRequest $pendingRequest): array
- {
- ++$this->transportResolutions;
-
- return parent::resolveTransport($pendingRequest);
- }
-}
diff --git a/tests/Saloon/Cache/Feature/CacheTest.php b/tests/Saloon/Cache/Feature/CacheTest.php
new file mode 100644
index 0000000000..bd3598f127
--- /dev/null
+++ b/tests/Saloon/Cache/Feature/CacheTest.php
@@ -0,0 +1,930 @@
+make('config')->set('cache.stores.custom', ['driver' => 'array']);
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Http::preventStrayRequests();
+ }
+
+ // REMOVED: "a request with the HasCaching trait will cache the response with a real request" - moved to
+ // Integration/Saloon/Cache/Feature/CacheTest, which sends it to the engine test server.
+
+ public function testARequestWithTheHasCachingTraitWillCacheTheResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 201, ['X-Howdy' => 'Yeehaw']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertTrue($responseA->isMocked());
+ $this->assertSame(201, $responseA->status());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame('Yeehaw', $responseA->header('X-Howdy'));
+
+ // Now send a response without the mock middleware, and it should be cached!
+ // We'll also pass the mock client in here to ensure it's being ignored
+
+ $responseB = TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $this->assertTrue($responseB->isFaked());
+ $this->assertTrue($responseB->isCached());
+ $this->assertFalse($responseB->isMocked());
+ $this->assertSame(201, $responseB->status());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ $this->assertSame('Yeehaw', $responseB->header('X-Howdy'));
+ $this->assertCount(1, Cache::store()->getStore()->all());
+ }
+
+ public function testARequestWithTheHasCachingTraitWillCacheTheResponseWithStringBody(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make('Hi
', 201, ['X-Howdy' => 'Yeehaw']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(201, $responseA->status());
+ $this->assertSame('Hi
', $responseA->body());
+ $this->assertSame('Yeehaw', $responseA->header('X-Howdy'));
+
+ // Now send a response without the mock middleware, and it should be cached!
+
+ $responseB = TestConnector::make()->send(new CachedUserRequest);
+
+ $this->assertTrue($responseB->isFaked());
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(201, $responseB->status());
+ $this->assertSame('Hi
', $responseB->body());
+ $this->assertSame('Yeehaw', $responseB->header('X-Howdy'));
+ }
+
+ // Upstream caches only GET and OPTIONS by default; HEAD and QUERY are also cacheable here.
+ public function testItWontCacheOnAnythingOtherThanGetHeadOptionsAndQuery(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Gareth']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new CachedPostRequest, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $responseB = TestConnector::make()->send(new CachedPostRequest, $mockClient);
+
+ $this->assertFalse($responseB->isCached());
+ $this->assertSame(['name' => 'Gareth'], $responseB->json());
+ }
+
+ public function testItWillCachePostRequestsIfYouCustomiseTheCacheableMethodsMethod(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $responseA = TestConnector::make()->send(new AllowedCachedPostRequest, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $responseB = TestConnector::make()->send(new AllowedCachedPostRequest, $mockClient);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ }
+
+ // Upstream's "a response will not be cached if the response was not 2xx". Redirects are cached there too, so this is
+ // named for the statuses it covers.
+ public function testAResponseWillNotBeCachedIfTheResponseWasAClientOrServerError(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam'], 422),
+ MockResponse::make(['name' => 'Gareth'], 500),
+ ]);
+
+ $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame(422, $responseA->status());
+
+ $responseB = TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $this->assertFalse($responseB->isCached());
+ $this->assertSame(['name' => 'Gareth'], $responseB->json());
+ $this->assertSame(500, $responseB->status());
+ }
+
+ public function testARedirectResponseIsCached(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make('', 302, ['Location' => 'https://example.com/moved']),
+ ]);
+
+ TestConnector::make()->send(new CachedUserRequest, $mockClient);
+
+ $response = TestConnector::make()->send(new CachedUserRequest);
+
+ $this->assertTrue($response->isCached());
+ $this->assertSame(302, $response->status());
+ $this->assertSame('https://example.com/moved', $response->header('Location'));
+ }
+
+ // Upstream asserts the hashed file name. Cache keys are hashed with the cache scope here, so this checks that the
+ // custom key replaces the request identity: a different query string still reaches the cached response.
+ public function testACustomCacheKeyCanBeProvidedOnTheResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ TestConnector::make()->send(new CustomKeyCachedUserRequest, $mockClient);
+
+ $response = TestConnector::make()->send((new CustomKeyCachedUserRequest)->withQueryParameters(['page' => 2]));
+
+ $this->assertTrue($response->isCached());
+ $this->assertSame(['name' => 'Sam'], $response->json());
+ }
+
+ public function testQueryParametersAreUsedInTheCacheKey(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $requestA = (new CachedUserRequest)->withQueryParameters(['name' => 'Sam']);
+ $responseA = TestConnector::make()->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $requestB = (new CachedUserRequest)->withQueryParameters(['name' => 'Sam']);
+ $responseB = TestConnector::make()->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+
+ $requestC = new CachedUserRequest;
+ $responseC = TestConnector::make()->send($requestC, $mockClient);
+
+ $this->assertFalse($responseC->isCached());
+ }
+
+ public function testBodyCanBeUsedInTheCacheKey(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Gareth']),
+ ]);
+
+ $requestA = (new BodyCacheKeyRequest)->withData([
+ 'name' => 'Sam',
+ 'expiry' => '10 hours',
+ ]);
+
+ $responseA = TestConnector::make()->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+ $this->assertSame(200, $responseA->status());
+
+ $requestB = (new BodyCacheKeyRequest)->withData([
+ 'name' => 'Sam',
+ 'expiry' => '10 hours',
+ ]);
+
+ $responseB = TestConnector::make()->send($requestB, $mockClient);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ $this->assertSame(200, $responseB->status());
+ }
+
+ public function testYouWillNotReceiveACachedResponseIfTheResponseHasExpired(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Michael']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $requestA = new ShortLivedCachedUserRequest;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new ShortLivedCachedUserRequest;
+ $responseB = $connector->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+
+ $this->travel(3)->seconds();
+
+ $requestC = new ShortLivedCachedUserRequest;
+ $responseC = $connector->send($requestC, $mockClient);
+
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Michael'], $responseC->json());
+ }
+
+ public function testYouCanDefineACacheOnTheConnectorAndItReturnsACachedResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new CachedConnector;
+
+ $requestA = new CachedConnectorRequest;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new CachedConnectorRequest;
+ $responseB = $connector->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ }
+
+ public function testIfARequestHasCacheConfigurationThenItWillTakePriorityOverTheConnectors(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new CachedConnector;
+
+ $requestA = new CachedUserRequestOnCachedConnector;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new CachedUserRequestOnCachedConnector;
+ $responseB = $connector->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+
+ $this->assertCount(1, Cache::store('custom')->getStore()->all());
+ $this->assertCount(0, Cache::store()->getStore()->all());
+ }
+
+ public function testYouCanDisableTheCache(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Michael']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $requestA = new CachedUserRequest;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new CachedUserRequest;
+ $responseB = $connector->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+
+ $requestC = new CachedUserRequest;
+ $requestC->disableCaching();
+
+ $responseC = $connector->send($requestC, $mockClient);
+
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Michael'], $responseC->json());
+ }
+
+ public function testCacheCanBeInvalidated(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ MockResponse::make(['name' => 'Teo']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $requestA = new CachedUserRequest;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new CachedUserRequest;
+ $responseB = $connector->send($requestB);
+
+ // The response should now be cached...
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+
+ $requestC = new CachedUserRequest;
+ $requestC->invalidateCache();
+ $responseC = $connector->send($requestC, $mockClient);
+
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Teo'], $responseC->json());
+
+ // Now just make sure that the new response is cached...
+
+ $requestD = new CachedUserRequest;
+ $responseD = $connector->send($requestD);
+
+ $this->assertTrue($responseD->isCached());
+ $this->assertSame(['name' => 'Teo'], $responseD->json());
+ }
+
+ public function testItThrowsAnExceptionIfYouUseTheHasCachingTraitWithoutTheCacheableInterface(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+ $request = new CachedUserRequestWithoutCacheable;
+
+ $this->expectException(CachingException::class);
+ $this->expectExceptionMessage('The request or connector must implement [Hypervel\Saloon\Cache\Contracts\Cacheable] when using request cache controls.');
+
+ $connector->send($request, $mockClient);
+ }
+
+ public function testAResponseRejectedByAFailureHookIsNotCachedForRetries(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['status' => 'pending']),
+ MockResponse::make(['status' => 'complete']),
+ ]);
+
+ $response = TestConnector::make()->send((new PendingStatusCachedRequest)->retry(2), $mockClient);
+
+ $this->assertFalse($response->isCached());
+ $this->assertSame(['status' => 'complete'], $response->json());
+ $this->assertTrue(TestConnector::make()->send(new PendingStatusCachedRequest)->isCached());
+ }
+
+ public function testAFakeResponseFromRequestMiddlewareBypassesTheCache(): void
+ {
+ $connector = new TestConnector;
+ $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+
+ $request = new CachedUserRequest;
+ $request->middleware()->onRequest(fn (): MockResponse => MockResponse::make(['name' => 'Middleware']));
+
+ $response = $connector->send($request);
+
+ $this->assertFalse($response->isCached());
+ $this->assertSame(['name' => 'Middleware'], $response->json());
+ $this->assertSame(['name' => 'Sam'], $connector->send(new CachedUserRequest)->json());
+ }
+
+ public function testAMockClientWithoutCacheBypassesReadsWritesAndInvalidation(): void
+ {
+ $connector = new TestConnector;
+ $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+ $mockClient = (new MockClient([
+ MockResponse::make(['name' => 'Alex']),
+ MockResponse::make(['name' => 'Taylor']),
+ ]))->withoutCache();
+
+ $uncached = $connector->send(new CachedUserRequest, $mockClient);
+ $notInvalidated = $connector->send((new CachedUserRequest)->invalidateCache(), $mockClient);
+ $cached = $connector->send(new CachedUserRequest);
+
+ $this->assertFalse($uncached->isCached());
+ $this->assertSame(['name' => 'Alex'], $uncached->json());
+ $this->assertSame(['name' => 'Taylor'], $notInvalidated->json());
+ $this->assertTrue($cached->isCached());
+ $this->assertSame(['name' => 'Sam'], $cached->json());
+ }
+
+ public function testCacheHitsAreNotRecordedAsSentRequests(): void
+ {
+ $mockClient = new MockClient([MockResponse::make(['name' => 'Sam'])]);
+ $connector = new TestConnector;
+
+ $mocked = $connector->send(new CachedUserRequest, $mockClient);
+ $cached = $connector->send(new CachedUserRequest, $mockClient);
+
+ $this->assertTrue($cached->isCached());
+ $mockClient->assertSentCount(1);
+ $this->assertSame([$mocked], $mockClient->recorded()->all());
+ }
+
+ public function testSuccessfulNetworkResponsesAreCachedWithoutReplayingTransport(): void
+ {
+ $http = $this->http();
+ $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]);
+ $manager = $this->manager($http);
+ $connector = new CacheConnectorStub;
+
+ $first = $manager->send($connector, new CacheRequestStub);
+ $second = $manager->send($connector, new CacheRequestStub);
+ $refreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache());
+
+ $this->assertFalse($first->isCached());
+ $this->assertSame(['version' => 1], $first->json());
+ $this->assertTrue($second->isCached());
+ $this->assertTrue($second->isFaked());
+ $this->assertFalse($second->isMocked());
+ $this->assertSame(['version' => 1], $second->json());
+ $this->assertFalse($refreshed->isCached());
+ $this->assertSame(['version' => 2], $refreshed->json());
+ $http->assertSentCount(2);
+ }
+
+ public function testSendingAndSentEventsArePairedForCacheHits(): void
+ {
+ $http = $this->http();
+ $http->fake(['*' => Factory::response(['cached' => true])]);
+ $events = new Dispatcher;
+ $dispatched = [];
+ $events->listen(SendingSaloonRequest::class, function () use (&$dispatched): void {
+ $dispatched[] = 'sending';
+ });
+ $events->listen(SentSaloonRequest::class, function () use (&$dispatched): void {
+ $dispatched[] = 'sent';
+ });
+ $manager = $this->manager($http, events: $events);
+
+ $manager->send(new CacheConnectorStub, new CacheRequestStub);
+ $cached = $manager->send(new CacheConnectorStub, new CacheRequestStub);
+
+ $this->assertTrue($cached->isCached());
+ $this->assertSame(['sending', 'sent', 'sending', 'sent'], $dispatched);
+ $http->assertSentCount(1);
+ }
+
+ public function testSendingListenerMutationsAreIncludedInTheCacheIdentity(): void
+ {
+ $http = $this->http();
+ $requests = [];
+ $http->fake(function (HttpRequest $request) use (&$requests): PromiseInterface {
+ $requests[] = [$request->url(), $request->header('X-Variant'), $request->body()];
+
+ return Factory::response(['version' => count($requests)]);
+ });
+ $events = new Dispatcher;
+ $variant = 'a';
+ $events->listen(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use (&$variant): void {
+ $event->pendingRequest
+ ->withHeader('X-Variant', $variant)
+ ->withQueryParameters(['variant' => $variant])
+ ->withData(['variant' => $variant]);
+ });
+ $manager = $this->manager($http, events: $events);
+ $connector = new CacheConnectorStub;
+
+ $first = $manager->send($connector, new CacheRequestStub);
+ $variant = 'b';
+ $second = $manager->send($connector, new CacheRequestStub);
+ $variant = 'a';
+ $cached = $manager->send($connector, new CacheRequestStub);
+
+ $this->assertSame(1, $first->json('version'));
+ $this->assertSame(2, $second->json('version'));
+ $this->assertSame(1, $cached->json('version'));
+ $this->assertTrue($cached->isCached());
+ $this->assertSame([
+ ['https://api.example.com/users?variant=a', ['a'], '{"variant":"a"}'],
+ ['https://api.example.com/users?variant=b', ['b'], '{"variant":"b"}'],
+ ], $requests);
+ $http->assertSentCount(2);
+ }
+
+ public function testCacheMissResolvesTransportConfigurationOnce(): void
+ {
+ $http = new Factory;
+ $http->registerConnection('saloon');
+ $http->fake(['*' => Factory::response(['ok' => true])]);
+ $senderConfig = m::mock(ConfigRepository::class);
+ $senderConfig->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
+ $sender = new CountingCacheSender($http, $senderConfig);
+
+ $this->manager($http, sender: $sender)->send(new CacheConnectorStub, new CacheRequestStub);
+
+ $this->assertSame(1, $sender->transportResolutions);
+ }
+
+ public function testCacheScopeAppliesToReadsWritesAndInvalidation(): void
+ {
+ $http = $this->http();
+ $http->fake(['*' => $http->sequence()
+ ->push(['version' => 'tenant-a-1'])
+ ->push(['version' => 'tenant-b-1'])
+ ->push(['version' => 'tenant-a-2'])]);
+ $manager = $this->manager($http);
+ $scope = 'tenant-a';
+ $manager->resolveCacheScopeUsing(static function () use (&$scope): string {
+ return $scope;
+ });
+ $connector = new CacheConnectorStub;
+
+ $tenantA = $manager->send($connector, new CacheRequestStub);
+ $tenantACached = $manager->send($connector, new CacheRequestStub);
+ $scope = 'tenant-b';
+ $tenantB = $manager->send($connector, new CacheRequestStub);
+ $tenantBCached = $manager->send($connector, new CacheRequestStub);
+ $scope = 'tenant-a';
+ $tenantARefreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache());
+ $scope = 'tenant-b';
+ $tenantBStillCached = $manager->send($connector, new CacheRequestStub);
+
+ $this->assertSame('tenant-a-1', $tenantA->json('version'));
+ $this->assertTrue($tenantACached->isCached());
+ $this->assertSame('tenant-b-1', $tenantB->json('version'));
+ $this->assertTrue($tenantBCached->isCached());
+ $this->assertSame('tenant-a-2', $tenantARefreshed->json('version'));
+ $this->assertFalse($tenantARefreshed->isCached());
+ $this->assertSame('tenant-b-1', $tenantBStillCached->json('version'));
+ $this->assertTrue($tenantBStillCached->isCached());
+ $http->assertSentCount(3);
+ }
+
+ public function testCacheScopeResolverIsSkippedWhenCachingIsDisabledAndNullAllowsSharing(): void
+ {
+ $http = $this->http();
+ $http->fake(['*' => $http->sequence()
+ ->push(['version' => 1])
+ ->push(['version' => 2])
+ ->push(['version' => 3])]);
+ $manager = $this->manager($http);
+ $scope = null;
+ $resolutions = 0;
+ $manager->resolveCacheScopeUsing(static function () use (&$scope, &$resolutions): ?string {
+ ++$resolutions;
+
+ return $scope;
+ });
+ $connector = new CacheConnectorStub;
+
+ $first = $manager->send($connector, new CacheRequestStub);
+ $shared = $manager->send($connector, new CacheRequestStub);
+ $manager->send($connector, (new CacheRequestStub)->disableCaching());
+ $manager->send($connector, (new CacheRequestStub)->disableCaching());
+
+ $this->assertSame(1, $first->json('version'));
+ $this->assertSame(1, $shared->json('version'));
+ $this->assertTrue($shared->isCached());
+ $this->assertSame(2, $resolutions);
+ $http->assertSentCount(3);
+ }
+
+ public function testRequestTypeCanOptOutOfConnectorCachingWithoutCacheControls(): void
+ {
+ $http = $this->http();
+ $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]);
+ $manager = $this->manager($http);
+ $connector = new CacheableConnectorStub;
+ $request = new class extends Request {
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the uncached endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/live';
+ }
+
+ /**
+ * Keep this request type out of connector caching.
+ */
+ public function cachingEnabled(): bool
+ {
+ return false;
+ }
+ };
+
+ $first = $manager->send($connector, $request);
+ $second = $manager->send($connector, $request);
+
+ $this->assertSame(1, $first->json('version'));
+ $this->assertSame(2, $second->json('version'));
+ $this->assertFalse($first->isCached());
+ $this->assertFalse($second->isCached());
+ $http->assertSentCount(2);
+ }
+
+ public function testDefaultKeyCanonicalizesMapsAndSeparatesResponseIdentity(): void
+ {
+ $key = new CacheKey;
+ $connector = new CacheConnectorStub;
+ $first = $this->pending($connector, (new CacheRequestStub)
+ ->withHeader('X-Order', ['a', 'b']));
+ $same = $this->pending($connector, (new CacheRequestStub)
+ ->withHeader('x-order', ['a', 'b']));
+ $different = $this->pending($connector, (new CacheRequestStub)
+ ->withHeader('X-Order', ['b', 'a']));
+
+ $firstKey = $key->make($first, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]);
+
+ $this->assertSame($firstKey, $key->make($same, ['curl' => [1 => 'a', 2 => 'b'], 'verify' => true]));
+ $this->assertNotSame($firstKey, $key->make($different, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]));
+ $this->assertNotSame($firstKey, $key->make($first, ['verify' => false, 'curl' => [2 => 'b', 1 => 'a']]));
+ $this->assertMatchesRegularExpression('/^saloon:[a-f0-9]{64}$/', $firstKey);
+
+ $firstCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([
+ 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/',
+ ])));
+ $differentCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([
+ 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/admin',
+ ])));
+
+ $this->assertNotSame($key->make($firstCookie, []), $key->make($differentCookie, []));
+ }
+
+ public function testCustomKeysRemainBoundedAndCacheScopesStayDistinct(): void
+ {
+ $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub);
+ $key = new CacheKey;
+ $custom = str_repeat('secret-account-', 100);
+
+ $tenantA = $key->make($pendingRequest, [], $custom, 'tenant-a');
+ $tenantB = $key->make($pendingRequest, [], $custom, 'tenant-b');
+
+ $this->assertNotSame($tenantA, $tenantB);
+ $this->assertSame(71, strlen($tenantA));
+ $this->assertStringNotContainsString('secret-account', $tenantA);
+ $this->assertStringNotContainsString('tenant-a', $tenantA);
+ }
+
+ public function testDefaultBodyIdentityRestoresSeekableStreamsAndRejectsUnsafeInputs(): void
+ {
+ $request = new CacheStreamRequestStub(Utils::streamFor('payload'));
+ $pendingRequest = $this->pending(new CacheConnectorStub, $request);
+ $pendingRequest->preparedBody()?->seek(2);
+
+ (new CacheKey)->make($pendingRequest, []);
+
+ $this->assertSame(2, $pendingRequest->preparedBody()?->tell());
+
+ $nonSeekable = m::mock(StreamInterface::class);
+ $nonSeekable->shouldReceive('isSeekable')->andReturn(false);
+ $unsafe = $this->pending(new CacheConnectorStub, new CacheStreamRequestStub($nonSeekable));
+
+ try {
+ (new CacheKey)->make($unsafe, []);
+ $this->fail('A non-seekable default cache body was accepted.');
+ } catch (BodyException) {
+ $this->addToAssertionCount(1);
+ }
+
+ $this->assertMatchesRegularExpression(
+ '/^saloon:[a-f0-9]{64}$/',
+ (new CacheKey)->make($unsafe, [], 'custom-key'),
+ );
+ }
+
+ public function testSinkAndUnrepresentableIdentityOptionsAreRejected(): void
+ {
+ $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub);
+
+ foreach ([['sink' => '/tmp/result'], ['curl' => [1 => new stdClass]]] as $options) {
+ try {
+ (new CacheKey)->make($pendingRequest, $options);
+ $this->fail('An unsafe cache identity option was accepted.');
+ } catch (CachingException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+
+ try {
+ (new CacheKey)->make($pendingRequest, ['sink' => '/tmp/result'], 'custom-key');
+ $this->fail('A cached sink was accepted with a custom cache key.');
+ } catch (CachingException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+
+ /**
+ * Create an isolated HTTP factory.
+ */
+ protected function http(): Factory
+ {
+ $http = new Factory;
+ $http->registerConnection('saloon');
+
+ return $http;
+ }
+
+ /**
+ * Create a Saloon manager with an array cache store.
+ */
+ protected function manager(Factory $http, ?Dispatcher $events = null, ?Sender $sender = null): SaloonManager
+ {
+ $cache = m::mock(CacheFactory::class);
+ $cache->shouldReceive('store')->with(null)->andReturn(new Repository(new ArrayStore));
+ $config = m::mock(ConfigRepository::class);
+ $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
+ $config->shouldReceive('get')->with('saloon.cache.store')->andReturn(null);
+
+ return new SaloonManager(
+ $sender ?? new Sender($http, $config),
+ $cache,
+ m::mock(RateLimiter::class),
+ $config,
+ $events ?? new Dispatcher,
+ );
+ }
+
+ /**
+ * Create a finalized pending request for cache-key tests.
+ */
+ protected function pending(Connector $connector, Request $request): PendingRequest
+ {
+ return (new PendingRequest(
+ $connector,
+ $request,
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ ))->applyAuthentication()->finalizeUri()->prepareBody();
+ }
+}
+
+class PendingStatusCachedRequest extends CachedUserRequest
+{
+ /**
+ * Treat a pending status as a failed request.
+ */
+ public function hasRequestFailed(Response $response): ?bool
+ {
+ return $response->json('status') === 'pending' ? true : null;
+ }
+}
+
+class CacheConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+}
+
+class CacheableConnectorStub extends CacheConnectorStub implements Cacheable
+{
+ /**
+ * Get the cache duration.
+ */
+ public function cacheFor(): int
+ {
+ return 60;
+ }
+
+ /**
+ * Get the cache store name.
+ */
+ public function cacheStore(): string
+ {
+ return 'connector-store';
+ }
+}
+
+class CacheRequestStub extends Request implements Cacheable
+{
+ use HasCaching;
+
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+
+ /**
+ * Get the cache duration.
+ */
+ public function cacheFor(): DateInterval|DateTimeInterface|int
+ {
+ return 60;
+ }
+}
+
+class CacheStreamRequestStub extends CacheRequestStub
+{
+ /**
+ * Create a request with a stream body.
+ */
+ public function __construct(protected StreamInterface $stream)
+ {
+ $this->withBody($stream);
+ }
+}
+
+class CountingCacheSender extends Sender
+{
+ public int $transportResolutions = 0;
+
+ /**
+ * Resolve the transport and count the resolution.
+ */
+ public function resolveTransport(PendingRequest $pendingRequest): array
+ {
+ ++$this->transportResolutions;
+
+ return parent::resolveTransport($pendingRequest);
+ }
+}
diff --git a/tests/Saloon/Cache/Feature/ClearCacheTest.php b/tests/Saloon/Cache/Feature/ClearCacheTest.php
new file mode 100644
index 0000000000..f13486592e
--- /dev/null
+++ b/tests/Saloon/Cache/Feature/ClearCacheTest.php
@@ -0,0 +1,218 @@
+ 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+ $request = new CachedUserRequest;
+
+ // Send and cache the response
+ $responseA = $connector->send($request, $mockClient);
+ $this->assertFalse($responseA->isCached());
+
+ // Verify it is cached
+ $responseB = $connector->send(new CachedUserRequest);
+ $this->assertTrue($responseB->isCached());
+
+ // Delete the cache without sending a request
+ $sending = 0;
+ Event::listen(SendingSaloonRequest::class, function () use (&$sending): void {
+ ++$sending;
+ });
+
+ $request = new CachedUserRequest;
+ $request->clearCache($connector);
+
+ $this->assertSame(0, $sending);
+ Http::assertNothingSent();
+
+ // Now sending should result in a cache miss
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Michael']),
+ ]);
+
+ $responseC = $connector->send(new CachedUserRequest, $mockClient);
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Michael'], $responseC->json());
+ }
+
+ public function testClearCacheOnAnUncachedRequestDoesNotThrow(): void
+ {
+ $connector = new TestConnector;
+ $request = new CachedUserRequest;
+
+ $request->clearCache($connector);
+
+ $this->assertCount(0, Cache::store()->getStore()->all());
+ }
+
+ public function testClearCacheUsesACustomCacheKeyOverride(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+
+ // Send and cache with the custom key
+ $connector->send(new CustomKeyCachedUserRequest, $mockClient);
+
+ $this->assertCount(1, Cache::store()->getStore()->all());
+
+ // Delete using the custom key, which ignores the request's query string
+ $request = (new CustomKeyCachedUserRequest)->withQueryParameters(['page' => 2]);
+ $request->clearCache($connector);
+
+ $this->assertCount(0, Cache::store()->getStore()->all());
+ }
+
+ public function testAfterClearCacheTheNextSendFetchesFreshAndRepopulatesCache(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+
+ // Send and cache
+ $connector->send(new CachedUserRequest, $mockClient);
+
+ // Confirm cached
+ $responseB = $connector->send(new CachedUserRequest);
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+
+ // Delete cache
+ $request = new CachedUserRequest;
+ $request->clearCache($connector);
+
+ // Send again - should be a fresh response
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Teo']),
+ ]);
+
+ $responseC = $connector->send(new CachedUserRequest, $mockClient);
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Teo'], $responseC->json());
+
+ // Verify the new response is cached
+ $responseD = $connector->send(new CachedUserRequest);
+ $this->assertTrue($responseD->isCached());
+ $this->assertSame(['name' => 'Teo'], $responseD->json());
+ }
+
+ public function testClearCacheWorksWhenCalledFromTheConnector(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new CachedConnector;
+
+ // Send and cache
+ $connector->send(new CachedConnectorRequest, $mockClient);
+
+ // Confirm cached
+ $responseB = $connector->send(new CachedConnectorRequest);
+ $this->assertTrue($responseB->isCached());
+
+ // Delete cache from the connector side
+ $connector->clearCache(new CachedConnectorRequest);
+
+ // Should be a cache miss now
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Michael']),
+ ]);
+
+ $responseC = $connector->send(new CachedConnectorRequest, $mockClient);
+ $this->assertFalse($responseC->isCached());
+ $this->assertSame(['name' => 'Michael'], $responseC->json());
+ }
+
+ // REMOVED: "clearCache throws when called on a connector with another connector" and "clearCache throws when
+ // called on a request with another request" - the request and connector methods type their counterparts natively.
+
+ public function testClearCacheOnlyClearsTheCurrentCacheScope(): void
+ {
+ $scope = 'tenant-a';
+ Saloon::resolveCacheScopeUsing(static function () use (&$scope): string {
+ return $scope;
+ });
+ $mockClient = new MockClient([
+ MockResponse::make(['tenant' => 'a']),
+ MockResponse::make(['tenant' => 'b']),
+ MockResponse::make(['tenant' => 'a-fresh']),
+ ]);
+ $connector = new TestConnector;
+
+ $connector->send(new CachedUserRequest, $mockClient);
+ $scope = 'tenant-b';
+ $connector->send(new CachedUserRequest, $mockClient);
+ $scope = 'tenant-a';
+ (new CachedUserRequest)->clearCache($connector);
+
+ $tenantA = $connector->send(new CachedUserRequest, $mockClient);
+ $scope = 'tenant-b';
+ $tenantB = $connector->send(new CachedUserRequest);
+
+ $this->assertFalse($tenantA->isCached());
+ $this->assertSame(['tenant' => 'a-fresh'], $tenantA->json());
+ $this->assertTrue($tenantB->isCached());
+ $this->assertSame(['tenant' => 'b'], $tenantB->json());
+ }
+
+ public function testClearCacheIgnoresDisabledCaching(): void
+ {
+ $connector = new TestConnector;
+ $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])]));
+
+ (new CachedUserRequest)->disableCaching()->clearCache($connector);
+
+ $this->assertCount(0, Cache::store()->getStore()->all());
+ }
+}
diff --git a/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php b/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php
new file mode 100644
index 0000000000..615688f56f
--- /dev/null
+++ b/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php
@@ -0,0 +1,92 @@
+make('config')->set('cache.stores.file', [
+ 'driver' => 'file',
+ 'path' => $this->cachePath(),
+ 'lock_path' => $this->cachePath(),
+ ]);
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ (new Filesystem)->deleteDirectory($this->cachePath());
+
+ Http::preventStrayRequests();
+ }
+
+ /**
+ * Tear down the test environment.
+ */
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->cachePath());
+
+ parent::tearDown();
+ }
+
+ public function testItWillReturnACachedResponse(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['name' => 'Sam']),
+ ]);
+
+ $connector = new TestConnector;
+
+ $requestA = new HypervelCachedUserRequest;
+ $responseA = $connector->send($requestA, $mockClient);
+
+ $this->assertFalse($responseA->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseA->json());
+
+ $requestB = new HypervelCachedUserRequest;
+ $responseB = $connector->send($requestB);
+
+ $this->assertTrue($responseB->isCached());
+ $this->assertSame(['name' => 'Sam'], $responseB->json());
+ }
+
+ /**
+ * Get the temporary file cache directory.
+ */
+ protected function cachePath(): string
+ {
+ return ParallelTesting::tempDir('SaloonHypervelCacheDriverTest');
+ }
+}
diff --git a/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php b/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php
new file mode 100644
index 0000000000..7e018444bf
--- /dev/null
+++ b/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php
@@ -0,0 +1,118 @@
+files = new Filesystem;
+ $this->fixturePath = ParallelTesting::tempDir('SaloonResponseCacheMockFixtureTest');
+ $this->files->deleteDirectory($this->fixturePath);
+ $this->files->ensureDirectoryExists($this->fixturePath);
+
+ Saloon::fixturePath($this->fixturePath);
+ Http::preventStrayRequests();
+ }
+
+ /**
+ * Tear down the test environment.
+ */
+ protected function tearDown(): void
+ {
+ $this->files->deleteDirectory($this->fixturePath);
+
+ parent::tearDown();
+ }
+
+ public function testResponseCacheCanReturnAStaleBodyWhenMocksUseFixturesAndWithoutCacheIsNotUsed(): void
+ {
+ $this->writeCachedUserRequestFixture(1);
+
+ $mockClient = new MockClient([
+ CachedUserRequest::class => MockResponse::fixture('cached-user-request'),
+ ]);
+
+ $connector = TestConnector::make();
+
+ $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version'));
+
+ $this->writeCachedUserRequestFixture(2);
+
+ $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version'));
+ }
+
+ public function testWithoutCacheOnTheMockClientSkipsResponseCacheSoUpdatedFixtureFilesAreUsed(): void
+ {
+ $this->writeCachedUserRequestFixture(1);
+
+ $mockClient = (new MockClient([
+ CachedUserRequest::class => MockResponse::fixture('cached-user-request'),
+ ]))->withoutCache();
+
+ $connector = TestConnector::make();
+
+ $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version'));
+
+ $this->writeCachedUserRequestFixture(2);
+
+ $this->assertSame(2, $connector->send(new CachedUserRequest, $mockClient)->json('version'));
+ }
+
+ /**
+ * Write the cached user request fixture.
+ */
+ protected function writeCachedUserRequestFixture(int $version): void
+ {
+ $payload = [
+ 'statusCode' => 200,
+ 'headers' => ['Content-Type' => ['application/json']],
+ 'data' => json_encode(['version' => $version]),
+ 'context' => [],
+ ];
+
+ $this->files->put(
+ $this->fixturePath . '/cached-user-request.json',
+ json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
+ );
+ }
+}
diff --git a/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php b/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php
new file mode 100644
index 0000000000..516e7524d4
--- /dev/null
+++ b/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php
@@ -0,0 +1,37 @@
+
+ */
+ protected function defaultBody(): array
+ {
+ return [
+ 'name' => 'Sammy',
+ ];
+ }
+
+ /**
+ * Get the cache duration.
+ */
+ public function cacheFor(): int
+ {
+ return 60;
+ }
+
+ /**
+ * Define the cacheable HTTP methods.
+ *
+ * @return list
+ */
+ protected function cacheableMethods(): array
+ {
+ return [Method::GET, Method::OPTIONS, Method::POST];
+ }
+}
diff --git a/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php b/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php
new file mode 100644
index 0000000000..ed8019185d
--- /dev/null
+++ b/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php
@@ -0,0 +1,47 @@
+body(), JSON_THROW_ON_ERROR);
+ }
+}
diff --git a/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php b/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php
new file mode 100644
index 0000000000..1b1108d964
--- /dev/null
+++ b/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php
@@ -0,0 +1,24 @@
+
+ */
+ protected function defaultBody(): array
+ {
+ return [
+ 'name' => 'Sammy',
+ ];
+ }
+
+ /**
+ * Get the cache duration.
+ */
+ public function cacheFor(): int
+ {
+ return 60;
+ }
+}
diff --git a/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php b/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php
new file mode 100644
index 0000000000..af923aaf20
--- /dev/null
+++ b/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php
@@ -0,0 +1,36 @@
+by('cache');
$request = new CachedRateLimitedRequestStub($policy);
- $manager->fake([MockResponse::make(['fake' => true])]);
+ $manager->fake((new MockClient([MockResponse::make(['fake' => true])]))->withoutCache());
$connector->send($request);
$manager->clearFake();
@@ -132,6 +133,9 @@ public function testDeniedRequestGroupDoesNotChargeItsEarlierPolicyButKeepsTheSe
$http->fake(['*' => Factory::response(['unexpected' => true])]);
$connector = new RateLimitedConnectorStub($manager);
$request = new class extends MultipleRateLimitRequestStub {
+ /**
+ * Resolve both policies in one request group.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return ['first' => $this->firstPolicy(), 'second' => $this->secondPolicy()];
@@ -389,7 +393,7 @@ public function testWaitingUsesTheStoreDelayAndThenContinues(): void
$response = $connector->send($request);
$this->assertTrue($response->successful());
- Sleep::assertSlept(static fn ($duration): bool => (float) $duration->totalSeconds === 2.0);
+ Sleep::assertSlept(static fn (CarbonInterval $duration): bool => (float) $duration->totalSeconds === 2.0);
$http->assertSentCount(2);
}
@@ -469,15 +473,24 @@ protected function manager(): array
class PlainRateLimitConnectorStub extends Connector
{
+ /**
+ * Create a connector that sends through the given manager.
+ */
public function __construct(protected SaloonManager $manager)
{
}
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Send a request through the given manager.
+ */
public function send(Request $request, ?MockClient $mockClient = null): Response
{
return $this->manager->send($this, $request, $mockClient);
@@ -488,6 +501,9 @@ class RateLimitedConnectorStub extends PlainRateLimitConnectorStub
{
use HasRateLimits;
+ /**
+ * Create a connector with the given attempt limit.
+ */
public function __construct(
SaloonManager $manager,
protected int $maximumAttempts = 2,
@@ -495,11 +511,17 @@ public function __construct(
parent::__construct($manager);
}
+ /**
+ * Get the connector policy.
+ */
public function policy(): AdmissionPolicy
{
return Limit::perMinute($this->maximumAttempts)->by('connector');
}
+ /**
+ * Resolve the connector rate limits.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [$this->policy()];
@@ -510,6 +532,9 @@ class PlainRateLimitRequestStub extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/resource';
@@ -520,11 +545,17 @@ class RateLimitedRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Get the request policy.
+ */
public function policy(): AdmissionPolicy
{
return Limit::perMinute(2)->by('request');
}
+ /**
+ * Resolve the request rate limits.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [$this->policy()];
@@ -536,15 +567,24 @@ class CachedRateLimitedRequestStub extends PlainRateLimitRequestStub implements
use HasCaching;
use HasRateLimits;
+ /**
+ * Create a cached request with the given policy.
+ */
public function __construct(protected AdmissionPolicy $policy)
{
}
+ /**
+ * Get the cache duration.
+ */
public function cacheFor(): DateInterval|DateTimeInterface|int
{
return 60;
}
+ /**
+ * Resolve the request rate limits.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [$this->policy];
@@ -555,6 +595,9 @@ class ThrowingCooldownRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Register response middleware that throws.
+ */
public function boot(PendingRequest $pendingRequest): void
{
$pendingRequest->middleware()->onResponse(
@@ -562,6 +605,9 @@ public function boot(PendingRequest $pendingRequest): void
);
}
+ /**
+ * Resolve no rate limits.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [];
@@ -572,6 +618,9 @@ class DateCooldownRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Resolve no rate limits.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [];
@@ -640,6 +689,9 @@ class OversizedCooldownRequestStub extends DateCooldownRequestStub
class WaitingCooldownRequestStub extends DateCooldownRequestStub
{
+ /**
+ * Wait for rate limits instead of throwing.
+ */
protected function waitForRateLimits(): bool
{
return true;
@@ -650,6 +702,9 @@ class CallbackRateLimitRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Resolve a policy with an after callback.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [Limit::perMinute(1)->after(static fn (): bool => true)];
@@ -660,11 +715,17 @@ class OperationRateLimitRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Resolve a policy keyed by the operation's tenant header.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [Limit::perMinute(2)->by((string) $pendingRequest->headers()['X-Tenant'])];
}
+ /**
+ * Resolve the rate-limiter store name.
+ */
protected function resolveRateLimitStore(): string
{
return 'secondary';
@@ -675,16 +736,25 @@ class MultipleRateLimitRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
+ /**
+ * Get the first policy.
+ */
public function firstPolicy(): AdmissionPolicy
{
return Limit::perMinute(10)->by('first');
}
+ /**
+ * Get the second policy.
+ */
public function secondPolicy(): AdmissionPolicy
{
return Limit::perMinute(1)->by('second');
}
+ /**
+ * Resolve both policies.
+ */
protected function resolveRateLimits(PendingRequest $pendingRequest): array
{
return [$this->firstPolicy(), $this->secondPolicy()];
@@ -693,6 +763,9 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
class WaitingMultipleRateLimitRequestStub extends MultipleRateLimitRequestStub
{
+ /**
+ * Wait for rate limits instead of throwing.
+ */
protected function waitForRateLimits(): bool
{
return true;
diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php
index d283eab553..9d6bbfe52d 100644
--- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php
+++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php
@@ -371,6 +371,34 @@ public function testClientRequestWatcherPlainTextResponse()
$this->assertSame('plain telescope response', $entry->content['response']);
}
+ #[DataProvider('scalarResponseProvider')]
+ public function testScalarResponseIsRecordedAsSentUnderAJsonMediaType(string $contentType, string $body, string $recorded): void
+ {
+ $client = $this->makeClient([new Response(200, ['Content-Type' => $contentType], $body)]);
+
+ $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/scalar'));
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame($recorded, $entry->content['response']);
+ }
+
+ /**
+ * Provide scalar response bodies and their recorded values.
+ */
+ public static function scalarResponseProvider(): array
+ {
+ return [
+ 'zero' => ['application/json', '0', '0'],
+ 'false' => ['application/json', 'false', 'false'],
+ 'null' => ['application/json', 'null', 'null'],
+ 'string' => ['application/json', '"ok"', '"ok"'],
+ '+json with charset' => ['application/problem+json; charset=utf-8', '42', '42'],
+ 'malformed json' => ['application/json', '{"id":', 'HTML Response'],
+ 'html' => ['text/html', '0', 'HTML Response'],
+ ];
+ }
+
public function testHalJsonResponseIsDecodedAndMasked(): void
{
Telescope::hideResponseParameters(['access_token']);
@@ -878,6 +906,43 @@ public function testOversizedResponseIsPurgedByDefault()
$this->assertSame('Purged By Telescope', $entry->content['response']);
}
+ #[WithConfig('telescope.watchers', [
+ ClientRequestWatcher::class => [
+ 'enabled' => true,
+ 'response_size_limit' => 1,
+ ],
+ ])]
+ public function testOversizedScalarJsonResponseIsPurgedByDefault(): void
+ {
+ $body = '"' . str_repeat('x', 2000) . '"';
+ $client = $this->makeClient([new Response(200, ['Content-Type' => 'application/json'], $body)]);
+
+ $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/raw'));
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame('Purged By Telescope', $entry->content['response']);
+ }
+
+ #[WithConfig('telescope.watchers', [
+ ClientRequestWatcher::class => [
+ 'enabled' => true,
+ 'response_size_limit' => 1,
+ 'truncate_oversized' => true,
+ ],
+ ])]
+ public function testOversizedPlainTextResponseIsTruncated(): void
+ {
+ $body = str_repeat('x', 2000);
+ $client = $this->makeClient([new Response(200, ['Content-Type' => 'text/plain'], $body)]);
+
+ $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/raw'));
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame(substr($body, 0, 1024) . ' (truncated...)', $entry->content['response']);
+ }
+
#[WithConfig('telescope.watchers', [
ClientRequestWatcher::class => [
'enabled' => true,
diff --git a/tests/Telescope/Watchers/RequestWatchersTest.php b/tests/Telescope/Watchers/RequestWatchersTest.php
index 3ab3b21d4e..7c54471c48 100644
--- a/tests/Telescope/Watchers/RequestWatchersTest.php
+++ b/tests/Telescope/Watchers/RequestWatchersTest.php
@@ -5,7 +5,9 @@
namespace Hypervel\Tests\Telescope\Watchers;
use Closure;
+use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
+use Hypervel\Http\Response as HttpResponse;
use Hypervel\Http\UploadedFile;
use Hypervel\Log\Context\Repository as ContextRepository;
use Hypervel\Routing\Controllers\HasMiddleware;
@@ -20,6 +22,7 @@
use Hypervel\Telescope\Watchers\RequestWatcher;
use Hypervel\Testbench\Attributes\WithConfig;
use Hypervel\Tests\Telescope\FeatureTestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
#[WithConfig('telescope.watchers', [
@@ -298,6 +301,51 @@ public function testRequestWatcherPlainTextResponse()
$this->assertSame('plain telescope response', $entry->content['response']);
}
+ #[DataProvider('scalarResponseProvider')]
+ public function testRequestWatcherRecordsScalarResponseAsSentUnderAJsonMediaType(string $contentType, string $body, string $recorded): void
+ {
+ Route::get('/scalar', fn (): HttpResponse => Response::make($body, 200, ['Content-Type' => $contentType]));
+
+ $this->get('/scalar')->assertSuccessful();
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame($recorded, $entry->content['response']);
+ }
+
+ /**
+ * Provide scalar response bodies and their recorded values.
+ */
+ public static function scalarResponseProvider(): array
+ {
+ return [
+ 'zero' => ['application/json', '0', '0'],
+ 'false' => ['application/json', 'false', 'false'],
+ 'null' => ['application/json', 'null', 'null'],
+ 'string' => ['application/json', '"ok"', '"ok"'],
+ '+json with charset' => ['application/problem+json; charset=utf-8', '42', '42'],
+ 'malformed json' => ['application/json', '{"id":', 'HTML Response'],
+ 'html' => ['text/html', '0', 'HTML Response'],
+ ];
+ }
+
+ #[WithConfig('telescope.watchers', [
+ RequestWatcher::class => [
+ 'enabled' => true,
+ 'size_limit' => 1,
+ ],
+ ])]
+ public function testRequestWatcherPurgesOversizedScalarJsonResponse(): void
+ {
+ Route::get('/scalar', fn (): JsonResponse => response()->json(str_repeat('x', 2000)));
+
+ $this->get('/scalar')->assertSuccessful();
+
+ $entry = $this->loadTelescopeEntries()->first();
+
+ $this->assertSame(Telescope::PURGED_VALUE, $entry->content['response']);
+ }
+
public function testRequestWatcherRecordsPlainTextPayload()
{
Route::post('/receive-plain-text', function () {
From f361d74f4175aee283b6da0ed0566e60c717afe6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 5 Oct 2026 23:21:31 +0000
Subject: [PATCH 13/26] Reconcile Saloon pagination with the pagination plugin
Ports the pagination plugin's feature tests (saloonphp/pagination-plugin
at a61822ca93c1) into tests/Saloon/Pagination/Feature, running against
an Http::fake of the public superhero endpoints they call. #17's
rewind case is ported in CollectTest; rewind() already cleared the
body checksums.
Defect fixed: the infinite-loop check was a response middleware
closure bound to the paginator, so the paginator's own request held
the paginator through its pipeline. A dropped paginator stayed in
memory until the cycle collector ran, and any kept response kept it
alive too. The check now runs in Paginator::current() after the send
and before item mapping, keyed by page so a mapping retry replaces
its checksum, and is skipped while pooling.
Upstream's protected getBodyChecksum() is restored (xxh128 of the
body), so APIs that add a per-request value can compare page contents
only, and so is upstream's exception message, naming pages instead of
requests. The guide shows the override.
Test adaptations: async() and promise iteration are REMOVED, with the
async cases traversing through pool(); the imported offset connectors
end at getOffset() >= total and the async total pages round up, so a
partial last page ends correctly. PaginatorTest drops cases now
covered upstream and adds the dropped-paginator, checksum override and
request-middleware cases. The README records the renamed and omitted
paginator methods, pool() in place of async pagination, unpoolable
cursors and zero-based keys counted from the start page.
Validation: each changed test file, the Saloon suite (ParaTest),
composer analyse and php-cs-fixer. A scratch run at 7 per page
confirmed the corrected fixtures fetch 3 pages with all 20 items.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 9 +
src/saloon/README.md | 2 +
src/saloon/src/Pagination/Paginator.php | 50 +++--
.../Feature/AsyncPaginationTest.php | 123 +++++++++++
.../Feature/AsyncPoolPaginationTest.php | 64 ++++++
.../Saloon/Pagination/Feature/CollectTest.php | 74 +++++++
.../Feature/InfiniteLoopDetectionTest.php | 154 +++++++++++++
tests/Saloon/Pagination/Feature/ItemsTest.php | 33 +++
.../Pagination/Feature/PaginationTest.php | 203 ++++++++++++++++++
.../Pagination/Feature/PerPageLimitTest.php | 88 ++++++++
.../Pagination/Feature/StartPageTest.php | 59 +++++
.../Connectors/Async/OffsetConnector.php | 57 +++++
.../Connectors/Async/PagedConnector.php | 52 +++++
.../Fixtures/Connectors/CursorConnector.php | 54 +++++
.../CustomStartPagePagedConnector.php | 48 +++++
.../DisabledInfiniteLoopConnector.php | 48 +++++
.../Fixtures/Connectors/OffsetConnector.php | 48 +++++
.../Fixtures/Connectors/PagedConnector.php | 43 ++++
.../Fixtures/Connectors/TestConnector.php | 22 ++
.../Fixtures/Requests/MappedPagedRequest.php | 32 +++
.../Requests/SuperheroCursorRequest.php | 22 ++
.../Requests/SuperheroLimitOffsetRequest.php | 22 ++
.../Requests/SuperheroPagedRequest.php | 22 ++
.../Fixtures/Requests/UserRequest.php | 21 ++
.../Pagination/Fixtures/SuperheroApi.php | 83 +++++++
.../Saloon/Pagination/PaginationTestCase.php | 33 +++
tests/Saloon/Pagination/PaginatorTest.php | 182 +++++++++++-----
28 files changed, 1569 insertions(+), 81 deletions(-)
create mode 100644 tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
create mode 100644 tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php
create mode 100644 tests/Saloon/Pagination/Feature/CollectTest.php
create mode 100644 tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php
create mode 100644 tests/Saloon/Pagination/Feature/ItemsTest.php
create mode 100644 tests/Saloon/Pagination/Feature/PaginationTest.php
create mode 100644 tests/Saloon/Pagination/Feature/PerPageLimitTest.php
create mode 100644 tests/Saloon/Pagination/Feature/StartPageTest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Requests/MappedPagedRequest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Requests/SuperheroLimitOffsetRequest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Requests/SuperheroPagedRequest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/Requests/UserRequest.php
create mode 100644 tests/Saloon/Pagination/Fixtures/SuperheroApi.php
create mode 100644 tests/Saloon/Pagination/PaginationTestCase.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 93e4fa002e..b1ff27cba9 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -203,7 +203,7 @@ saloonphp/pagination-plugin:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package, under Pagination. Request-owned pagination and link paginators are also used by SDK generators; check affected consumers when adapting upstream changes. Tests map to tests/Saloon/Pagination.
+ notes: Part of the unified src/saloon package, under Pagination. Request-owned pagination, link paginators and the protected paginator state ($pageNumber, $startPage, $perPageLimit, $currentResponse, $currentPageLoaded, $pooling) are used by SDK generators; check affected consumers when adapting upstream changes. Upstream's async iteration maps to Paginator::pool(), so async test cases traverse through pool(). The infinite-loop check runs in Paginator::current(), not request middleware, because a middleware closure would keep the paginator alive through its own request. Upstream tests map to tests/Saloon/Pagination/Feature, with the public superhero API faked by Fixtures/SuperheroApi; Hypervel-only cases are in tests/Saloon/Pagination/PaginatorTest.
saloonphp/rate-limit-plugin:
branch: v2
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 15892bb4b5..e98dab2d70 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1841,6 +1841,15 @@ protected string $perPageName = 'pageSize';
During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages return the same response body. Check that your paginator correctly identifies the last page. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator.
+The check compares each page's body after all response middleware has run. If your API adds a value that changes on every request, such as a request ID, override `getBodyChecksum` to compare only the page contents:
+
+```php
+protected function getBodyChecksum(Response $response): string
+{
+ return hash('xxh128', json_encode($response->json('data')));
+}
+```
+
If a request implements `MapPaginatedResponseItems`, its `mapPaginatedResponseItems` method takes precedence over the paginator's item mapping. Declare `@implements MapPaginatedResponseItems` with the same item type as its paginator. Mapping runs once per fetched page, after all response middleware, and `totalResults` counts these final items.
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 4c42f652fe..4b529a278a 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -22,6 +22,8 @@ Pools send requests concurrently through coroutines instead of promises, so ther
Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cacheStore()` instead of upstream's `resolveCacheDriver()` and `cacheExpiryInSeconds()`, so there is no `Driver` contract and no Flysystem, PSR or Laravel driver; add other caches as framework cache stores. `CachingException` replaces `HasCachingException`. Connectors are read-only, so they implement `Cacheable` without the `HasCaching` request controls and override `cacheKey()` and `cacheableMethods()`, which replaces `getCacheableMethods()`. GET, HEAD, OPTIONS and QUERY requests are cacheable by default. See [Caching](https://hypervel.org/docs/saloon#caching).
+Paginators set their values with `maxPages()`, `perPageLimit()` and `startPage()`, and `totalResults()`, `request()` and `currentPage()` replace `getTotalResults()`, `getOriginalRequest()` and `getCurrentPage()`. The deprecated `getPage()` and `$page` are not included; custom `applyPagination()` methods read the protected `$pageNumber`. Page and offset paginators send their pages concurrently with `pool()`, which sends the first page, then the rest through a coroutine pool, and returns the responses keyed by page position, so there is no `async()`, `isAsyncPaginationEnabled()` or `HasAsyncPagination` and no promises to iterate. Cursor paginators can't be pooled because each cursor comes from the previous response. Iterator keys and `currentPage()` count from zero at the start page, and `maxPages()` counts pages from the start page. Upstream starts keys at `startPage - 1` and compares `maxPages` with page numbers, so `startPage(3)` with `maxPages(2)` returns no pages there. See [API Pagination](https://hypervel.org/docs/saloon#api-pagination).
+
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php
index 3573470708..684c130488 100644
--- a/src/saloon/src/Pagination/Paginator.php
+++ b/src/saloon/src/Pagination/Paginator.php
@@ -110,27 +110,7 @@ public function __construct(
$this->request = clone $request;
$this->request->middleware()
- ->onResponse(static fn (Response $response): Response => $response->throw())
- ->onResponse(function (Response $response): void {
- if (! $this->detectInfiniteLoop || $this->pooling) {
- return;
- }
-
- // Retrying the current page replaces its checksum instead of counting as another page.
- $this->lastFiveBodyChecksums[$this->currentPage] = hash('xxh128', $response->body());
-
- if (count($this->lastFiveBodyChecksums) < 5) {
- return;
- }
-
- if (count(array_unique($this->lastFiveBodyChecksums)) === 1) {
- throw new PaginationException(
- 'Potential infinite loop detected because the last five pages had the same body.',
- );
- }
-
- unset($this->lastFiveBodyChecksums[array_key_first($this->lastFiveBodyChecksums)]);
- });
+ ->onResponse(static fn (Response $response): Response => $response->throw());
}
/**
@@ -147,6 +127,24 @@ public function current(): Response
$request = $this->applyPagination(clone $this->request);
$response = $this->connector->send($request);
+
+ // Checked here instead of in request middleware, where the callback would keep the paginator alive through
+ // its own request and every response.
+ if ($this->detectInfiniteLoop && ! $this->pooling) {
+ // Loading a page again after a mapping failure replaces its checksum instead of adding another page.
+ $this->lastFiveBodyChecksums[$this->currentPage] = $this->getBodyChecksum($response);
+
+ if (count($this->lastFiveBodyChecksums) >= 5) {
+ if (count(array_unique($this->lastFiveBodyChecksums)) === 1) {
+ throw new PaginationException(
+ 'Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.',
+ );
+ }
+
+ unset($this->lastFiveBodyChecksums[array_key_first($this->lastFiveBodyChecksums)]);
+ }
+ }
+
$this->currentPageItems = $this->pageItems($response);
// Keep the preceding response until mapping succeeds so a failed load retries the same page.
$this->currentResponse = $response;
@@ -394,6 +392,16 @@ public function count(): int
return $count;
}
+ /**
+ * Get the checksum of the response body.
+ *
+ * @param Response $response
+ */
+ protected function getBodyChecksum(Response $response): string
+ {
+ return hash('xxh128', $response->body());
+ }
+
/**
* Resolve response items using the request override when present.
*
diff --git a/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
new file mode 100644
index 0000000000..a5596a5322
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
@@ -0,0 +1,123 @@
+paginate($request);
+
+ $superheroes = [];
+
+ $iteratorCounter = 0;
+
+ foreach ($paginator->pool() as $response) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ }
+
+ $this->assertSame(4, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+
+ // Now we'll pool the same paginator again
+
+ $collection = (new Collection($paginator->pool()))
+ ->map(static fn (Response $response): array => $response->json('data'))
+ ->collapse();
+
+ $this->assertSame(range(1, 20), $collection->sortBy('id')->pluck('id')->values()->all());
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ public function testYouCanPaginateAsynchronouslyThroughManyPagesOfResultsWithLimitOffsetPagination(): void
+ {
+ $connector = new OffsetConnector;
+ $request = new SuperheroLimitOffsetRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+
+ $iteratorCounter = 0;
+
+ foreach ($paginator->pool() as $response) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ }
+
+ $this->assertSame(4, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+
+ // Now we'll pool the same paginator again
+
+ $collection = (new Collection($paginator->pool()))
+ ->map(static fn (Response $response): array => $response->json('data'))
+ ->collapse();
+
+ $this->assertSame(range(1, 20), $collection->sortBy('id')->pluck('id')->values()->all());
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ public function testIfYouDontImplementTheGetTotalPagesMethodOnAPaginatorItWillThrowAnExceptionIfYouTryToUseAsyncPagination(): void
+ {
+ $connector = new SyncPagedConnector;
+ $request = new SuperheroPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('Implement [getTotalPages] to use pooled pagination.');
+
+ $paginator->pool();
+ }
+
+ public function testIfThePaginatorReturnsAllThePagesInTheFirstPageItWontContinue(): void
+ {
+ $connector = new PagedConnector;
+ $request = new SuperheroPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+
+ $iteratorCounter = 0;
+
+ $paginator->perPageLimit(100);
+
+ foreach ($paginator->pool() as $response) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ }
+
+ $this->assertSame(1, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php b/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php
new file mode 100644
index 0000000000..4d1cee91f3
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php
@@ -0,0 +1,64 @@
+send()->wait().
+class AsyncPoolPaginationTest extends PaginationTestCase
+{
+ public function testYouCanMakeAPoolOfRequestsWithPagedPagination(): void
+ {
+ $connector = new PagedConnector;
+ $request = new SuperheroPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ $paginator->pool(5, function (Response $response) use (&$iteratorCounter, &$superheroes): void {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ });
+
+ $this->assertSame(4, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ sort($mapped);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+
+ public function testYouCanMakeAPoolOfRequestsWithLimitOffsetPagination(): void
+ {
+ $connector = new OffsetConnector;
+ $request = new SuperheroLimitOffsetRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ $paginator->pool(5, function (Response $response) use (&$iteratorCounter, &$superheroes): void {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ });
+
+ $this->assertSame(4, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ sort($mapped);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/CollectTest.php b/tests/Saloon/Pagination/Feature/CollectTest.php
new file mode 100644
index 0000000000..d4a781ed6f
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/CollectTest.php
@@ -0,0 +1,74 @@
+paginate($request);
+
+ $collection = $paginator->collect(throughItems: false)->collect();
+
+ $this->assertInstanceOf(Collection::class, $collection);
+ $this->assertContainsOnlyInstancesOf(Response::class, $collection);
+
+ $toIds = static fn (array $items): array => array_map(static fn (array $item): int => $item['id'], $items);
+
+ $this->assertSame([1, 2, 3, 4, 5], $toIds($collection[0]->json('data')));
+ $this->assertSame([6, 7, 8, 9, 10], $toIds($collection[1]->json('data')));
+ $this->assertSame([11, 12, 13, 14, 15], $toIds($collection[2]->json('data')));
+ $this->assertSame([16, 17, 18, 19, 20], $toIds($collection[3]->json('data')));
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ /**
+ * @see https://github.com/saloonphp/saloon/issues/464
+ */
+ public function testMultipleCollectCallsOnSameLazyCollectionDoNotTriggerInfiniteLoopDetection(): void
+ {
+ $singlePageResponse = [
+ 'data' => [['id' => 1], ['id' => 2]],
+ 'next_page_url' => null,
+ ];
+
+ Saloon::fake(new MockClient([
+ MockResponse::make($singlePageResponse),
+ MockResponse::make($singlePageResponse),
+ MockResponse::make($singlePageResponse),
+ MockResponse::make($singlePageResponse),
+ MockResponse::make($singlePageResponse),
+ MockResponse::make($singlePageResponse),
+ ]));
+
+ $connector = new PagedConnector;
+ $request = new SuperheroPagedRequest;
+ $lazyCollection = $connector->paginate($request)->collect();
+
+ $first = $lazyCollection->collect();
+ $this->assertInstanceOf(Collection::class, $first);
+ $this->assertSame([1, 2], $first->pluck('id')->all());
+
+ $lazyCollection->collect();
+ $lazyCollection->collect();
+ $lazyCollection->collect();
+ $lazyCollection->collect();
+
+ $again = $lazyCollection->collect();
+ $this->assertSame([1, 2], $again->pluck('id')->all());
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php
new file mode 100644
index 0000000000..a6101bc34c
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php
@@ -0,0 +1,154 @@
+ MockResponse::make(['next_page_url' => 'infinity']),
+ ]);
+
+ Saloon::fake($mockClient);
+
+ $connector = new PagedConnector;
+
+ // We'll create a paginator that expects the "next_page_url" to be empty.
+ // This will compare the responses and throw an exception if the last
+ // five responses have been the same.
+
+ $paginator = $connector->paginate(new SuperheroPagedRequest);
+
+ $thrownException = false;
+
+ try {
+ iterator_to_array($paginator);
+ } catch (PaginationException $exception) {
+ $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage());
+
+ $mockClient->assertSentCount(5);
+
+ $thrownException = true;
+ }
+
+ $this->assertTrue($thrownException);
+ }
+
+ public function testThePaginationPluginCanDetectAPotentialInfiniteLoopAfterTheInitialRequest(): void
+ {
+ // We'll have two regular requests sent, and then we'll have 6 exactly the same
+ // response bodies.
+
+ $mockClient = new MockClient([
+ MockResponse::make(['next_page_url' => '2']),
+ MockResponse::make(['next_page_url' => '3']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ ]);
+
+ Saloon::fake($mockClient);
+
+ $connector = new PagedConnector;
+
+ $paginator = $connector->paginate(new SuperheroPagedRequest);
+
+ $thrownException = false;
+
+ try {
+ iterator_to_array($paginator);
+ } catch (PaginationException $exception) {
+ $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage());
+
+ $mockClient->assertSentCount(7);
+
+ $thrownException = true;
+ }
+
+ $this->assertTrue($thrownException);
+ }
+
+ // Checksums use xxh128 instead of md5 and are keyed by page position, so a repeated load replaces its page's entry.
+ public function testThePaginatorOnlyKeepsFiveResponseChecksumsInMemoryAtOnce(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make($responseA = ['next_page_url' => '1']),
+ MockResponse::make($responseB = ['next_page_url' => '2']),
+ MockResponse::make($responseC = ['next_page_url' => '3']),
+ MockResponse::make($responseD = ['next_page_url' => '4']),
+ MockResponse::make($responseE = ['next_page_url' => '5']),
+ MockResponse::make($responseF = ['next_page_url' => '6']),
+ MockResponse::make($responseG = ['next_page_url' => null]),
+ ]);
+
+ Saloon::fake($mockClient);
+
+ $connector = new PagedConnector;
+
+ $paginator = $connector->paginate(new SuperheroPagedRequest);
+
+ iterator_to_array($paginator);
+
+ $previousBodyChecksums = (fn (): array => $this->lastFiveBodyChecksums)->call($paginator);
+
+ // We should only have four items because after each successful fifth attempt
+ // we will remove the oldest one from the array.
+
+ $this->assertCount(4, $previousBodyChecksums);
+
+ $this->assertSame([
+ 3 => hash('xxh128', json_encode($responseD)),
+ 4 => hash('xxh128', json_encode($responseE)),
+ 5 => hash('xxh128', json_encode($responseF)),
+ 6 => hash('xxh128', json_encode($responseG)),
+ ], $previousBodyChecksums);
+ }
+
+ public function testYouCanDisableTheInfiniteLoopDetectionOnAPaginator(): void
+ {
+ $mockClient = new MockClient([
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ MockResponse::make(['next_page_url' => 'infinity']),
+ ]);
+
+ Saloon::fake($mockClient);
+
+ $connector = new DisabledInfiniteLoopConnector;
+
+ $paginator = $connector->paginate(new SuperheroPagedRequest);
+
+ $thrownException = false;
+
+ try {
+ iterator_to_array($paginator);
+ } catch (NoMockResponseFoundException) {
+ // We'll detect a "NoMockResponseFoundException" because our mock client should attempt to
+ // keep making requests because the infinite loop detection has been disabled.
+
+ $thrownException = true;
+ }
+
+ $this->assertTrue($thrownException);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/ItemsTest.php b/tests/Saloon/Pagination/Feature/ItemsTest.php
new file mode 100644
index 0000000000..2a18215b99
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/ItemsTest.php
@@ -0,0 +1,33 @@
+paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator->items() as $item) {
+ $superheroes[] = $item;
+ ++$iteratorCounter;
+ }
+
+ $this->assertSame(20, $iteratorCounter);
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/PaginationTest.php b/tests/Saloon/Pagination/Feature/PaginationTest.php
new file mode 100644
index 0000000000..d776ad6555
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/PaginationTest.php
@@ -0,0 +1,203 @@
+paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(4, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+
+ // Now we'll test the collect method
+
+ $collection = $paginator->collect()->collect();
+
+ $this->assertInstanceOf(Collection::class, $collection);
+ $this->assertSame(range(1, 20), $collection->pluck('id')->toArray());
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ public function testYouCanPaginateAutomaticallyThroughManyPagesOfResultsWithLimitOffsetPagination(): void
+ {
+ $connector = new OffsetConnector;
+ $request = new SuperheroLimitOffsetRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+
+ foreach ($paginator as $item) {
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+
+ // Now we'll test the collect method
+
+ $collection = $paginator->collect()->collect();
+
+ $this->assertInstanceOf(Collection::class, $collection);
+ $this->assertSame(range(1, 20), $collection->pluck('id')->toArray());
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ public function testYouCanPaginateAutomaticallyThroughManyPagesOfResultsWithCursorPagination(): void
+ {
+ $connector = new CursorConnector;
+ $request = new SuperheroCursorRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+
+ foreach ($paginator as $item) {
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+
+ // Now we'll test the collect method
+
+ $collection = $paginator->collect()->collect();
+
+ $this->assertInstanceOf(Collection::class, $collection);
+ $this->assertSame(range(1, 20), $collection->pluck('id')->toArray());
+
+ $this->assertSame(20, $paginator->totalResults());
+ }
+
+ public function testYouCanSpecifyTheMaximumNumberOfPagesToIterateOver(): void
+ {
+ $connector = new CursorConnector;
+ $request = new SuperheroCursorRequest;
+ $paginator = $connector->paginate($request);
+
+ $paginator->maxPages(2);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertCount(10, $superheroes);
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 10), $mapped);
+ }
+
+ public function testIfThePaginatorReturnsAllThePagesInTheFirstPageItWontContinue(): void
+ {
+ $connector = new PagedConnector;
+ $request = new SuperheroPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ $paginator->perPageLimit(100);
+
+ foreach ($paginator as $response) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $response->json('data'));
+ }
+
+ $this->assertSame(1, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+
+ public function testThePaginatorWillThrowAnExceptionIfYouUseARequestThatIsNotPaginatable(): void
+ {
+ $connector = new PagedConnector;
+ $request = new UserRequest;
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The request must implement [' . Paginatable::class . '] to be used with a paginator.');
+
+ $connector->paginate($request);
+ }
+
+ public function testAnIndividualRequestCanImplementTheGetPaginatedResultsInterfaceToOverwriteTheConnectorsPaginator(): void
+ {
+ $connector = new PagedConnector;
+ $request = new MappedPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+
+ foreach ($paginator->items() as $item) {
+ $superheroes[] = $item;
+ }
+
+ $this->assertSame([
+ 'Batman',
+ 'Superman',
+ 'Flash',
+ 'Green Lantern',
+ 'Green Arrow',
+ 'Wonder Woman',
+ 'Martian Manhunter',
+ 'Robin/Nightwing',
+ 'Blue Beetle',
+ 'Black Canary',
+ 'Spider Man',
+ 'Captain America',
+ 'Iron Man',
+ 'Thor',
+ 'Hulk',
+ 'Wolverine',
+ 'Daredevil',
+ 'Hawkeye',
+ 'Cyclops',
+ 'Silver Surfer',
+ ], $superheroes);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/PerPageLimitTest.php b/tests/Saloon/Pagination/Feature/PerPageLimitTest.php
new file mode 100644
index 0000000000..45d9b66788
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/PerPageLimitTest.php
@@ -0,0 +1,88 @@
+paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ $paginator->perPageLimit(10);
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+
+ public function testYouCanSpecifyAPerPageLimitOnALimitOffsetPaginator(): void
+ {
+ $connector = new OffsetConnector;
+ $request = new SuperheroLimitOffsetRequest;
+ $paginator = $connector->paginate($request);
+
+ $paginator->perPageLimit(10);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+
+ public function testYouCanSpecifyAPerPageLimitOnACursorPaginator(): void
+ {
+ $connector = new CursorConnector;
+ $request = new SuperheroCursorRequest;
+ $paginator = $connector->paginate($request);
+
+ $paginator->perPageLimit(10);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertSame(20, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(1, 20), $mapped);
+ }
+}
diff --git a/tests/Saloon/Pagination/Feature/StartPageTest.php b/tests/Saloon/Pagination/Feature/StartPageTest.php
new file mode 100644
index 0000000000..3c5564f3f4
--- /dev/null
+++ b/tests/Saloon/Pagination/Feature/StartPageTest.php
@@ -0,0 +1,59 @@
+paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertSame(10, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(11, 20), $mapped);
+ }
+
+ public function testYouCanSpecifyAStartPageOnAPaginatorInstance(): void
+ {
+ $connector = new PagedConnector;
+ $request = new SuperheroPagedRequest;
+ $paginator = $connector->paginate($request);
+
+ $superheroes = [];
+ $iteratorCounter = 0;
+
+ $paginator->startPage(3);
+
+ foreach ($paginator as $item) {
+ ++$iteratorCounter;
+ $superheroes = array_merge($superheroes, $item->json('data'));
+ }
+
+ $this->assertSame(2, $iteratorCounter);
+ $this->assertSame(10, $paginator->totalResults());
+
+ $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes);
+
+ $this->assertSame(range(11, 20), $mapped);
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php
new file mode 100644
index 0000000000..7cd49b53d7
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php
@@ -0,0 +1,57 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends OffsetPaginator {
+ /**
+ * Per Page.
+ */
+ protected ?int $perPageLimit = 5;
+
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return $this->getOffset() >= (int) $response->json('total');
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+
+ /**
+ * Get the total number of pages.
+ */
+ protected function getTotalPages(Response $response): int
+ {
+ return (int) ceil($response->json('total') / $this->perPageLimit);
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php
new file mode 100644
index 0000000000..812ebd98ac
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php
@@ -0,0 +1,52 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends PagedPaginator {
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return empty($response->json('next_page_url'));
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+
+ /**
+ * Get the total number of pages.
+ */
+ protected function getTotalPages(Response $response): int
+ {
+ return (int) ceil($response->json('total') / $response->json('per_page'));
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php
new file mode 100644
index 0000000000..b164623904
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php
@@ -0,0 +1,54 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends CursorPaginator {
+ /**
+ * Get the next cursor.
+ */
+ protected function getNextCursor(Response $response): int|string
+ {
+ $nextPageUrl = $response->json('next_page_url');
+ parse_str(parse_url($nextPageUrl, PHP_URL_QUERY), $queryParams);
+
+ return $queryParams['cursor'];
+ }
+
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return empty($response->json('next_page_url'));
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php
new file mode 100644
index 0000000000..1f22636494
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php
@@ -0,0 +1,48 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends PagedPaginator {
+ /**
+ * Set a custom start page.
+ */
+ protected int $startPage = 3;
+
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return empty($response->json('next_page_url'));
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php
new file mode 100644
index 0000000000..fa0fe0dbfb
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php
@@ -0,0 +1,48 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends PagedPaginator {
+ /**
+ * Do not detect infinite loops.
+ */
+ protected bool $detectInfiniteLoop = false;
+
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return empty($response->json('next_page_url'));
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php
new file mode 100644
index 0000000000..dfc864445a
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php
@@ -0,0 +1,48 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends OffsetPaginator {
+ /**
+ * Per Page.
+ */
+ protected ?int $perPageLimit = 5;
+
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return $this->getOffset() >= (int) $response->json('total');
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php
new file mode 100644
index 0000000000..af6c6e4e45
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php
@@ -0,0 +1,43 @@
+paginate($this);
+ }
+
+ return new class(connector: $this, request: $request) extends PagedPaginator {
+ /**
+ * Check if we are on the last page.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return empty($response->json('next_page_url'));
+ }
+
+ /**
+ * Get the results from the page.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data') ?? [];
+ }
+ };
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php
new file mode 100644
index 0000000000..ad874d7ce4
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php
@@ -0,0 +1,22 @@
+collect('data')->pluck('superhero')->toArray();
+ }
+}
diff --git a/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php b/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php
new file mode 100644
index 0000000000..eb509a48de
--- /dev/null
+++ b/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php
@@ -0,0 +1,22 @@
+toPsrRequest()->getUri();
+ $query = Query::parse($uri->getQuery());
+ $total = count(self::SUPERHEROES);
+ $page = (int) ($query['page'] ?? 1);
+ $perPage = (int) ($query['per_page'] ?? 5);
+ $cursor = (int) ($query['cursor'] ?? 0);
+
+ return match ($uri->getPath()) {
+ '/api/superheroes/per-page' => Factory::response([
+ 'data' => self::superheroes(($page - 1) * $perPage, $perPage),
+ 'total' => $total,
+ 'per_page' => $perPage,
+ 'next_page_url' => $page * $perPage < $total
+ ? self::BASE_URL . '/superheroes/per-page?page=' . ($page + 1)
+ : null,
+ ]),
+ '/api/superheroes/cursor' => Factory::response([
+ 'data' => self::superheroes($cursor, $perPage),
+ 'next_page_url' => $cursor + $perPage < $total
+ ? self::BASE_URL . '/superheroes/cursor?cursor=' . ($cursor + $perPage)
+ : null,
+ ]),
+ '/api/superheroes/limit-offset' => Factory::response([
+ 'data' => self::superheroes((int) ($query['offset'] ?? 0), (int) ($query['limit'] ?? 5)),
+ 'total' => $total,
+ ]),
+ default => Factory::response(status: 404),
+ };
+ });
+ }
+
+ /**
+ * Get one slice of superheroes.
+ *
+ * @return list
+ */
+ protected static function superheroes(int $offset, int $limit): array
+ {
+ $superheroes = [];
+
+ foreach (array_slice(self::SUPERHEROES, $offset, $limit) as $index => $name) {
+ $superheroes[] = ['id' => $offset + $index + 1, 'superhero' => $name];
+ }
+
+ return $superheroes;
+ }
+}
diff --git a/tests/Saloon/Pagination/PaginationTestCase.php b/tests/Saloon/Pagination/PaginationTestCase.php
new file mode 100644
index 0000000000..71a0267b77
--- /dev/null
+++ b/tests/Saloon/Pagination/PaginationTestCase.php
@@ -0,0 +1,33 @@
+ [false, '0'];
}
- public function testRequestCanMapPaginatedItems(): void
- {
- $manager = $this->manager();
- $manager->fake([
- MappedPagedRequestStub::class => static function (PendingRequest $pendingRequest): MockResponse {
- $page = (int) $pendingRequest->request()->queryParameters()['page'];
-
- return MockResponse::make([
- 'data' => [['name' => 'item-' . $page]],
- 'page' => $page,
- 'pages' => 1,
- ]);
- },
- ]);
- $paginator = new PagedPaginatorStub(
- new PaginationConnectorStub($manager),
- new MappedPagedRequestStub,
- );
-
- $this->assertSame(['item-1'], iterator_to_array($paginator->items(), false));
- $this->assertSame(1, $paginator->request()->mapping->calls);
- }
-
#[DataProvider('responseChanges')]
public function testItemsAreMappedOnceAfterTheCompleteResponsePipeline(bool $replace): void
{
@@ -444,6 +422,22 @@ public function testMappedItemsAreReleasedOnAdvanceAndRewind(): void
$this->assertSame(0, $paginator->totalResults());
}
+ public function testADroppedPaginatorIsFreedWhileItsResponseIsKept(): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make([
+ 'data' => [1], 'page' => 1, 'pages' => 2,
+ ])]);
+ $paginator = new PagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $reference = WeakReference::create($paginator);
+
+ $response = $paginator->current();
+ unset($paginator);
+
+ $this->assertNull($reference->get());
+ $this->assertSame([1], $response->json('data'));
+ }
+
public function testPooledMappingReleasesItemsBeforePageCountsAndCallbacks(): void
{
$manager = $this->manager();
@@ -1000,18 +994,48 @@ public function testLinkPaginationMayStartAtPageZero(): void
$this->assertCount(2, $paginator->pool());
}
- public function testRepeatedBodiesStopASequentialPaginationLoop(): void
+ public function testBodyChecksumsCanIgnoreFieldsThatChangeOnEveryRequest(): void
{
$manager = $this->manager();
- $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]])]);
+ $requested = 0;
+ $manager->fake([PagedRequestStub::class => static function () use (&$requested): MockResponse {
+ return MockResponse::make(['data' => [1], 'request_id' => ++$requested]);
+ }]);
+ $paginator = (new DataChecksumPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub))
+ ->maxPages(6);
+ $caught = null;
+
+ try {
+ iterator_to_array($paginator);
+ } catch (PaginationException $exception) {
+ $caught = $exception;
+ }
+
+ $this->assertInstanceOf(PaginationException::class, $caught);
+ $this->assertSame(5, $requested);
+ }
+
+ public function testLoopDetectionChecksTheResponseReturnedByRequestMiddleware(): void
+ {
+ $manager = $this->manager();
+ $requested = 0;
+ $manager->fake([NormalizedPagedRequestStub::class => static function () use (&$requested): MockResponse {
+ return MockResponse::make(['data' => [1], 'request_id' => ++$requested]);
+ }]);
$paginator = (new NeverEndingPagedPaginatorStub(
new PaginationConnectorStub($manager),
- new PagedRequestStub,
+ new NormalizedPagedRequestStub,
))->maxPages(6);
+ $caught = null;
- $this->expectException(PaginationException::class);
+ try {
+ iterator_to_array($paginator);
+ } catch (PaginationException $exception) {
+ $caught = $exception;
+ }
- iterator_to_array($paginator);
+ $this->assertInstanceOf(PaginationException::class, $caught);
+ $this->assertSame(5, $requested);
}
public function testPooledPaginationFetchesTheFirstPageThenBoundsRemainingWork(): void
@@ -1066,13 +1090,6 @@ public function testCursorPaginationCannotBePooledAndOffsetRequiresALimit(): voi
(new OffsetPaginatorStub($connector, new OffsetRequestStub))->current();
}
- public function testPaginatorRequiresAPaginatableRequest(): void
- {
- $this->expectException(InvalidArgumentException::class);
-
- new PagedPaginatorStub(new PaginationConnectorStub($this->manager()), new NonPaginatableRequestStub);
- }
-
/**
* Create a Saloon manager.
*/
@@ -1095,15 +1112,24 @@ protected function manager(): SaloonManager
class PaginationConnectorStub extends Connector
{
+ /**
+ * Create a connector that sends through the given manager.
+ */
public function __construct(protected SaloonManager $manager)
{
}
+ /**
+ * Resolve the base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Send the request through the test manager.
+ */
public function send(Request $request, ?MockClient $mockClient = null): Response
{
return $this->manager->send($this, $request, $mockClient);
@@ -1114,29 +1140,27 @@ class PagedRequestStub extends Request implements Paginatable
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the endpoint.
+ */
public function resolveEndpoint(): string
{
return '/paged';
}
}
-class MappedPagedRequestStub extends PagedRequestStub implements MapPaginatedResponseItems
+class NormalizedPagedRequestStub extends PagedRequestStub
{
- public object $mapping;
-
/**
- * Share mapping observations across request clones.
+ * Remove the per-request identifier from each response body.
*/
- public function __construct()
+ public function boot(PendingRequest $pendingRequest): void
{
- $this->mapping = (object) ['calls' => 0];
- }
-
- public function mapPaginatedResponseItems(Response $response): array
- {
- ++$this->mapping->calls;
-
- return array_column($response->json('data'), 'name');
+ $pendingRequest->middleware()->onResponse(static fn (Response $response): Response => Response::fromResponse(
+ new HttpResponse(new PsrResponse($response->status(), [], json_encode(['data' => $response->json('data')]))),
+ $response->pendingRequest(),
+ $response->toPsrRequest(),
+ ));
}
}
@@ -1144,6 +1168,9 @@ class OffsetRequestStub extends Request implements Paginatable
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the endpoint.
+ */
public function resolveEndpoint(): string
{
return '/offset';
@@ -1154,34 +1181,36 @@ class CursorRequestStub extends Request implements Paginatable
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the endpoint.
+ */
public function resolveEndpoint(): string
{
return '/cursor';
}
}
-class NonPaginatableRequestStub extends Request
-{
- protected Method $method = Method::GET;
-
- public function resolveEndpoint(): string
- {
- return '/invalid';
- }
-}
-
class PagedPaginatorStub extends PagedPaginator
{
+ /**
+ * Determine if the response is the last page.
+ */
protected function isLastPage(Response $response): bool
{
return $response->json('page') >= $response->json('pages');
}
+ /**
+ * Get the items from one page.
+ */
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
}
+ /**
+ * Get the total number of pages.
+ */
protected function getTotalPages(Response $response): int
{
return (int) $response->json('pages');
@@ -1190,17 +1219,34 @@ protected function getTotalPages(Response $response): int
class NeverEndingPagedPaginatorStub extends PagedPaginator
{
+ /**
+ * Keep requesting pages.
+ */
protected function isLastPage(Response $response): bool
{
return false;
}
+ /**
+ * Get the items from one page.
+ */
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
}
}
+class DataChecksumPaginatorStub extends NeverEndingPagedPaginatorStub
+{
+ /**
+ * Compare pages by their items only.
+ */
+ protected function getBodyChecksum(Response $response): string
+ {
+ return hash('xxh128', json_encode($response->json('data')));
+ }
+}
+
class MappingPagedPaginatorStub extends PagedPaginatorStub
{
public array $mappedPages = [];
@@ -1237,16 +1283,25 @@ class RenamedPagedPaginatorStub extends NeverEndingPagedPaginatorStub
class OffsetPaginatorStub extends OffsetPaginator
{
+ /**
+ * Determine if the response is the last page.
+ */
protected function isLastPage(Response $response): bool
{
return $response->json('offset') + count($response->json('data')) >= $response->json('total');
}
+ /**
+ * Get the items from one page.
+ */
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
}
+ /**
+ * Get the total number of pages.
+ */
protected function getTotalPages(Response $response): int
{
return (int) ceil($response->json('total') / $this->perPageLimit);
@@ -1255,16 +1310,25 @@ protected function getTotalPages(Response $response): int
class CursorPaginatorStub extends CursorPaginator
{
+ /**
+ * Get the next cursor.
+ */
protected function getNextCursor(Response $response): int|string
{
return $response->json('next');
}
+ /**
+ * Determine if the response is the last page.
+ */
protected function isLastPage(Response $response): bool
{
return $response->json('next') === null;
}
+ /**
+ * Get the items from one page.
+ */
protected function getPageItems(Response $response, Request $request): array
{
return $response->json('data');
From 8534043bb9ef476b31d6b97725e6009fabd4f4a1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 02:21:19 +0000
Subject: [PATCH 14/26] Reconcile Saloon rate-limit integration with the
rate-limit plugin
Ports the rate-limit plugin's feature, unit and job middleware tests
(saloonphp/rate-limit-plugin at aed5ff488e4d) into
tests/Saloon/RateLimit/{Feature,Unit,Hypervel/Feature}, sending through
Http::fake() because Saloon fakes and cache hits make no provider call
and consume no capacity.
- A 429 whose Retry-After is missing, malformed or too large for the
rate limiter records upstream's 60-second cooldown (#28). A valid
zero or past delay records none.
- waitForRateLimits() now receives the denied policy and its decision,
so a resource can wait out a burst limit and still throw for a daily
quota. Nothing is resent from middleware: waiting for Cooldown with a
429 retry policy waits out the cooldown and sends again through the
manager's bounded attempt loop with the original connector (#27, #29).
- resolveRateLimiterName() (default: the class name) names the limiter
saloon:{name} for admission, cooldowns and inspection, so resources
returning the same name share a quota and its cooldown. The default
cooldown key is empty, so cooldowns follow the name.
- Saloon::inspectRateLimit() checks a resource's policy or cooldown
without consuming it, through the send path's store and name, in
place of hasReachedRateLimit() and getExceededLimit().
- rateLimitingEnabled(PendingRequest) skips a resource's admission,
cooldown checks and cooldown recording for one operation, in place of
useRateLimitPlugin() and $rateLimitingEnabled.
- RateLimitReachedException names the limiter, with the policy key when
set, so a log shows which integration was blocked.
Test adaptations: the Limit::custom() case is REMOVED (custom responses
override resolveRateLimitCooldown()), and so is the unconfigured-limit
case (rejected when the limit is created). TestConnector takes the
limits to wait for in place of sleep(). JobMiddlewareTest dispatches
real jobs through ReleaseOnRateLimit and absorbs ReleaseOnRateLimitTest.
RateLimitTest drops the cases now covered upstream. The README and the
guide cover sharing, inspecting and disabling limits and the 60-second
fallback; the sync note records the test mapping.
Validation: each changed test file, the Saloon and RateLimiter suites
(ParaTest), FacadeDocblocksTest, composer analyse and php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/saloon.md | 93 ++-
src/saloon/README.md | 4 +
src/saloon/src/Facades/Saloon.php | 1 +
src/saloon/src/Http/Connector.php | 22 +-
src/saloon/src/Http/Request.php | 22 +-
.../Exceptions/RateLimitReachedException.php | 8 +-
.../src/RateLimit/Traits/HasRateLimits.php | 69 +-
src/saloon/src/SaloonManager.php | 32 +-
.../RateLimit/Feature/HasRateLimitsTest.php | 636 ++++++++++++++++++
.../Fixtures/Connectors/BaseConnector.php | 18 +
.../Connectors/CustomPrefixConnector.php | 17 +
.../CustomTooManyRequestsConnector.php | 19 +
.../Fixtures/Connectors/DestructConnector.php | 50 ++
.../DisabledTooManyRequestsConnector.php | 19 +
.../SleepTooManyRequestsConnector.php | 46 ++
.../Fixtures/Connectors/TestConnector.php | 46 ++
.../Fixtures/Jobs/ApiRateLimitedJob.php | 47 ++
.../Fixtures/Requests/LimitedRequest.php | 39 ++
.../Fixtures/Requests/LimitedSoloRequest.php | 39 ++
.../Fixtures/Requests/UserRequest.php | 24 +
.../Hypervel/Feature/JobMiddlewareTest.php | 119 ++++
tests/Saloon/RateLimit/RateLimitTest.php | 196 +-----
.../RateLimit/ReleaseOnRateLimitTest.php | 67 --
tests/Saloon/RateLimit/Unit/DestructTest.php | 23 +
25 files changed, 1373 insertions(+), 285 deletions(-)
create mode 100644 tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/CustomPrefixConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/CustomTooManyRequestsConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/SleepTooManyRequestsConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Requests/LimitedSoloRequest.php
create mode 100644 tests/Saloon/RateLimit/Fixtures/Requests/UserRequest.php
create mode 100644 tests/Saloon/RateLimit/Hypervel/Feature/JobMiddlewareTest.php
delete mode 100644 tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php
create mode 100644 tests/Saloon/RateLimit/Unit/DestructTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index b1ff27cba9..c83006d9d4 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -210,7 +210,7 @@ saloonphp/rate-limit-plugin:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Tests map to tests/Saloon/RateLimit.
+ notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Upstream's limiter middleware, too-many-attempts limit and store records become admission before transport, a cooldown recorded from the transport response, and Saloon::inspectRateLimit(). Feature and Unit tests map to tests/Saloon/RateLimit/{Feature,Unit}, and Laravel tests to tests/Saloon/RateLimit/Hypervel; ported cases fake the transport with Http::fake(), because Saloon fakes consume no capacity, and assert through inspectRateLimit() instead of store records. RateLimitTest keeps the Retry-After parsing and policy-group cases.
Sammyjo20/saloon-docs:
branch: v3
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index e98dab2d70..ca045dc335 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -63,6 +63,9 @@
- [Defining Policies](#defining-policies)
- [Tenant and Service Limits](#tenant-and-service-limits)
- [Server Cooldowns](#server-cooldowns)
+ - [Sharing Limits](#sharing-limits)
+ - [Inspecting Limits](#inspecting-limits)
+ - [Disabling Rate Limits](#disabling-rate-limits)
- [Queued Requests](#queued-requests)
- [Multi-Tenant Integrations](#multi-tenant-integrations)
- [Telescope](#telescope)
@@ -1973,12 +1976,16 @@ class GitHubConnector extends Connector
}
```
-By default, a denied policy throws `RateLimitReachedException` before reaching the network. Override `waitForRateLimits` to return true when the operation should sleep until capacity is available:
+By default, a denied policy throws `RateLimitReachedException` before reaching the network. Override `waitForRateLimits` to sleep until capacity is available instead. It receives the denied policy and its decision, so you may wait out a short burst limit while still throwing for a daily quota:
```php
-protected function waitForRateLimits(): bool
+use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
+use Hypervel\RateLimiter\Cooldown;
+
+protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
- return true;
+ return $result->retryAfter() <= 10;
}
```
@@ -2028,14 +2035,14 @@ Saloon limiter names always begin with `saloon:`. Restricting the callback preve
### Server Cooldowns
-When a rate-limited connector or request receives a 429 response with a valid `Retry-After` header, Saloon records the provider's cooldown before response middleware runs. A later operation checks this cooldown before consuming its configured admission policies.
+When a rate-limited connector or request receives a 429 response, Saloon records the provider's cooldown before response middleware runs. The `Retry-After` header sets its length. If the header is missing or invalid, the cooldown lasts 60 seconds, while a delay that has already passed records no cooldown. A later operation checks this cooldown before consuming its configured admission policies.
-By default, the connector or request class identifies the cooldown. If a provider applies cooldowns per account or credential, override `resolveRateLimitCooldownKey` with that stable provider identity:
+By default, each connector or request has its own cooldown. If a provider applies cooldowns per account or credential, override `resolveRateLimitCooldownKey` with that stable provider identity:
```php
protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string
{
- return static::class . ':account:' . $this->providerAccountId;
+ return 'account:' . $this->providerAccountId;
}
```
@@ -2071,7 +2078,79 @@ class GitHubConnector extends Connector
}
```
-A 429 response is returned through normal error handling. Recording a cooldown never resends the response recursively. If the normal retry policy requests another attempt, that attempt must first pass the recorded cooldown.
+A 429 response is returned through normal error handling. Recording a cooldown never resends the response recursively. If the normal retry policy requests another attempt, that attempt must first pass the recorded cooldown. To wait out the cooldown and send the request again, retry 429 responses and wait for cooldowns:
+
+```php
+use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
+use Hypervel\RateLimiter\Cooldown;
+use Hypervel\Saloon\Data\RetryPolicy;
+use Hypervel\Saloon\Exceptions\Request\FatalRequestException;
+use Hypervel\Saloon\Exceptions\Request\RequestException;
+
+protected function defaultRetryPolicy(): ?RetryPolicy
+{
+ return new RetryPolicy(
+ times: 2,
+ when: fn (FatalRequestException|RequestException $exception): bool => $exception instanceof RequestException
+ && $exception->status() === 429,
+ );
+}
+
+protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
+{
+ return $policy instanceof Cooldown;
+}
+```
+
+
+### Sharing Limits
+
+Saloon records a resource's limits and cooldowns under its class name. When several connectors or requests use the same provider quota, override `resolveRateLimiterName` on each of them to return the same name:
+
+```php
+protected function resolveRateLimiterName(): string
+{
+ return 'acme';
+}
+```
+
+Resources with the same name share a policy's state when they declare the same policy and use the same rate-limiter store and key scope. A 429 received by one of them pauses the others too, unless their cooldown keys differ.
+
+
+### Inspecting Limits
+
+The `Saloon` facade's `inspectRateLimit` method checks a resource's policy or cooldown without consuming it, using the same store and limiter name as a request:
+
+```php
+use Hypervel\RateLimiter\Cooldown;
+use Hypervel\RateLimiter\Limit;
+use Hypervel\Saloon\Facades\Saloon;
+
+$result = Saloon::inspectRateLimit($connector, Limit::perMinute(60)->by('github'));
+
+if ($result->remaining() <= $result->limit() * 0.2) {
+ // 20% or fewer of this minute's requests remain...
+}
+
+$cooldown = Saloon::inspectRateLimit($connector, Cooldown::for(null));
+```
+
+Pass the same policy your resource declares. Putting shared policies in a method keeps them identical.
+
+
+### Disabling Rate Limits
+
+Override `rateLimitingEnabled` to skip a resource's limits for an operation. A disabled operation consumes no capacity, ignores active cooldowns, and records no cooldown from its response:
+
+```php
+protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool
+{
+ return ! $pendingRequest->request() instanceof ImportRequest;
+}
+```
+
+Disabling a request's limits does not disable its connector's limits. Returning no policies still applies cooldowns.
### Queued Requests
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 4b529a278a..b22f3c7f5a 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -24,6 +24,10 @@ Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cac
Paginators set their values with `maxPages()`, `perPageLimit()` and `startPage()`, and `totalResults()`, `request()` and `currentPage()` replace `getTotalResults()`, `getOriginalRequest()` and `getCurrentPage()`. The deprecated `getPage()` and `$page` are not included; custom `applyPagination()` methods read the protected `$pageNumber`. Page and offset paginators send their pages concurrently with `pool()`, which sends the first page, then the rest through a coroutine pool, and returns the responses keyed by page position, so there is no `async()`, `isAsyncPaginationEnabled()` or `HasAsyncPagination` and no promises to iterate. Cursor paginators can't be pooled because each cursor comes from the previous response. Iterator keys and `currentPage()` count from zero at the start page, and `maxPages()` counts pages from the start page. Upstream starts keys at `startPage - 1` and compares `maxPages` with page numbers, so `startPage(3)` with `maxPages(2)` returns no pages there. See [API Pagination](https://hypervel.org/docs/saloon#api-pagination).
+Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract or plugin store. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()` and `$detectTooManyAttempts`. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again.
+
+Connectors are read-only, so `rateLimitingEnabled(PendingRequest)` replaces `useRateLimitPlugin()` and `$rateLimitingEnabled`, and `Saloon::inspectRateLimit()` replaces `hasReachedRateLimit()` and `getExceededLimit()`; compare `remaining()` with `limit()` in place of a threshold. `RateLimitReachedException` exposes `policy()` and `result()` instead of `getLimit()`, and the job middleware is `Queue\ReleaseOnRateLimit` instead of `Helpers\ApiRateLimited`. See [Rate Limiting](https://hypervel.org/docs/saloon#rate-limiting).
+
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
diff --git a/src/saloon/src/Facades/Saloon.php b/src/saloon/src/Facades/Saloon.php
index d008c29e78..40f3780c90 100644
--- a/src/saloon/src/Facades/Saloon.php
+++ b/src/saloon/src/Facades/Saloon.php
@@ -21,6 +21,7 @@
* @method static \Hypervel\Saloon\Http\Faking\MockClient fake(array|\Hypervel\Saloon\Http\Faking\MockClient $responses = [])
* @method static \Hypervel\Saloon\SaloonManager fixturePath(string $path)
* @method static string getFixturePath()
+ * @method static ($policy is \Hypervel\RateLimiter\Cooldown ? \Hypervel\RateLimiter\CooldownResult : \Hypervel\RateLimiter\LimitResult) inspectRateLimit(\Hypervel\Saloon\Http\Connector|\Hypervel\Saloon\Http\Request $resource, \Hypervel\RateLimiter\AdmissionPolicy|\Hypervel\RateLimiter\Cooldown $policy)
* @method static \Hypervel\Saloon\Http\MiddlewarePipeline middleware()
* @method static \Hypervel\Saloon\Http\Faking\MockClient|null mockClient()
* @method static \Hypervel\RateLimiter\RateLimiter rateLimiter()
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index b97980854a..0a0540304f 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -6,6 +6,8 @@
use Hypervel\Container\Container;
use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
+use Hypervel\RateLimiter\Cooldown;
use Hypervel\Saloon\Contracts\Authenticator;
use Hypervel\Saloon\Contracts\Body\BodyRepository;
use Hypervel\Saloon\Data\RetryPolicy;
@@ -270,11 +272,11 @@ final public function resolveCacheableMethods(): array
}
/**
- * Determine if this connector defines rate limits.
+ * Determine if the connector's rate limits apply to an operation.
*
* @internal
*/
- public function usesRateLimits(): bool
+ public function usesRateLimits(PendingRequest $pendingRequest): bool
{
return false;
}
@@ -301,11 +303,21 @@ public function resolveRateLimitStoreName(): UnitEnum|string|null
}
/**
- * Determine if connector rate limits should be awaited.
+ * Resolve the identity whose limits and cooldowns the connector uses.
*
* @internal
*/
- public function shouldWaitForRateLimits(): bool
+ public function rateLimiterName(): string
+ {
+ return static::class;
+ }
+
+ /**
+ * Determine if a denied connector rate limit should be awaited.
+ *
+ * @internal
+ */
+ public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
return false;
}
@@ -317,7 +329,7 @@ public function shouldWaitForRateLimits(): bool
*/
public function resolveRateLimitCooldownKeyFor(PendingRequest $pendingRequest): string
{
- return static::class;
+ return '';
}
/**
diff --git a/src/saloon/src/Http/Request.php b/src/saloon/src/Http/Request.php
index 249b68dffd..9ace63bd6c 100644
--- a/src/saloon/src/Http/Request.php
+++ b/src/saloon/src/Http/Request.php
@@ -6,6 +6,8 @@
use Hypervel\Contracts\Container\SelfBuilding;
use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
+use Hypervel\RateLimiter\Cooldown;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Traits\Auth\AuthenticatesRequests;
use Hypervel\Saloon\Traits\Body\HasBody;
@@ -153,11 +155,11 @@ public function resolveCacheableMethods(): ?array
}
/**
- * Determine if this request defines rate limits.
+ * Determine if the request's rate limits apply to an operation.
*
* @internal
*/
- public function usesRateLimits(): bool
+ public function usesRateLimits(PendingRequest $pendingRequest): bool
{
return false;
}
@@ -184,11 +186,21 @@ public function resolveRateLimitStoreName(): UnitEnum|string|null
}
/**
- * Determine if request rate limits should be awaited.
+ * Resolve the identity whose limits and cooldowns the request uses.
*
* @internal
*/
- public function shouldWaitForRateLimits(): bool
+ public function rateLimiterName(): string
+ {
+ return static::class;
+ }
+
+ /**
+ * Determine if a denied request rate limit should be awaited.
+ *
+ * @internal
+ */
+ public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
return false;
}
@@ -200,7 +212,7 @@ public function shouldWaitForRateLimits(): bool
*/
public function resolveRateLimitCooldownKeyFor(PendingRequest $pendingRequest): string
{
- return static::class;
+ return '';
}
/**
diff --git a/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php b/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php
index 0475980bc6..d99ecbbd1f 100644
--- a/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php
+++ b/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php
@@ -15,12 +15,14 @@ class RateLimitReachedException extends SaloonException
* Create a rate limit exception.
*/
public function __construct(
+ string $limiterName,
protected readonly AdmissionPolicy|Cooldown $policy,
protected readonly Decision $result,
) {
- parent::__construct($policy->key === ''
- ? 'The request rate limit has been reached.'
- : sprintf('The request rate limit [%s] has been reached.', $policy->key));
+ parent::__construct(sprintf(
+ 'The request rate limit [%s] has been reached.',
+ $policy->key === '' ? $limiterName : $limiterName . ':' . $policy->key,
+ ));
}
/**
diff --git a/src/saloon/src/RateLimit/Traits/HasRateLimits.php b/src/saloon/src/RateLimit/Traits/HasRateLimits.php
index 930566cf2a..f2c5d3268d 100644
--- a/src/saloon/src/RateLimit/Traits/HasRateLimits.php
+++ b/src/saloon/src/RateLimit/Traits/HasRateLimits.php
@@ -6,6 +6,8 @@
use Carbon\Exceptions\InvalidFormatException;
use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
+use Hypervel\RateLimiter\Cooldown;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Response;
use Hypervel\Support\CarbonImmutable;
@@ -14,13 +16,13 @@
trait HasRateLimits
{
/**
- * Determine if this resource defines rate limits.
+ * Determine if this resource's rate limits apply to an operation.
*
* @internal
*/
- final public function usesRateLimits(): bool
+ final public function usesRateLimits(PendingRequest $pendingRequest): bool
{
- return true;
+ return $this->rateLimitingEnabled($pendingRequest);
}
/**
@@ -45,13 +47,23 @@ final public function resolveRateLimitStoreName(): UnitEnum|string|null
}
/**
- * Determine if denied rate limits should be awaited.
+ * Resolve the identity whose limits and cooldowns this resource uses.
+ *
+ * @internal
+ */
+ final public function rateLimiterName(): string
+ {
+ return $this->resolveRateLimiterName();
+ }
+
+ /**
+ * Determine if a denied rate limit should be awaited.
*
* @internal
*/
- final public function shouldWaitForRateLimits(): bool
+ final public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
- return $this->waitForRateLimits();
+ return $this->waitForRateLimits($policy, $result);
}
/**
@@ -74,6 +86,14 @@ final public function resolveRateLimitCooldownFor(Response $response): ?int
return $this->resolveRateLimitCooldown($response);
}
+ /**
+ * Determine if this resource's rate limits apply to an operation.
+ */
+ protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool
+ {
+ return true;
+ }
+
/**
* Resolve the admission policies for an operation.
*
@@ -90,9 +110,20 @@ protected function resolveRateLimitStore(): UnitEnum|string|null
}
/**
- * Determine if denied rate limits should be awaited.
+ * Resolve the identity whose limits and cooldowns this resource uses.
+ *
+ * Resources that return the same name share limits and cooldowns within
+ * the same store, key scope and policy identity.
+ */
+ protected function resolveRateLimiterName(): string
+ {
+ return static::class;
+ }
+
+ /**
+ * Determine if a denied rate limit should be awaited.
*/
- protected function waitForRateLimits(): bool
+ protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
return false;
}
@@ -102,7 +133,7 @@ protected function waitForRateLimits(): bool
*/
protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string
{
- return static::class;
+ return '';
}
/**
@@ -117,8 +148,20 @@ protected function resolveRateLimitCooldown(Response $response): ?int
return null;
}
- $retryAfter = trim($response->header('Retry-After'));
+ // A 429 without a usable delay still blocks for upstream's 60-second default. A valid delay that has
+ // already passed blocks nothing.
+ $seconds = $this->parseRateLimitRetryAfter(trim($response->header('Retry-After'))) ?? 60;
+
+ return $seconds > 0 ? $seconds : null;
+ }
+ /**
+ * Parse a Retry-After value into seconds.
+ *
+ * Return null when the value is missing, malformed or too long for the rate limiter.
+ */
+ private function parseRateLimitRetryAfter(string $retryAfter): ?int
+ {
if ($retryAfter === '') {
return null;
}
@@ -224,12 +267,10 @@ private function parseRateLimitRetryAfterHttpDate(string $format, string $timest
}
/**
- * Return a positive cooldown that the rate limiter can represent.
+ * Return a delay the rate limiter can represent, or null when it is too long.
*/
private function checkedRateLimitCooldownSeconds(int $seconds): ?int
{
- return $seconds > 0 && $seconds <= intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000)
- ? $seconds
- : null;
+ return $seconds <= intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) ? $seconds : null;
}
}
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index c0fd86b736..e9c8961dba 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -14,7 +14,9 @@
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\RateLimiter\Contracts\Decision;
use Hypervel\RateLimiter\Cooldown;
+use Hypervel\RateLimiter\CooldownResult;
use Hypervel\RateLimiter\Limiter;
+use Hypervel\RateLimiter\LimitResult;
use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Cache\CacheKey;
use Hypervel\Saloon\Cache\Data\CachedResponse;
@@ -302,6 +304,16 @@ public function rateLimiter(): RateLimiter
return $this->rateLimiter;
}
+ /**
+ * Inspect a connector or request rate limit without consuming it.
+ *
+ * @return ($policy is Cooldown ? CooldownResult : LimitResult)
+ */
+ public function inspectRateLimit(Connector|Request $resource, AdmissionPolicy|Cooldown $policy): LimitResult|CooldownResult
+ {
+ return $this->rateLimiterFor($resource)->inspect($policy, $this->limiterNameFor($resource));
+ }
+
/**
* Set the event dispatcher.
*
@@ -558,7 +570,7 @@ protected function enforceRateLimits(PendingRequest $pendingRequest): void
*/
protected function enforceRateLimitsFor(PendingRequest $pendingRequest, Connector|Request $resource): void
{
- if (! $resource->usesRateLimits()) {
+ if (! $resource->usesRateLimits($pendingRequest)) {
return;
}
@@ -580,7 +592,7 @@ protected function enforceRateLimitsFor(PendingRequest $pendingRequest, Connecto
}
$limiter = $this->rateLimiterFor($resource);
- $limiterName = 'saloon:' . $resource::class;
+ $limiterName = $this->limiterNameFor($resource);
$cooldown = Cooldown::for($resource->resolveRateLimitCooldownKeyFor($pendingRequest));
while (true) {
@@ -620,7 +632,7 @@ protected function recordRateLimitCooldownFor(
Response $response,
Connector|Request $resource,
): void {
- if (! $resource->usesRateLimits()
+ if (! $resource->usesRateLimits($pendingRequest)
|| ($seconds = $resource->resolveRateLimitCooldownFor($response)) === null) {
return;
}
@@ -628,10 +640,18 @@ protected function recordRateLimitCooldownFor(
$this->rateLimiterFor($resource)->block(
Cooldown::for($resource->resolveRateLimitCooldownKeyFor($pendingRequest)),
$seconds,
- 'saloon:' . $resource::class,
+ $this->limiterNameFor($resource),
);
}
+ /**
+ * Resolve the limiter name used for a resource's limits and cooldowns.
+ */
+ protected function limiterNameFor(Connector|Request $resource): string
+ {
+ return 'saloon:' . $resource->rateLimiterName();
+ }
+
/**
* Resolve the framework limiter selected by a resource.
*/
@@ -653,8 +673,8 @@ protected function waitForRateLimit(
AdmissionPolicy|Cooldown $policy,
Decision $result,
): void {
- if (! $resource->shouldWaitForRateLimits()) {
- throw new RateLimitReachedException($policy, $result);
+ if (! $resource->shouldWaitForRateLimits($policy, $result)) {
+ throw new RateLimitReachedException($this->limiterNameFor($resource), $policy, $result);
}
Sleep::sleep($result->retryAfter());
diff --git a/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
new file mode 100644
index 0000000000..9f1bff79b4
--- /dev/null
+++ b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
@@ -0,0 +1,636 @@
+make('config')->set('saloon.rate_limiter.store', 'worker-array');
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Http::preventStrayRequests();
+ CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC');
+ }
+
+ public function testWhenMakingARequestWithTheHasRateLimitsTraitAddedItWillRecordTheHitsAndThrowExceptions(): void
+ {
+ $limit = Limit::perMinute(3);
+ $connector = new TestConnector([$limit]);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ // Let's start by making sure nothing has been recorded
+
+ $this->assertSame(3, Saloon::inspectRateLimit($connector, $limit)->remaining());
+
+ // We'll now send four requests. The first three will be fine, the fourth should throw an exception
+
+ $responseA = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseA->status());
+
+ $result = Saloon::inspectRateLimit($connector, $limit);
+ $this->assertSame(2, $result->remaining());
+ $this->assertSame(60, $result->resetAfter());
+
+ // A successful response records no "too many attempts" cooldown
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed());
+
+ $responseB = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining());
+
+ $responseC = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseC->status());
+ $this->assertSame(0, Saloon::inspectRateLimit($connector, $limit)->remaining());
+ $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed());
+
+ // Finally, when we make the fourth request it should throw an exception
+
+ $thrown = false;
+
+ try {
+ $connector->send(new UserRequest);
+ } catch (RateLimitReachedException $exception) {
+ $thrown = true;
+
+ $this->assertSame(
+ sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class),
+ $exception->getMessage(),
+ );
+ $this->assertSame($limit, $exception->policy());
+ $this->assertSame(0, $exception->result()->remaining());
+ $this->assertSame(60, $exception->result()->retryAfter());
+ }
+
+ $this->assertTrue($thrown);
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $limit)->denied());
+ Http::assertSentCount(3);
+ }
+
+ public function testWhenMakingARequestWithTheHasRateLimitsTraitAddedItWillRecordTheHitsAndCanSleep(): void
+ {
+ Sleep::fake(syncWithCarbon: true);
+
+ $limit = Limit::perSecond(3, 5);
+ $connector = new TestConnector([$limit], waitFor: [$limit]);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ // We'll now send four requests. The first three will be fine, the fourth should wait
+
+ $responseA = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseA->status());
+
+ $result = Saloon::inspectRateLimit($connector, $limit);
+ $this->assertSame(2, $result->remaining());
+ $this->assertSame(5, $result->resetAfter());
+
+ $responseB = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseB->status());
+ $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining());
+
+ $responseC = $connector->send(new UserRequest);
+ $this->assertSame(200, $responseC->status());
+ $this->assertSame(0, Saloon::inspectRateLimit($connector, $limit)->remaining());
+
+ // Now when we make this request, it should pause for 5 seconds and then start a new window
+
+ $responseD = $connector->send(new UserRequest);
+
+ $this->assertSame(200, $responseD->status());
+ Sleep::assertSequence([Sleep::for(5)->seconds()]);
+ $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining());
+ Http::assertSentCount(4);
+ }
+
+ // The 429 response is returned and its cooldown is recorded, so the next request throws instead of the 429 itself.
+ public function testYouCanCreateALimiterThatListensFor429AndWillAutomaticallyBackOffForTheRetryAfterDuration(): void
+ {
+ $limit = Limit::perSecond(3, 5);
+ $connector = new TestConnector([$limit], waitFor: [$limit]);
+ $cooldown = Cooldown::for(null);
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed());
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam', 'status' => 'Success'])
+ ->push(['name' => 'Gareth', 'status' => 'Success'])
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '500'])]);
+
+ $connector->send(new UserRequest);
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed());
+
+ $connector->send(new UserRequest);
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed());
+ $this->assertSame(429, $connector->send(new UserRequest)->status());
+
+ $thrown = false;
+
+ try {
+ $connector->send(new UserRequest);
+ } catch (RateLimitReachedException $exception) {
+ $thrown = true;
+
+ $this->assertSame(
+ sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class),
+ $exception->getMessage(),
+ );
+ $this->assertInstanceOf(Cooldown::class, $exception->policy());
+ $this->assertSame(500, $exception->result()->retryAfter());
+ }
+
+ $this->assertTrue($thrown);
+ Http::assertSentCount(3);
+ }
+
+ #[DataProvider('unusableRetryAfterProvider')]
+ public function testIfTheRetryAfterHeaderIsMissingOrCannotBeParsedThenTheDefaultRetryIs60Seconds(?string $retryAfter): void
+ {
+ $limit = Limit::perSecond(3, 5);
+ $connector = new TestConnector([$limit], waitFor: [$limit]);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam', 'status' => 'Success'])
+ ->push(['name' => 'Gareth', 'status' => 'Success'])
+ ->push(['status' => 'Too Many Requests'], 429, $retryAfter === null ? [] : ['Retry-After' => $retryAfter])]);
+
+ $connector->send(new UserRequest);
+ $connector->send(new UserRequest);
+ $connector->send(new UserRequest);
+
+ $thrown = false;
+
+ try {
+ $connector->send(new UserRequest);
+ } catch (RateLimitReachedException $exception) {
+ $thrown = true;
+
+ $this->assertSame(
+ sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class),
+ $exception->getMessage(),
+ );
+ $this->assertInstanceOf(Cooldown::class, $exception->policy());
+ $this->assertSame(60, $exception->result()->retryAfter());
+ }
+
+ $this->assertTrue($thrown);
+ }
+
+ /**
+ * Get Retry-After values that do not give a usable delay.
+ */
+ public static function unusableRetryAfterProvider(): array
+ {
+ return [
+ 'missing' => [null],
+ 'malformed' => ['not-working'],
+ 'not an HTTP date' => ['01/01/2023'],
+ 'too long for the rate limiter' => [str_repeat('9', 30)],
+ ];
+ }
+
+ #[DataProvider('elapsedRetryAfterProvider')]
+ public function testAValidRetryAfterThatHasAlreadyPassedRecordsNoCooldown(string $retryAfter): void
+ {
+ $connector = new TestConnector([]);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => $retryAfter])
+ ->push(['name' => 'Sam'])]);
+
+ $this->assertSame(429, $connector->send(new UserRequest)->status());
+ $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed());
+ $this->assertSame(200, $connector->send(new UserRequest)->status());
+ }
+
+ /**
+ * Get valid Retry-After values whose delay has already passed.
+ */
+ public static function elapsedRetryAfterProvider(): array
+ {
+ return [
+ 'zero seconds' => ['0'],
+ 'past date' => ['Wed, 12 Aug 2026 12:00:00 GMT'],
+ ];
+ }
+
+ // No admission policies are declared, so only the response-driven cooldown can deny the fourth request.
+ public function testYouCanCustomiseWhenTheTooManyRequestsLimitIsApplied(): void
+ {
+ $connector = new CustomTooManyRequestsConnector([]);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam'])
+ ->push(['name' => 'Sam'])
+ ->push(['error' => 'Too Many Attempts'])]);
+
+ $connector->send(new UserRequest);
+ $connector->send(new UserRequest);
+
+ $this->assertSame(['error' => 'Too Many Attempts'], $connector->send(new UserRequest)->json());
+
+ try {
+ $connector->send(new UserRequest);
+ $this->fail('The custom cooldown was not applied.');
+ } catch (RateLimitReachedException $exception) {
+ $this->assertSame(
+ sprintf('The request rate limit [saloon:%s] has been reached.', CustomTooManyRequestsConnector::class),
+ $exception->getMessage(),
+ );
+ $this->assertInstanceOf(Cooldown::class, $exception->policy());
+ $this->assertSame(60, $exception->result()->retryAfter());
+ }
+
+ Http::assertSentCount(3);
+ }
+
+ public function testYouCanDisableThe429ErrorDetection(): void
+ {
+ $connector = new DisabledTooManyRequestsConnector([]);
+ $cooldown = Cooldown::for(null);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam'])
+ ->push(['name' => 'Sam'])
+ ->push(['status' => 'Too Many Requests'], 429)
+ ->push(['name' => 'Sam'])]);
+
+ $connector->send(new UserRequest);
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed());
+
+ $connector->send(new UserRequest);
+ $connector->send(new UserRequest);
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed());
+ $this->assertSame(200, $connector->send(new UserRequest)->status());
+ }
+
+ public function testYouCanCustomiseThe429ErrorDetectionToSleepInstead(): void
+ {
+ Sleep::fake(syncWithCarbon: true);
+
+ $connector = new SleepTooManyRequestsConnector([]);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam'])
+ ->push(['status' => 'Too Many Requests'], 429)
+ ->push(['name' => 'Jon'])]);
+
+ $connector->send(new UserRequest);
+
+ $response = $connector->send(new UserRequest);
+
+ $this->assertSame(['name' => 'Jon'], $response->json());
+ Sleep::assertSequence([Sleep::for(5)->seconds()]);
+ Http::assertSentCount(3);
+ }
+
+ public function testTheRateLimiterCanBeUsedOnARequest(): void
+ {
+ $connector = new BaseConnector;
+ $request = new LimitedRequest;
+ $limit = Limit::perMinute(60);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ // Let's start by making sure nothing has been recorded
+
+ $this->assertSame(60, Saloon::inspectRateLimit($request, $limit)->remaining());
+
+ $connector->send($request);
+
+ $result = Saloon::inspectRateLimit($request, $limit);
+ $this->assertSame(59, $result->remaining());
+ $this->assertSame(60, $result->resetAfter());
+ $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed());
+ }
+
+ public function testWhenTheTheRateLimiterIsUsedOnBothTheConnectorOrRequestAllTheLimitsAreUsed(): void
+ {
+ $connectorLimit = Limit::perMinute(3);
+ $requestLimit = Limit::perMinute(60);
+ $connector = new TestConnector([$connectorLimit]);
+ $request = new LimitedRequest;
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $connector->send($request);
+
+ $result = Saloon::inspectRateLimit($connector, $connectorLimit);
+ $this->assertSame(2, $result->remaining());
+ $this->assertSame(60, $result->resetAfter());
+
+ $result = Saloon::inspectRateLimit($request, $requestLimit);
+ $this->assertSame(59, $result->remaining());
+ $this->assertSame(60, $result->resetAfter());
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed());
+ $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed());
+ }
+
+ public function testTheRateLimiterCanBeUsedOnASoloRequest(): void
+ {
+ $request = new LimitedSoloRequest;
+ $limit = Limit::perMinute(60);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $this->assertSame(60, Saloon::inspectRateLimit($request, $limit)->remaining());
+
+ $request->send();
+
+ $result = Saloon::inspectRateLimit($request, $limit);
+ $this->assertSame(59, $result->remaining());
+ $this->assertSame(60, $result->resetAfter());
+ $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed());
+ }
+
+ // REMOVED: you can specify a custom closure to determine the limiter based on response - Limit::custom() is not
+ // included. Response-driven limits override resolveRateLimitCooldown(), as
+ // testYouCanCustomiseWhenTheTooManyRequestsLimitIsApplied covers.
+
+ public function testIfAConnectorHasTheAlwaysThrowOnErrorTraitThenTheLimiterWillTakePriority(): void
+ {
+ $limit = Limit::perMinute(3);
+ $connector = new ThrowConnector([$limit]);
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['name' => 'Sam'], 500)
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '10'])]);
+
+ $thrown = false;
+
+ try {
+ $connector->send(new UserRequest);
+ } catch (InternalServerErrorException) {
+ $thrown = true;
+ }
+
+ $this->assertTrue($thrown);
+
+ // The limit was charged before the request was sent, and a 429's cooldown is recorded before the response
+ // throws.
+
+ $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining());
+
+ $thrown = false;
+
+ try {
+ $connector->send(new UserRequest);
+ } catch (TooManyRequestsException) {
+ $thrown = true;
+ }
+
+ $this->assertTrue($thrown);
+ $this->assertSame(10, Saloon::inspectRateLimit($connector, Cooldown::for(null))->retryAfter());
+ }
+
+ public function testTheLimitIsGivenTheCorrectPrefixEvenWithCustomNames(): void
+ {
+ $limit = Limit::perMinute(3);
+ $namedLimit = Limit::perMinute(3)->by('custom_name');
+ $connector = new TestConnector([$limit, $namedLimit]);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $connector->send(new UserRequest);
+
+ // Both limits are recorded under the connector's limiter name, so another name does not see them
+
+ $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining());
+ $this->assertSame(2, Saloon::inspectRateLimit($connector, $namedLimit)->remaining());
+ $this->assertSame(3, Saloon::inspectRateLimit(new CustomPrefixConnector([]), $limit)->remaining());
+ $this->assertSame(3, Saloon::inspectRateLimit(new CustomPrefixConnector([]), $namedLimit)->remaining());
+ }
+
+ public function testYouCanCustomiseThePrefix(): void
+ {
+ $limit = Limit::perMinute(3);
+ $namedLimit = Limit::perMinute(3)->by('custom_name');
+ $connector = new CustomPrefixConnector([$limit, $namedLimit]);
+ $sharingConnector = new class([$limit]) extends TestConnector {
+ /**
+ * Resolve the limiter name.
+ */
+ protected function resolveRateLimiterName(): string
+ {
+ return 'custom';
+ }
+ };
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $connector->send(new UserRequest);
+ $sharingConnector->send(new UserRequest);
+
+ // Connectors with the same limiter name share the state of the same policy
+
+ $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining());
+ $this->assertSame(2, Saloon::inspectRateLimit($connector, $namedLimit)->remaining());
+ $this->assertSame(3, Saloon::inspectRateLimit(new TestConnector([]), $limit)->remaining());
+ }
+
+ public function testResourcesWithTheSameLimiterNameShareCooldownsUnlessTheirCooldownKeysDiffer(): void
+ {
+ $connector = new CustomPrefixConnector([]);
+ $sharingConnector = new class([]) extends TestConnector {
+ /**
+ * Resolve the limiter name.
+ */
+ protected function resolveRateLimiterName(): string
+ {
+ return 'custom';
+ }
+ };
+ $otherAccountConnector = new class([]) extends CustomPrefixConnector {
+ /**
+ * Resolve the cooldown key.
+ */
+ protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string
+ {
+ return 'account:other';
+ }
+ };
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '30'])
+ ->push(['name' => 'Sam'])]);
+
+ $this->assertSame(429, $connector->send(new UserRequest)->status());
+
+ try {
+ $sharingConnector->send(new UserRequest);
+ $this->fail('A shared cooldown reached transport.');
+ } catch (RateLimitReachedException $exception) {
+ $this->assertSame(30, $exception->result()->retryAfter());
+ }
+
+ $this->assertSame(200, $otherAccountConnector->send(new UserRequest)->status());
+ Http::assertSentCount(2);
+ }
+
+ // Connectors are read-only, so an operation-aware rateLimitingEnabled() replaces upstream's useRateLimitPlugin().
+ public function testYouCanProgrammaticallyDisableTheRateLimiting(): void
+ {
+ $limit = Limit::perMinute(3);
+ $connector = new BaseConnector;
+
+ Http::fake(['*' => Http::sequence()
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '30'])
+ ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '90'])
+ ->push(['name' => 'Sam'])]);
+
+ $this->assertSame(429, $connector->send(new OptionallyLimitedRequest($limit))->status());
+ $this->assertSame(2, Saloon::inspectRateLimit(new OptionallyLimitedRequest($limit), $limit)->remaining());
+
+ // A disabled operation ignores the active cooldown and its policies, and records no cooldown of its own
+
+ $this->assertSame(429, $connector->send(new OptionallyLimitedRequest($limit, limited: false))->status());
+ $this->assertSame(200, $connector->send(new OptionallyLimitedRequest($limit, limited: false))->status());
+ $this->assertSame(2, Saloon::inspectRateLimit(new OptionallyLimitedRequest($limit), $limit)->remaining());
+
+ try {
+ $connector->send(new OptionallyLimitedRequest($limit));
+ $this->fail('The enabled request ignored the recorded cooldown.');
+ } catch (RateLimitReachedException $exception) {
+ $this->assertSame(30, $exception->result()->retryAfter());
+ }
+
+ Http::assertSentCount(3);
+ }
+
+ // REMOVED: it will fail if you dont configure a limiter properly - a limit without a window is rejected when it is
+ // created. See RateLimiter/LimitTest::testInvalidScalarValuesAreRejected.
+
+ public function testAResourceCanWaitForOneLimitAndThrowForAnother(): void
+ {
+ Sleep::fake(syncWithCarbon: true);
+
+ $burstLimit = Limit::perSecond(1);
+ $dailyLimit = Limit::perDay(2)->by('daily');
+ $connector = new TestConnector([$burstLimit, $dailyLimit], waitFor: [$burstLimit]);
+
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $connector->send(new UserRequest);
+ $connector->send(new UserRequest);
+
+ try {
+ $connector->send(new UserRequest);
+ $this->fail('The daily limit was awaited.');
+ } catch (RateLimitReachedException $exception) {
+ $this->assertSame(
+ sprintf('The request rate limit [saloon:%s:daily] has been reached.', TestConnector::class),
+ $exception->getMessage(),
+ );
+ $this->assertSame($dailyLimit, $exception->policy());
+ }
+
+ Sleep::assertSequence([Sleep::for(1)->seconds(), Sleep::for(1)->seconds()]);
+ Http::assertSentCount(2);
+ }
+}
+
+class ThrowConnector extends TestConnector
+{
+ use AlwaysThrowOnErrors;
+}
+
+class OptionallyLimitedRequest extends Request
+{
+ use HasRateLimits;
+
+ /**
+ * Define the HTTP method.
+ */
+ protected Method $method = Method::GET;
+
+ /**
+ * Create a request whose limit applies unless it is disabled.
+ */
+ public function __construct(
+ protected Limit $limit,
+ protected bool $limited = true,
+ ) {
+ }
+
+ /**
+ * Define the endpoint for the request.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/user';
+ }
+
+ /**
+ * Determine if the request's rate limits apply to an operation.
+ */
+ protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool
+ {
+ return $this->limited;
+ }
+
+ /**
+ * Resolve the limits.
+ */
+ protected function resolveRateLimits(PendingRequest $pendingRequest): array
+ {
+ return [$this->limit];
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php
new file mode 100644
index 0000000000..15c782d815
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php
@@ -0,0 +1,18 @@
+json('error') === 'Too Many Attempts' ? 60 : null;
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php
new file mode 100644
index 0000000000..d7dbc55b0b
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php
@@ -0,0 +1,50 @@
+destructed = true;
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php
new file mode 100644
index 0000000000..bed14f65b4
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php
@@ -0,0 +1,19 @@
+ $exception instanceof RequestException
+ && $exception->status() === 429,
+ );
+ }
+
+ /**
+ * Resolve the cooldown imposed by a response.
+ */
+ protected function resolveRateLimitCooldown(Response $response): ?int
+ {
+ return $response->status() === 429 ? 5 : null;
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php
new file mode 100644
index 0000000000..724327f646
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php
@@ -0,0 +1,46 @@
+ $limits
+ * @param list $waitFor
+ */
+ public function __construct(
+ protected array $limits,
+ protected array $waitFor = [],
+ ) {
+ }
+
+ /**
+ * Resolve the limits.
+ */
+ protected function resolveRateLimits(PendingRequest $pendingRequest): array
+ {
+ return $this->limits;
+ }
+
+ /**
+ * Determine if a denied rate limit should be awaited.
+ */
+ protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
+ {
+ return in_array($policy, $this->waitFor, true);
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php b/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php
new file mode 100644
index 0000000000..fd05c75581
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php
@@ -0,0 +1,47 @@
+
+ */
+ public function middleware(): array
+ {
+ return [new ReleaseOnRateLimit];
+ }
+
+ /**
+ * Create a job that sends through the given connector.
+ */
+ public function __construct(protected TestConnector $connector)
+ {
+ }
+
+ /**
+ * Execute the job.
+ */
+ public function handle(): void
+ {
+ $this->connector->send(new UserRequest);
+ }
+}
diff --git a/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php b/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php
new file mode 100644
index 0000000000..7d4fde9715
--- /dev/null
+++ b/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php
@@ -0,0 +1,39 @@
+make('config')->set('saloon.rate_limiter.store', 'worker-array');
+ }
+
+ public function testTheApiRateLimitedJobWillReleaseAJobOntoTheQueueIfALimitIsReached(): void
+ {
+ $limit = Limit::perMinute(3);
+ $connector = new TestConnector([$limit]);
+
+ Http::preventStrayRequests();
+ Http::fake(['*' => Http::response(['name' => 'Sam'])]);
+
+ $jobs = [];
+
+ Queue::after(static function (JobProcessed $event) use (&$jobs): void {
+ $jobs[] = $event->job;
+ });
+
+ ApiRateLimitedJob::dispatchSync($connector);
+ ApiRateLimitedJob::dispatchSync($connector);
+ ApiRateLimitedJob::dispatchSync($connector);
+ ApiRateLimitedJob::dispatchSync($connector);
+
+ $this->assertCount(4, $jobs);
+ $this->assertFalse($jobs[0]->isReleased());
+ $this->assertFalse($jobs[1]->isReleased());
+ $this->assertFalse($jobs[2]->isReleased());
+ $this->assertTrue($jobs[3]->isReleased());
+
+ $this->assertTrue(Saloon::inspectRateLimit($connector, $limit)->denied());
+ Http::assertSentCount(3);
+ }
+
+ public function testSuccessfulJobsPassThroughAndReturnTheirResult(): void
+ {
+ $job = new RateLimitedJobStub;
+
+ $result = (new ReleaseOnRateLimit)->handle(
+ $job,
+ fn (RateLimitedJobStub $handledJob): string => $handledJob === $job ? 'completed' : 'wrong job',
+ );
+
+ $this->assertSame('completed', $result);
+ $this->assertNull($job->releasedAfter);
+ }
+
+ public function testDeniedRequestsReleaseTheJobForTheDecisionDelay(): void
+ {
+ $job = new RateLimitedJobStub;
+ $exception = new RateLimitReachedException(
+ 'saloon:provider',
+ Limit::perMinute(1)->by('provider'),
+ new LimitResult(false, 1, 0, 2_500_000, 2_500_000),
+ );
+
+ $result = (new ReleaseOnRateLimit)->handle($job, static fn (): never => throw $exception);
+
+ $this->assertNull($result);
+ $this->assertSame(3, $job->releasedAfter);
+ }
+
+ public function testOtherExceptionsPropagate(): void
+ {
+ $exception = new RuntimeException('Job failed.');
+
+ $this->expectExceptionObject($exception);
+
+ (new ReleaseOnRateLimit)->handle(new RateLimitedJobStub, static fn (): never => throw $exception);
+ }
+}
+
+class RateLimitedJobStub
+{
+ public ?int $releasedAfter = null;
+
+ /**
+ * Release the job back onto the queue.
+ */
+ public function release(int $delay = 0): void
+ {
+ $this->releasedAfter = $delay;
+ }
+}
diff --git a/tests/Saloon/RateLimit/RateLimitTest.php b/tests/Saloon/RateLimit/RateLimitTest.php
index 94bafd2ec9..79a297d731 100644
--- a/tests/Saloon/RateLimit/RateLimitTest.php
+++ b/tests/Saloon/RateLimit/RateLimitTest.php
@@ -4,7 +4,6 @@
namespace Hypervel\Tests\Saloon\RateLimit;
-use Carbon\CarbonInterval;
use DateInterval;
use DateTimeInterface;
use Hypervel\Cache\ArrayStore;
@@ -14,6 +13,7 @@
use Hypervel\Events\Dispatcher;
use Hypervel\Http\Client\Factory;
use Hypervel\RateLimiter\AdmissionPolicy;
+use Hypervel\RateLimiter\Contracts\Decision;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\KeyResolver;
use Hypervel\RateLimiter\Limit;
@@ -38,50 +38,11 @@
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use Mockery as m;
-use RuntimeException;
+// Feature/HasRateLimitsTest covers consuming connector and request policies, denials before transport, cooldowns
+// recorded before response middleware and waiting.
class RateLimitTest extends TestCase
{
- public function testConnectorAndRequestPoliciesAreConsumedInOrder(): void
- {
- [$manager, $limiter, $http] = $this->manager();
- $http->fake(['*' => Factory::response(['ok' => true])]);
- $connector = new RateLimitedConnectorStub($manager);
- $request = new RateLimitedRequestStub;
-
- $response = $connector->send($request);
-
- $this->assertTrue($response->successful());
- $this->assertSame(1, $limiter->inspect(
- $connector->policy(),
- 'saloon:' . $connector::class,
- )->remaining());
- $this->assertSame(1, $limiter->inspect(
- $request->policy(),
- 'saloon:' . $request::class,
- )->remaining());
- }
-
- public function testDeniedPolicyThrowsItsDecisionBeforeTransport(): void
- {
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => Factory::response(['ok' => true])]);
- $connector = new RateLimitedConnectorStub($manager, maximumAttempts: 1);
-
- $connector->send(new PlainRateLimitRequestStub);
-
- try {
- $connector->send(new PlainRateLimitRequestStub);
- $this->fail('A denied rate limit reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertInstanceOf(Limit::class, $exception->policy());
- $this->assertSame('connector', $exception->policy()->key);
- $this->assertTrue($exception->result()->denied());
- }
-
- $http->assertSentCount(1);
- }
-
public function testFakesAndCacheHitsDoNotConsumeAdmissionCapacity(): void
{
[$manager, $limiter, $http] = $this->manager();
@@ -158,40 +119,16 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
$http->assertNothingSent();
}
- public function testServerCooldownIsRecordedBeforeResponseMiddleware(): void
- {
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => '10'])
- ->push(['unexpected' => true])]);
- $connector = new PlainRateLimitConnectorStub($manager);
-
- try {
- $connector->send(new ThrowingCooldownRequestStub);
- $this->fail('The response middleware did not throw.');
- } catch (RuntimeException $exception) {
- $this->assertSame('response middleware failed', $exception->getMessage());
- }
-
- try {
- $connector->send(new ThrowingCooldownRequestStub);
- $this->fail('A recorded cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame(10, $exception->result()->retryAfter());
- }
-
- $http->assertSentCount(1);
- }
-
- public function testRetryAfterHttpDatesAreParsedAndInvalidValuesAreIgnored(): void
+ public function testRetryAfterHttpDatesAreParsed(): void
{
$now = CarbonImmutable::parse('2026-08-13 12:00:00 UTC');
CarbonImmutable::setTestNow($now);
[$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => $now->addSeconds(5)->toRfc7231String()])
- ->push([], 429, ['Retry-After' => 'invalid'])
- ->push(['ok' => true])]);
+ $http->fake(['*' => Factory::response(
+ body: [],
+ status: 429,
+ headers: ['Retry-After' => $now->addSeconds(5)->toRfc7231String()],
+ )]);
$connector = new PlainRateLimitConnectorStub($manager);
$dated = new DateCooldownRequestStub;
@@ -204,10 +141,7 @@ public function testRetryAfterHttpDatesAreParsedAndInvalidValuesAreIgnored(): vo
$this->assertSame(5, $exception->result()->retryAfter());
}
- $invalid = new InvalidCooldownRequestStub;
- $this->assertSame(429, $connector->send($invalid)->status());
- $this->assertTrue($connector->send($invalid)->successful());
- $http->assertSentCount(3);
+ $http->assertSentCount(1);
}
public function testObsoleteHttpDatesAndCheckedNumericDurationsAreParsed(): void
@@ -219,9 +153,7 @@ public function testObsoleteHttpDatesAndCheckedNumericDurationsAreParsed(): void
->push([], 429, ['Retry-After' => $now->addSeconds(5)->format('l, d-M-y H:i:s \G\M\T')])
->push([], 429, ['Retry-After' => 'Thu Aug 6 12:00:06 2026'])
->push([], 429, ['Retry-After' => 'Fri, 06 Aug 2026 12:00:07 GMT'])
- ->push([], 429, ['Retry-After' => '0008'])
- ->push([], 429, ['Retry-After' => str_repeat('9', 30)])
- ->push(['ok' => true])]);
+ ->push([], 429, ['Retry-After' => '0008'])]);
$connector = new PlainRateLimitConnectorStub($manager);
$assertCooldown = function (Request $request, int $seconds) use ($connector): void {
$connector->send($request);
@@ -238,9 +170,7 @@ public function testObsoleteHttpDatesAndCheckedNumericDurationsAreParsed(): void
$assertCooldown(new AsctimeCooldownRequestStub, 6);
$assertCooldown(new MismatchedWeekdayCooldownRequestStub, 7);
$assertCooldown(new NumericCooldownRequestStub, 8);
- $this->assertSame(429, $connector->send(new OversizedCooldownRequestStub)->status());
- $this->assertTrue($connector->send(new OversizedCooldownRequestStub)->successful());
- $http->assertSentCount(6);
+ $http->assertSentCount(4);
}
public function testTwoDigitAsctimeDayIsParsed(): void
@@ -276,9 +206,7 @@ public function testHttpDateProtocolRulesAndInvalidCalendarValuesAreHandled(): v
->push([], 429, ['Retry-After' => 'Sunday, 06-Nov-70 12:00:00 GMT'])
->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 12:00:60 GMT'])
->push([], 429, ['Retry-After' => 'Thu, 31 Feb 2026 12:00:09 GMT'])
- ->push(['ok' => true])
- ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 25:00:09 GMT'])
- ->push(['ok' => true])]);
+ ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 25:00:09 GMT'])]);
$connector = new PlainRateLimitConnectorStub($manager);
$assertCooldown = function (Request $request, int $seconds) use ($connector): void {
$connector->send($request);
@@ -297,14 +225,10 @@ public function testHttpDateProtocolRulesAndInvalidCalendarValuesAreHandled(): v
);
$assertCooldown(new LeapSecondCooldownRequestStub, 60);
- $invalid = new InvalidCalendarCooldownRequestStub;
- $this->assertSame(429, $connector->send($invalid)->status());
- $this->assertTrue($connector->send($invalid)->successful());
-
- $invalid = new InvalidTimeCooldownRequestStub;
- $this->assertSame(429, $connector->send($invalid)->status());
- $this->assertTrue($connector->send($invalid)->successful());
- $http->assertSentCount(6);
+ // Out-of-range fields are rejected instead of normalized into another date, so the default cooldown applies.
+ $assertCooldown(new InvalidCalendarCooldownRequestStub, 60);
+ $assertCooldown(new InvalidTimeCooldownRequestStub, 60);
+ $http->assertSentCount(4);
}
public function testRfc850YearResolutionUsesGmtAtACenturyBoundary(): void
@@ -378,25 +302,6 @@ public function testRfc850YearResolutionUsesTheCompleteFiftyYearBoundary(): void
$http->assertSentCount(5);
}
- public function testWaitingUsesTheStoreDelayAndThenContinues(): void
- {
- CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC');
- Sleep::fake(syncWithCarbon: true);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => '2'])
- ->push(['ok' => true])]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $request = new WaitingCooldownRequestStub;
-
- $connector->send($request);
- $response = $connector->send($request);
-
- $this->assertTrue($response->successful());
- Sleep::assertSlept(static fn (CarbonInterval $duration): bool => (float) $duration->totalSeconds === 2.0);
- $http->assertSentCount(2);
- }
-
public function testWaitingForAGroupRechecksCooldownBeforeChargingTheGroup(): void
{
CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC');
@@ -413,7 +318,7 @@ public function testWaitingForAGroupRechecksCooldownBeforeChargingTheGroup(): vo
$this->assertSame(10, $limiter->inspect($request->firstPolicy(), $limiterName)->remaining());
if (! $published) {
- $limiter->block(Cooldown::for($request::class), 5, $limiterName);
+ $limiter->block(Cooldown::for(null), 5, $limiterName);
$published = true;
}
});
@@ -541,27 +446,6 @@ public function resolveEndpoint(): string
}
}
-class RateLimitedRequestStub extends PlainRateLimitRequestStub
-{
- use HasRateLimits;
-
- /**
- * Get the request policy.
- */
- public function policy(): AdmissionPolicy
- {
- return Limit::perMinute(2)->by('request');
- }
-
- /**
- * Resolve the request rate limits.
- */
- protected function resolveRateLimits(PendingRequest $pendingRequest): array
- {
- return [$this->policy()];
- }
-}
-
class CachedRateLimitedRequestStub extends PlainRateLimitRequestStub implements Cacheable
{
use HasCaching;
@@ -591,29 +475,6 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
}
}
-class ThrowingCooldownRequestStub extends PlainRateLimitRequestStub
-{
- use HasRateLimits;
-
- /**
- * Register response middleware that throws.
- */
- public function boot(PendingRequest $pendingRequest): void
- {
- $pendingRequest->middleware()->onResponse(
- static fn (): never => throw new RuntimeException('response middleware failed'),
- );
- }
-
- /**
- * Resolve no rate limits.
- */
- protected function resolveRateLimits(PendingRequest $pendingRequest): array
- {
- return [];
- }
-}
-
class DateCooldownRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
@@ -627,10 +488,6 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
}
}
-class InvalidCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
class Rfc850CooldownRequestStub extends DateCooldownRequestStub
{
}
@@ -683,21 +540,6 @@ class NumericCooldownRequestStub extends DateCooldownRequestStub
{
}
-class OversizedCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class WaitingCooldownRequestStub extends DateCooldownRequestStub
-{
- /**
- * Wait for rate limits instead of throwing.
- */
- protected function waitForRateLimits(): bool
- {
- return true;
- }
-}
-
class CallbackRateLimitRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
@@ -766,7 +608,7 @@ class WaitingMultipleRateLimitRequestStub extends MultipleRateLimitRequestStub
/**
* Wait for rate limits instead of throwing.
*/
- protected function waitForRateLimits(): bool
+ protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool
{
return true;
}
diff --git a/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php b/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php
deleted file mode 100644
index f20aca5e91..0000000000
--- a/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php
+++ /dev/null
@@ -1,67 +0,0 @@
-handle(
- $job,
- fn (RateLimitedJobStub $handledJob): string => $handledJob === $job ? 'completed' : 'wrong job',
- );
-
- $this->assertSame('completed', $result);
- $this->assertNull($job->releasedAfter);
- }
-
- public function testDeniedRequestsReleaseTheJobForTheDecisionDelay(): void
- {
- $job = new RateLimitedJobStub;
- $exception = new RateLimitReachedException(
- Limit::perMinute(1)->by('provider'),
- new LimitResult(false, 1, 0, 2_500_000, 2_500_000),
- );
-
- $result = (new ReleaseOnRateLimit)->handle(
- $job,
- static fn () => throw $exception,
- );
-
- $this->assertNull($result);
- $this->assertSame(3, $job->releasedAfter);
- }
-
- public function testOtherExceptionsPropagate(): void
- {
- $exception = new RuntimeException('Job failed.');
-
- $this->expectExceptionObject($exception);
-
- (new ReleaseOnRateLimit)->handle(
- new RateLimitedJobStub,
- static fn () => throw $exception,
- );
- }
-}
-
-class RateLimitedJobStub
-{
- public ?int $releasedAfter = null;
-
- public function release(int $delay = 0): void
- {
- $this->releasedAfter = $delay;
- }
-}
diff --git a/tests/Saloon/RateLimit/Unit/DestructTest.php b/tests/Saloon/RateLimit/Unit/DestructTest.php
new file mode 100644
index 0000000000..6b52a66c5e
--- /dev/null
+++ b/tests/Saloon/RateLimit/Unit/DestructTest.php
@@ -0,0 +1,23 @@
+assertFalse($destructed);
+
+ unset($connector);
+
+ $this->assertTrue($destructed);
+ }
+}
From 3b4da51fe9c2e44aee2e8cf14c3cdda7f23aefd2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 03:06:43 +0000
Subject: [PATCH 15/26] Add calendar windows and map the rate-limit plugin's
limits and stores
The rate-limit plugin (saloonphp/rate-limit-plugin at aed5ff488e4d)
offers limits that reset on the clock: untilEndOfMinute(),
untilEndOfHour(), untilMidnightTonight(), everyDayUntil() and
untilEndOfMonth(). Hypervel's rate limiter only had windows measured
from the first operation, so a provider quota that resets at midnight
could not be matched.
CalendarWindow is a new admission policy that resets at the start of
each minute, hour, day (at an optional HH:MM[:SS] time) or month, in
the application's timezone or one given with timezone(). It follows
the local clock across daylight saving changes:
- Hourly windows are clipped at offset changes, so a repeated hour is
its own window and half-hour shifts (Lord Howe Island) do not overlap.
- A daily reset time that happens twice resets at its first occurrence,
resolved explicitly because PHP's parser picks different occurrences
in different zones. A skipped reset time moves forward that day.
Stores count a calendar window like a fixed window; new state expires
at the window's end. The Redis store passes the window's start and end
computed from Redis TIME, and the script rejects a stale window before
writing, so a PHP clock in another period cannot charge the wrong one.
A stale group discards its tentative charges and retries. Key identity
includes the period, reset time and timezone.
Upstream's Limit, Bucket, LimitStore and store tests map case by case
to the rate limiter's store contract and integration tests;
Unit/HelperTest is ported and absorbs RateLimitTest's Retry-After
parsing cases. There are no file, cache or Predis stores: a general
cache can't apply the policies atomically, and Redis uses PhpRedis.
Identical policies share state instead of throwing upstream's
duplicate-name exception. The README, rate-limiting guide and sync
note record the mapping.
Validation: CalendarWindowTest, the store contract on worker-array,
Swoole, database (SQLite, MySQL, MariaDB, PostgreSQL) and Redis stores,
KeyResolverTest, LimiterTest, the Saloon rate-limit tests, the
RateLimiter and Saloon suites (ParaTest), composer analyse and
php-cs-fixer.
---
docs/upstream-sync/sync.yaml | 2 +-
src/docs/rate-limiting.md | 37 ++-
src/rate-limiter/README.md | 2 +-
src/rate-limiter/src/CalendarWindow.php | 272 ++++++++++++++++++
.../src/Concerns/CalculatesRateLimits.php | 20 +-
src/rate-limiter/src/KeyResolver.php | 6 +
src/rate-limiter/src/Limiter.php | 17 ++
src/rate-limiter/src/RedisStore.php | 133 ++++++---
src/saloon/README.md | 2 +-
src/support/src/CarbonImmutable.php | 2 +
.../RateLimiter/Redis/RedisStoreTest.php | 46 +++
tests/RateLimiter/CalendarWindowTest.php | 214 ++++++++++++++
.../Fixtures/RateLimiterStoreContract.php | 60 +++-
tests/RateLimiter/KeyResolverTest.php | 43 +++
tests/RateLimiter/LimiterTest.php | 2 +
.../RateLimit/Feature/HasRateLimitsTest.php | 1 -
tests/Saloon/RateLimit/RateLimitTest.php | 250 +---------------
tests/Saloon/RateLimit/Unit/HelperTest.php | 123 ++++++++
18 files changed, 933 insertions(+), 299 deletions(-)
create mode 100644 src/rate-limiter/src/CalendarWindow.php
create mode 100644 tests/RateLimiter/CalendarWindowTest.php
create mode 100644 tests/Saloon/RateLimit/Unit/HelperTest.php
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index c83006d9d4..55cb1da254 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -210,7 +210,7 @@ saloonphp/rate-limit-plugin:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Upstream's limiter middleware, too-many-attempts limit and store records become admission before transport, a cooldown recorded from the transport response, and Saloon::inspectRateLimit(). Feature and Unit tests map to tests/Saloon/RateLimit/{Feature,Unit}, and Laravel tests to tests/Saloon/RateLimit/Hypervel; ported cases fake the transport with Http::fake(), because Saloon fakes consume no capacity, and assert through inspectRateLimit() instead of store records. RateLimitTest keeps the Retry-After parsing and policy-group cases.
+ notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Upstream's limiter middleware, too-many-attempts limit and store records become admission before transport, a cooldown recorded from the transport response, and Saloon::inspectRateLimit(). Feature/HasRateLimitsTest, Unit/{Destruct,Helper}Test and Laravel/Feature/JobMiddlewareTest map to tests/Saloon/RateLimit/{Feature,Unit,Hypervel/Feature}; ported cases fake the transport with Http::fake(), because Saloon fakes consume no capacity, and assert through inspectRateLimit() instead of store records. Unit/{Limit,Bucket,LimitStore}Test, Feature/Stores and Laravel/Feature/LaravelStoreTest test classes replaced by the rate limiter's Limit, CalendarWindow and LeakyBucket policies and its stores, so compare their changes with tests/RateLimiter (including its shared store contract) and tests/Integration/RateLimiter instead of porting them. RateLimitTest keeps the fake, cache and policy-group cases.
Sammyjo20/saloon-docs:
branch: v3
diff --git a/src/docs/rate-limiting.md b/src/docs/rate-limiting.md
index 51c065cf60..48476cdac2 100644
--- a/src/docs/rate-limiting.md
+++ b/src/docs/rate-limiting.md
@@ -8,6 +8,7 @@
- [Defining Rate Limits](#defining-rate-limits)
- [Choosing a Rate Limit](#choosing-a-rate-limit)
- [Fixed Windows](#fixed-windows)
+ - [Calendar Windows](#calendar-windows)
- [Sliding Windows](#sliding-windows)
- [Leaky Buckets](#leaky-buckets)
- [Weighted Operations](#weighted-operations)
@@ -33,6 +34,7 @@ Hypervel includes a powerful rate limiter that you may use to limit HTTP routes,
The rate limiter supports:
- fixed-window limits;
+- calendar windows that reset at the start of each minute, hour, day, or month;
- sliding-window limits;
- continuously replenishing leaky buckets;
- weighted operations;
@@ -180,6 +182,7 @@ Hypervel provides several rate limits for different kinds of work:
| Rate Limit | When to Use It |
|---|---|
| Fixed window | You want a simple limit that resets all capacity at once. |
+| Calendar window | You want capacity to reset on the clock, such as an API quota that resets at midnight. |
| Sliding window | You want to smooth the traffic spike that may occur at a fixed-window boundary. |
| Leaky bucket | You want capacity to replenish continuously or need precise burst control. |
| Exponential backoff | You want repeated failures to create progressively longer delays. |
@@ -207,6 +210,36 @@ $limit = Limit::perMinute(120, decayMinutes: 2);
A denied operation does not consume capacity or extend the active window.
+
+### Calendar Windows
+
+The `CalendarWindow` class resets capacity at the start of each minute, hour, day, or month, rather than a fixed time after the first operation. This matches APIs whose quotas reset on the clock, such as a daily quota that resets at midnight:
+
+```php
+use Hypervel\RateLimiter\CalendarWindow;
+
+$perMinute = CalendarWindow::perMinute(60);
+$perHour = CalendarWindow::perHour(1000);
+$perDay = CalendarWindow::perDay(10_000);
+$perMonth = CalendarWindow::perMonth(100_000);
+```
+
+A daily window may reset at another time of day, given in the 24-hour `HH:MM` or `HH:MM:SS` format:
+
+```php
+$limit = CalendarWindow::perDay(10_000, at: '01:00');
+```
+
+Calendar windows follow your application's timezone. If a provider resets its quota in another timezone, use the `timezone` method:
+
+```php
+$limit = CalendarWindow::perDay(10_000)->timezone('America/Los_Angeles');
+```
+
+Windows follow the local clock when daylight saving time begins or ends, so a daily window may last 23 or 25 hours, and an hour that happens twice is two separate hourly windows. When the clocks go back and a daily reset time happens twice, the window resets the first time. When the clocks go forward past a daily reset time, the window resets later by the same amount that day.
+
+Calendar windows support the same `by`, `cost`, `globally`, `after`, and `response` modifiers as fixed windows. A denied operation does not consume capacity, and `resetAfter()` returns the time until the active window resets, or zero when no window is active.
+
### Sliding Windows
@@ -276,7 +309,7 @@ $limit = Limit::perMinute(100)
->by('uploads:'.$user->id);
```
-The cost may not exceed the fixed-window or sliding-window capacity, or the leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged.
+The cost may not exceed the fixed-window, calendar-window, or sliding-window capacity, or the leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged.
### Unlimited
@@ -547,7 +580,7 @@ interface Store
}
```
-A custom store receives validated `Limit`, `SlidingWindow`, and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores.
+A custom store receives validated `Limit`, `CalendarWindow`, `SlidingWindow`, and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. A calendar window counts like a fixed window, but new state expires at the end of the window that its `window` method returns for the store's current time. Both use epoch microseconds. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores.
The `consumeMany` method evaluates entries in order, including repeated keys, and returns results through the first denial. Store all accepted changes together; on denial, leave capacity unchanged and report remaining capacity without the discarded charges. The `block` method extends a cooldown without shortening an existing block.
diff --git a/src/rate-limiter/README.md b/src/rate-limiter/README.md
index 968620cd03..89a9c34161 100644
--- a/src/rate-limiter/README.md
+++ b/src/rate-limiter/README.md
@@ -6,7 +6,7 @@ Documentation: https://hypervel.org/docs/rate-limiting
## Differences From Laravel
- Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and `Hypervel\RateLimiter` namespace instead of Laravel's Cache component.
-- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `SlidingWindow` defines a weighted sliding window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class.
+- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `CalendarWindow` defines a window that resets at the start of each minute, hour, day or month, `SlidingWindow` defines a weighted sliding window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class.
- The `consume`, `consumeMany`, `inspect`, `attempt`, `recordFailure`, and `clear` methods replace Laravel's split primitive counter API.
- Rate limit keys are always hashed and include the rate limit type, its stable algorithm settings, and its global scope. Cost and callbacks do not affect identity. Changing identity settings starts new state, and `clear()` must receive the same settings that created the state.
- Weighted denials report the actual unused capacity. The `attempt()` method consumes before invoking its callback and retains the charge if the callback throws.
diff --git a/src/rate-limiter/src/CalendarWindow.php b/src/rate-limiter/src/CalendarWindow.php
new file mode 100644
index 0000000000..b3ae62dd7e
--- /dev/null
+++ b/src/rate-limiter/src/CalendarWindow.php
@@ -0,0 +1,272 @@
+at = strlen($at) === 5 ? $at . ':00' : $at;
+
+ if ($period !== 'day' && $this->at !== self::MIDNIGHT) {
+ throw new InvalidRateLimitException('Only daily calendar windows may reset at a time other than midnight.');
+ }
+
+ $this->zone = new DateTimeZone($timezone ?? date_default_timezone_get());
+ $this->timezone = $this->zone->getName();
+
+ parent::__construct($key, $cost, $global, $afterCallback, $responseCallback);
+ }
+
+ /**
+ * Create a new limit that resets at the start of every minute.
+ */
+ public static function perMinute(int $maxAttempts): static
+ {
+ return new static($maxAttempts, 'minute');
+ }
+
+ /**
+ * Create a new limit that resets at the start of every hour.
+ */
+ public static function perHour(int $maxAttempts): static
+ {
+ return new static($maxAttempts, 'hour');
+ }
+
+ /**
+ * Create a new limit that resets every day at the given local time.
+ */
+ public static function perDay(int $maxAttempts, string $at = self::MIDNIGHT): static
+ {
+ return new static($maxAttempts, 'day', $at);
+ }
+
+ /**
+ * Create a new limit that resets at the start of every month.
+ */
+ public static function perMonth(int $maxAttempts): static
+ {
+ return new static($maxAttempts, 'month');
+ }
+
+ /**
+ * Set the timezone whose clock and calendar the window follows.
+ */
+ public function timezone(string $timezone): static
+ {
+ return new static(
+ maxAttempts: $this->maxAttempts,
+ period: $this->period,
+ at: $this->at,
+ timezone: $timezone,
+ key: $this->key,
+ cost: $this->cost,
+ global: $this->global,
+ afterCallback: $this->afterCallback,
+ responseCallback: $this->responseCallback,
+ );
+ }
+
+ /**
+ * Get the start and end of the window containing the given time, in epoch microseconds.
+ *
+ * The end is always later than the given time.
+ *
+ * @return array{int, int}
+ */
+ public function window(int $nowMicroseconds): array
+ {
+ $now = intdiv($nowMicroseconds, 1_000_000);
+
+ [$start, $end] = match ($this->period) {
+ 'minute' => $this->clockWindow($now, 60),
+ 'hour' => $this->clockWindow($now, 3_600),
+ 'day' => $this->dailyWindow($now),
+ 'month' => $this->monthlyWindow($now),
+ };
+
+ if ($end > intdiv(self::MAX_INTEGER, 1_000_000)) {
+ throw new InvalidRateLimitException('The rate limiter timestamp exceeds the supported integer range.');
+ }
+
+ return [$start * 1_000_000, $end * 1_000_000];
+ }
+
+ /**
+ * Create a copy with the given shared policy values.
+ */
+ protected function newInstance(
+ string $key,
+ int $cost,
+ bool $global,
+ ?Closure $afterCallback,
+ ?Closure $responseCallback,
+ ): static {
+ return new static(
+ maxAttempts: $this->maxAttempts,
+ period: $this->period,
+ at: $this->at,
+ timezone: $this->timezone,
+ key: $key,
+ cost: $cost,
+ global: $global,
+ afterCallback: $afterCallback,
+ responseCallback: $responseCallback,
+ );
+ }
+
+ /**
+ * Get the minute or hour window containing the given timestamp.
+ *
+ * @return array{int, int}
+ */
+ private function clockWindow(int $now, int $length): array
+ {
+ $offset = $this->offsetAt($now);
+ $start = $now - ($now + $offset) % $length;
+ $end = $start + $length;
+
+ if ($this->offsetAt($start) === $offset && $this->offsetAt($end) === $offset) {
+ return [$start, $end];
+ }
+
+ // An offset change that is not a whole number of windows, such as Lord Howe
+ // Island's half hour, moves the local boundaries. Ending or starting the window
+ // at the change keeps it from overlapping its neighbor.
+ /** @var list $transitions */
+ $transitions = $this->zone->getTransitions($start, $end);
+
+ foreach (array_slice($transitions, 1) as $transition) {
+ if ($transition['ts'] <= $now) {
+ $start = max($start, $transition['ts']);
+ } else {
+ $end = min($end, $transition['ts']);
+
+ break;
+ }
+ }
+
+ return [$start, $end];
+ }
+
+ /**
+ * Get the daily window containing the given timestamp.
+ *
+ * @return array{int, int}
+ */
+ private function dailyWindow(int $now): array
+ {
+ $date = $this->localDate($now);
+ $today = $this->resolveLocalTime($date);
+
+ return $today <= $now
+ ? [$today, $this->resolveLocalTime($date->addDay())]
+ : [$this->resolveLocalTime($date->subDay()), $today];
+ }
+
+ /**
+ * Get the monthly window containing the given timestamp.
+ *
+ * @return array{int, int}
+ */
+ private function monthlyWindow(int $now): array
+ {
+ $month = $this->localDate($now)->startOfMonth();
+
+ return [$this->resolveLocalTime($month), $this->resolveLocalTime($month->addMonth())];
+ }
+
+ /**
+ * Get the local calendar date of a timestamp as a UTC midnight.
+ */
+ private function localDate(int $timestamp): CarbonImmutable
+ {
+ return CarbonImmutable::createFromFormat(
+ '!Y-m-d',
+ CarbonImmutable::createFromTimestamp($timestamp, $this->zone)->format('Y-m-d'),
+ 'UTC',
+ );
+ }
+
+ /**
+ * Resolve the reset time on a local date to a timestamp.
+ *
+ * PHP's date parser picks a zone-dependent occurrence of a time repeated by a
+ * clock change, so each possible offset is checked and the first occurrence wins.
+ * A time skipped by a clock change moves forward by the change.
+ */
+ private function resolveLocalTime(CarbonImmutable $date): int
+ {
+ $wall = $date->setTimeFromTimeString($this->at)->getTimestamp();
+ $before = $this->offsetAt($wall - 86_400);
+ $instants = [];
+
+ foreach ([$before, $this->offsetAt($wall + 86_400)] as $offset) {
+ if ($this->offsetAt($wall - $offset) === $offset) {
+ $instants[] = $wall - $offset;
+ }
+ }
+
+ return $instants === [] ? $wall - $before : min($instants);
+ }
+
+ /**
+ * Get the timezone's UTC offset in seconds at a timestamp.
+ */
+ private function offsetAt(int $timestamp): int
+ {
+ return $this->zone->getOffset(CarbonImmutable::createFromTimestamp($timestamp));
+ }
+}
diff --git a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php
index 9770b01958..f589682935 100644
--- a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php
+++ b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php
@@ -7,6 +7,7 @@
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\RateLimiter\Backoff;
use Hypervel\RateLimiter\BackoffResult;
+use Hypervel\RateLimiter\CalendarWindow;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\CooldownResult;
use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException;
@@ -32,7 +33,7 @@ protected function calculateConsume(
int &$expiresAt,
): LimitResult {
return match (true) {
- $policy instanceof Limit => $this->calculateFixedWindow(
+ $policy instanceof Limit, $policy instanceof CalendarWindow => $this->calculateFixedWindow(
$policy,
$now,
$value,
@@ -123,7 +124,7 @@ protected function calculateInspection(
int $expiresAt,
): LimitResult|BackoffResult|CooldownResult {
return match (true) {
- $policy instanceof Limit => $this->calculateFixedWindow(
+ $policy instanceof Limit, $policy instanceof CalendarWindow => $this->calculateFixedWindow(
$policy,
$now,
$value,
@@ -220,10 +221,10 @@ protected function calculateFailure(
}
/**
- * Calculate a fixed-window decision.
+ * Calculate a fixed-window or calendar-window decision.
*/
private function calculateFixedWindow(
- Limit $policy,
+ Limit|CalendarWindow $policy,
int $now,
int &$value,
int &$secondaryValue,
@@ -240,10 +241,9 @@ private function calculateFixedWindow(
$value = $policy->cost;
$secondaryValue = 0;
- $expiresAt = $this->addExact(
- $now,
- $this->secondsToMicroseconds($policy->decaySeconds),
- );
+ $expiresAt = $policy instanceof CalendarWindow
+ ? $policy->window($now)[1]
+ : $this->addExact($now, $this->secondsToMicroseconds($policy->decaySeconds));
return new LimitResult(
true,
@@ -564,10 +564,10 @@ private function resetExpiredState(
}
/**
- * Validate fixed-window state loaded from a store.
+ * Validate fixed-window or calendar-window state loaded from a store.
*/
private function validateFixedWindowState(
- Limit $policy,
+ Limit|CalendarWindow $policy,
int $value,
int $secondaryValue,
int $expiresAt,
diff --git a/src/rate-limiter/src/KeyResolver.php b/src/rate-limiter/src/KeyResolver.php
index 2a7772324b..5d1a318107 100644
--- a/src/rate-limiter/src/KeyResolver.php
+++ b/src/rate-limiter/src/KeyResolver.php
@@ -72,6 +72,12 @@ protected function policyIdentity(AdmissionPolicy|Backoff|Cooldown $policy): str
. $this->segment('max-attempts', (string) $policy->maxAttempts)
. $this->segment('decay-seconds', (string) $policy->decaySeconds)
. $this->segment('global', $policy->global ? '1' : '0'),
+ $policy instanceof CalendarWindow => $this->segment('policy', 'calendar-window')
+ . $this->segment('max-attempts', (string) $policy->maxAttempts)
+ . $this->segment('period', $policy->period)
+ . $this->segment('at', $policy->at)
+ . $this->segment('timezone', $policy->timezone)
+ . $this->segment('global', $policy->global ? '1' : '0'),
$policy instanceof SlidingWindow => $this->segment('policy', 'sliding-window')
. $this->segment('max-attempts', (string) $policy->maxAttempts)
. $this->segment('window-seconds', (string) $policy->windowSeconds)
diff --git a/src/rate-limiter/src/Limiter.php b/src/rate-limiter/src/Limiter.php
index 6e92642638..b2a41872ab 100644
--- a/src/rate-limiter/src/Limiter.php
+++ b/src/rate-limiter/src/Limiter.php
@@ -203,6 +203,7 @@ protected function validateAdmission(AdmissionPolicy $policy): void
{
$duration = match (true) {
$policy instanceof Limit => $this->validateFixedWindow($policy),
+ $policy instanceof CalendarWindow => $this->validateCalendarWindow($policy),
$policy instanceof SlidingWindow => $this->validateSlidingWindow($policy),
$policy instanceof LeakyBucket => $this->validateLeakyBucket($policy),
default => throw new InvalidRateLimitException(sprintf(
@@ -228,6 +229,22 @@ protected function validateFixedWindow(Limit $policy): int
return $policy->decaySeconds * 1_000_000;
}
+ /**
+ * Validate a calendar-window policy's capacity.
+ *
+ * Calendar windows vary in length, so this returns no duration and stores range-check each window's end when they create it.
+ */
+ protected function validateCalendarWindow(CalendarWindow $policy): int
+ {
+ if ($policy->cost > $policy->maxAttempts) {
+ throw new InvalidRateLimitException(
+ 'The rate limit cost may not exceed the calendar-window capacity.'
+ );
+ }
+
+ return 0;
+ }
+
/**
* Validate a sliding-window policy and return its maximum state duration.
*/
diff --git a/src/rate-limiter/src/RedisStore.php b/src/rate-limiter/src/RedisStore.php
index eca046a68e..1dc82b5fcb 100644
--- a/src/rate-limiter/src/RedisStore.php
+++ b/src/rate-limiter/src/RedisStore.php
@@ -8,6 +8,7 @@
use Hypervel\RateLimiter\Contracts\Store;
use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException;
use Hypervel\Redis\RedisConnection;
+use Hypervel\Support\CarbonImmutable;
use UnexpectedValueException;
class RedisStore implements Store
@@ -60,6 +61,24 @@ class RedisStore implements Store
redis.call('INCRBY', key, state.current - state.original)
end
end
+LUA;
+
+ // PHP calculates calendar boundaries before the script runs, so a new window
+ // only starts when Redis's clock is inside them. Otherwise calculate_calendar
+ // returns nil, and the script replies with Redis's time before any write so
+ // PHP can recalculate the window.
+ private const string CALENDAR_WINDOW_FUNCTIONS = <<<'LUA'
+local function calculate_calendar(state, policy, consume, now)
+ local duration = 0
+ if consume and state.fresh then
+ local nowMilliseconds = math.floor(now / 1000)
+ if nowMilliseconds < policy[3] or nowMilliseconds >= policy[4] then
+ return nil
+ end
+ duration = policy[4] - nowMilliseconds
+ end
+ return calculate_fixed(state, {policy[1], policy[2], duration}, consume)
+end
LUA;
private const string SLIDING_WINDOW_FUNCTIONS = <<<'LUA'
@@ -206,10 +225,12 @@ class RedisStore implements Store
LUA;
private const string CONSUME_MANY_SCRIPT = self::FIXED_WINDOW_FUNCTIONS . "\n"
- . self::SLIDING_WINDOW_FUNCTIONS . "\n" . self::LEAKY_BUCKET_FUNCTIONS . <<<'LUA'
+ . self::CALENDAR_WINDOW_FUNCTIONS . "\n" . self::SLIDING_WINDOW_FUNCTIONS . "\n"
+ . self::LEAKY_BUCKET_FUNCTIONS . <<<'LUA'
local algorithms = {
fixed = {load_fixed, calculate_fixed, persist_fixed},
+ calendar = {load_fixed, calculate_calendar, persist_fixed},
sliding = {load_sliding, calculate_sliding, persist_sliding},
leaky = {load_leaky, calculate_leaky, persist_leaky}
}
@@ -222,11 +243,11 @@ class RedisStore implements Store
local policy = {tonumber(ARGV[offset + 2]), tonumber(ARGV[offset + 3]),
tonumber(ARGV[offset + 4]), tonumber(ARGV[offset + 5])}
policies[index] = {algorithm, policy}
+ if (kind == 'leaky' or kind == 'calendar') and not now then
+ local time = redis.call('TIME')
+ now = tonumber(time[1]) * 1000000 + tonumber(time[2])
+ end
if not states[key] then
- if kind == 'leaky' and not now then
- local time = redis.call('TIME')
- now = tonumber(time[1]) * 1000000 + tonumber(time[2])
- end
local state = algorithm[1](key, policy, now)
originals[key] = {}
for field, value in pairs(state) do
@@ -234,7 +255,10 @@ class RedisStore implements Store
end
states[key] = state
end
- results[index] = algorithm[2](states[key], policy, true)
+ results[index] = algorithm[2](states[key], policy, true, now)
+ if not results[index] then
+ return {'stale', now}
+ end
if results[index][1] == 0 then
-- No writes have happened. Report capacity without the discarded charges.
for position = 1, index do
@@ -262,7 +286,10 @@ class RedisStore implements Store
local policy = {tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4]), tonumber(ARGV[5])}
local state = load(KEYS[1], policy, now)
local consume = ARGV[1] == 'consume'
-local result = calculate(state, policy, consume)
+local result = calculate(state, policy, consume, now)
+if not result then
+ return {'stale', now}
+end
if consume and result[1] == 1 then
persist(KEYS[1], state)
end
@@ -273,6 +300,11 @@ class RedisStore implements Store
. "\nlocal load, calculate, persist = load_fixed, calculate_fixed, persist_fixed\nlocal now\n"
. self::SCALAR_ADMISSION_SCRIPT;
+ private const string CALENDAR_WINDOW_SCRIPT = self::FIXED_WINDOW_FUNCTIONS . "\n" . self::CALENDAR_WINDOW_FUNCTIONS
+ . "\nlocal load, calculate, persist = load_fixed, calculate_calendar, persist_fixed\n"
+ . "local time = redis.call('TIME')\nlocal now = tonumber(time[1]) * 1000000 + tonumber(time[2])\n"
+ . self::SCALAR_ADMISSION_SCRIPT;
+
private const string SLIDING_WINDOW_SCRIPT = self::SLIDING_WINDOW_FUNCTIONS
. "\nlocal load, calculate, persist = load_sliding, calculate_sliding, persist_sliding\nlocal now\n"
. self::SCALAR_ADMISSION_SCRIPT;
@@ -453,24 +485,28 @@ public function consumeMany(array $policies): array
return $this->consumeManyOnCluster($connection, $policies);
}
- $arguments = [];
- $capacities = [];
+ $now = null;
- foreach ($policies as $entry) {
- [$kind, $parameters, $capacity] = $this->admissionParameters($entry['policy']);
- $arguments[] = $kind;
- $capacities[] = $capacity;
+ do {
+ $arguments = [];
+ $capacities = [];
- foreach ($parameters as $parameter) {
- $arguments[] = $parameter;
- }
+ foreach ($policies as $entry) {
+ [$kind, $parameters, $capacity] = $this->admissionParameters($entry['policy'], $now);
+ $arguments[] = $kind;
+ $capacities[] = $capacity;
+
+ foreach ($parameters as $parameter) {
+ $arguments[] = $parameter;
+ }
- if (count($parameters) === 3) {
- $arguments[] = '0';
+ if (count($parameters) === 3) {
+ $arguments[] = '0';
+ }
}
- }
- $tuples = $connection->evalWithShaCache(self::CONSUME_MANY_SCRIPT, array_column($policies, 'key'), $arguments);
+ $tuples = $connection->evalWithShaCache(self::CONSUME_MANY_SCRIPT, array_column($policies, 'key'), $arguments);
+ } while (($now = $this->staleCalendarTime($tuples)) !== null);
if (! is_array($tuples) || ! array_is_list($tuples) || $tuples === [] || count($tuples) > count($policies)) {
throw new UnexpectedValueException('Redis returned a malformed rate limiter group result.');
@@ -562,17 +598,22 @@ protected function executeAdmission(
string $mode,
?RedisConnection $connection = null,
): LimitResult {
- [$kind, $parameters, $capacity] = $this->admissionParameters($policy);
- $script = match ($kind) {
- 'fixed' => self::FIXED_WINDOW_SCRIPT,
- 'sliding' => self::SLIDING_WINDOW_SCRIPT,
- 'leaky' => self::LEAKY_BUCKET_SCRIPT,
- };
- $arguments = [$mode, ...$parameters];
-
- $result = $connection === null
- ? $this->execute($script, $key, $arguments)
- : $connection->evalWithShaCache($script, [$key], $arguments);
+ $now = null;
+
+ do {
+ [$kind, $parameters, $capacity] = $this->admissionParameters($policy, $now);
+ $script = match ($kind) {
+ 'fixed' => self::FIXED_WINDOW_SCRIPT,
+ 'calendar' => self::CALENDAR_WINDOW_SCRIPT,
+ 'sliding' => self::SLIDING_WINDOW_SCRIPT,
+ 'leaky' => self::LEAKY_BUCKET_SCRIPT,
+ };
+ $arguments = [$mode, ...$parameters];
+
+ $result = $connection === null
+ ? $this->execute($script, $key, $arguments)
+ : $connection->evalWithShaCache($script, [$key], $arguments);
+ } while (($now = $this->staleCalendarTime($result)) !== null);
return $this->limitResult($result, $capacity);
}
@@ -580,14 +621,24 @@ protected function executeAdmission(
/**
* Return the algorithm, script parameters, and capacity for an admission policy.
*
- * @return array{'fixed'|'leaky'|'sliding', list, int}
+ * A calendar window is calculated at the given epoch microsecond, or at the current time.
+ *
+ * @return array{'calendar'|'fixed'|'leaky'|'sliding', list, int}
*/
- protected function admissionParameters(AdmissionPolicy $policy): array
+ protected function admissionParameters(AdmissionPolicy $policy, ?int $nowMicroseconds = null): array
{
return match (true) {
$policy instanceof Limit => ['fixed', [
(string) $policy->cost, (string) $policy->maxAttempts, (string) ($policy->decaySeconds * 1000),
], $policy->maxAttempts],
+ $policy instanceof CalendarWindow => ['calendar', [
+ (string) $policy->cost,
+ (string) $policy->maxAttempts,
+ ...array_map(
+ static fn (int $boundary): string => (string) intdiv($boundary, 1000),
+ $policy->window($nowMicroseconds ?? (int) CarbonImmutable::now()->getPreciseTimestamp(6)),
+ ),
+ ], $policy->maxAttempts],
$policy instanceof SlidingWindow => ['sliding', [
(string) $policy->cost, (string) $policy->maxAttempts, (string) $policy->windowSeconds,
], $policy->maxAttempts],
@@ -673,6 +724,22 @@ protected function limitResult(mixed $result, int $expectedLimit): LimitResult
);
}
+ /**
+ * Get Redis's epoch microseconds from a stale calendar-window reply, or null for any other reply.
+ */
+ protected function staleCalendarTime(mixed $result): ?int
+ {
+ if (! is_array($result) || ($result[0] ?? null) !== 'stale') {
+ return null;
+ }
+
+ if (count($result) !== 2 || ! is_int($result[1]) || $result[1] < 1) {
+ throw new UnexpectedValueException('Redis returned a malformed rate limiter result.');
+ }
+
+ return $result[1];
+ }
+
/**
* Validate and return a five-integer Redis result tuple.
*
diff --git a/src/saloon/README.md b/src/saloon/README.md
index b22f3c7f5a..611988fd76 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -24,7 +24,7 @@ Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cac
Paginators set their values with `maxPages()`, `perPageLimit()` and `startPage()`, and `totalResults()`, `request()` and `currentPage()` replace `getTotalResults()`, `getOriginalRequest()` and `getCurrentPage()`. The deprecated `getPage()` and `$page` are not included; custom `applyPagination()` methods read the protected `$pageNumber`. Page and offset paginators send their pages concurrently with `pool()`, which sends the first page, then the rest through a coroutine pool, and returns the responses keyed by page position, so there is no `async()`, `isAsyncPaginationEnabled()` or `HasAsyncPagination` and no promises to iterate. Cursor paginators can't be pooled because each cursor comes from the previous response. Iterator keys and `currentPage()` count from zero at the start page, and `maxPages()` counts pages from the start page. Upstream starts keys at `startPage - 1` and compares `maxPages` with page numbers, so `startPage(3)` with `maxPages(2)` returns no pages there. See [API Pagination](https://hypervel.org/docs/saloon#api-pagination).
-Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract or plugin store. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()` and `$detectTooManyAttempts`. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again.
+Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract or plugin store. `Limit::perMinute()` and the other `Limit` factories replace upstream's `every*()` intervals, `LeakyBucket` replaces `Bucket`, and `CalendarWindow` replaces `untilEndOfMinute()`, `untilEndOfHour()`, `untilMidnightTonight()`, `everyDayUntil()` and `untilEndOfMonth()`. The rate limiter's Redis, Swoole, database and worker-array stores replace upstream's stores. There are no file, Laravel cache or PSR cache stores, since a general cache can't apply these policies atomically, and no Predis store, since the Redis store uses the framework's PhpRedis connections. Identical policies share one state and each charges it, rather than throwing upstream's duplicate-name exception. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()` and `$detectTooManyAttempts`. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again.
Connectors are read-only, so `rateLimitingEnabled(PendingRequest)` replaces `useRateLimitPlugin()` and `$rateLimitingEnabled`, and `Saloon::inspectRateLimit()` replaces `hasReachedRateLimit()` and `getExceededLimit()`; compare `remaining()` with `limit()` in place of a threshold. `RateLimitReachedException` exposes `policy()` and `result()` instead of `getLimit()`, and the job middleware is `Queue\ReleaseOnRateLimit` instead of `Helpers\ApiRateLimited`. See [Rate Limiting](https://hypervel.org/docs/saloon#rate-limiting).
diff --git a/src/support/src/CarbonImmutable.php b/src/support/src/CarbonImmutable.php
index 91ec5afae6..a8d87b8b14 100644
--- a/src/support/src/CarbonImmutable.php
+++ b/src/support/src/CarbonImmutable.php
@@ -12,10 +12,12 @@
* Carbon's immutable magic modifier metadata names its base class even though
* these methods preserve subclasses at runtime.
*
+ * @method static addDay()
* @method static addMicrosecond()
* @method static addMicroseconds(int|float $value = 1)
* @method static addMinute()
* @method static addMinutes(int|float $value = 1)
+ * @method static addMonth()
* @method static addSecond()
* @method static addSeconds(int|float $value = 1)
* @method static ceilSecond(float $precision = 1)
diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
index afd6f1e83c..ae54103126 100644
--- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
+++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
@@ -8,6 +8,7 @@
use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis;
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\RateLimiter\Backoff;
+use Hypervel\RateLimiter\CalendarWindow;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\KeyResolver;
use Hypervel\RateLimiter\LeakyBucket;
@@ -167,6 +168,51 @@ public function testLeakyBucketUsesRedisTimeAndRecoversCapacity(): void
$this->assertGreaterThan(1_700_000_000_000_000, (int) $storedTat);
}
+ #[DataProvider('calendarClockOffsetProvider')]
+ public function testCalendarWindowsFollowRedisTimeWhenThePhpClockIsInAnotherPeriod(int $offsetSeconds): void
+ {
+ CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds($offsetSeconds));
+ $policy = CalendarWindow::perHour(5)->timezone('UTC')->by('calendar-clock');
+ $limiter = $this->limiter();
+
+ $first = $limiter->consume($policy);
+ $second = $limiter->consume($policy);
+ $redisSeconds = (int) $this->redisClient()->time()[0];
+
+ $this->assertTrue($first->allowed());
+ $this->assertSame(4, $first->remaining());
+ $this->assertSame(3, $second->remaining());
+ $this->assertEqualsWithDelta(3_600 - $redisSeconds % 3_600, $second->resetAfter(), 1);
+ $this->assertLessThanOrEqual(3_600_000, $this->redisClient()->pttl($this->physicalKey($policy)));
+ }
+
+ /**
+ * Get PHP clock offsets that place the PHP calendar window outside Redis's.
+ */
+ public static function calendarClockOffsetProvider(): array
+ {
+ return [
+ 'PHP clock behind Redis' => [-7_200],
+ 'PHP clock ahead of Redis' => [7_200],
+ ];
+ }
+
+ public function testAStaleCalendarWindowDiscardsTheGroupsTentativeChargesBeforeRetrying(): void
+ {
+ CarbonImmutable::setTestNow(CarbonImmutable::now()->subHours(2));
+ $fixed = Limit::perMinute(10)->by('calendar-group');
+ $calendar = CalendarWindow::perHour(10)->timezone('UTC')->by('calendar-group');
+ $limiter = $this->limiter();
+
+ $results = $limiter->consumeMany([$fixed, $calendar]);
+
+ $this->assertCount(2, $results);
+ $this->assertSame(9, $results[0]->remaining());
+ $this->assertSame(9, $results[1]->remaining());
+ $this->assertLessThanOrEqual(3_600, $results[1]->resetAfter());
+ $this->assertSame(9, $limiter->inspect($fixed)->remaining());
+ }
+
public function testExponentialBackoffUsesOneRedisStateEntry(): void
{
$backoff = Backoff::exponential(
diff --git a/tests/RateLimiter/CalendarWindowTest.php b/tests/RateLimiter/CalendarWindowTest.php
new file mode 100644
index 0000000000..276b6aa1c6
--- /dev/null
+++ b/tests/RateLimiter/CalendarWindowTest.php
@@ -0,0 +1,214 @@
+assertPolicy(CalendarWindow::perMinute(60), 60, 'minute', '00:00:00');
+ $this->assertPolicy(CalendarWindow::perHour(1_000), 1_000, 'hour', '00:00:00');
+ $this->assertPolicy(CalendarWindow::perDay(10_000), 10_000, 'day', '00:00:00');
+ $this->assertPolicy(CalendarWindow::perDay(10_000, at: '01:30'), 10_000, 'day', '01:30:00');
+ $this->assertPolicy(CalendarWindow::perDay(10_000, at: '01:30:15'), 10_000, 'day', '01:30:15');
+ $this->assertPolicy(CalendarWindow::perMonth(100_000), 100_000, 'month', '00:00:00');
+ }
+
+ public function testTheTimezoneDefaultsToTheApplicationTimezoneAndMayBeChanged(): void
+ {
+ $previousTimezone = date_default_timezone_get();
+ date_default_timezone_set('Asia/Kolkata');
+
+ try {
+ $original = CalendarWindow::perDay(10);
+ } finally {
+ date_default_timezone_set($previousTimezone);
+ }
+
+ $changed = $original->timezone('America/New_York');
+
+ $this->assertNotSame($original, $changed);
+ $this->assertSame('Asia/Kolkata', $original->timezone);
+ $this->assertSame('America/New_York', $changed->timezone);
+ }
+
+ public function testFluentModifiersReturnImmutableCopiesThatKeepTheCalendarOptions(): void
+ {
+ $after = static fn (): bool => true;
+ $response = static fn (): string => 'limited';
+ $original = CalendarWindow::perDay(10, at: '01:00')->timezone('Europe/Berlin');
+ $modified = $original
+ ->by('api')
+ ->cost(3)
+ ->globally()
+ ->after($after)
+ ->response($response)
+ ->timezone('Australia/Sydney');
+
+ $this->assertNotSame($original, $modified);
+ $this->assertSame('', $original->key);
+ $this->assertSame('Europe/Berlin', $original->timezone);
+
+ $this->assertSame(10, $modified->maxAttempts);
+ $this->assertSame('day', $modified->period);
+ $this->assertSame('01:00:00', $modified->at);
+ $this->assertSame('Australia/Sydney', $modified->timezone);
+ $this->assertSame('api', $modified->key);
+ $this->assertSame(3, $modified->cost);
+ $this->assertTrue($modified->global);
+ $this->assertSame($after, $modified->afterCallback);
+ $this->assertSame($response, $modified->responseCallback);
+ }
+
+ public function testInvalidValuesAreRejected(): void
+ {
+ foreach ([
+ static fn (): CalendarWindow => CalendarWindow::perMinute(0),
+ static fn (): CalendarWindow => CalendarWindow::perMinute(1)->cost(0),
+ static fn (): CalendarWindow => new CalendarWindow(1, 'week'),
+ static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '24:00'),
+ static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '1:00'),
+ static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:60'),
+ static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:00:60'),
+ static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '1am'),
+ static fn (): CalendarWindow => new CalendarWindow(1, 'month', '01:00'),
+ ] as $callback) {
+ try {
+ $callback();
+ $this->fail('Expected an invalid rate limit exception.');
+ } catch (InvalidRateLimitException) {
+ $this->addToAssertionCount(1);
+ }
+ }
+ }
+
+ public function testInvalidTimezonesAreRejected(): void
+ {
+ $this->expectException(DateInvalidTimeZoneException::class);
+
+ CalendarWindow::perDay(1)->timezone('Not/A_Zone');
+ }
+
+ /**
+ * @param callable(): CalendarWindow $policy
+ */
+ #[DataProvider('windowProvider')]
+ public function testWindowsFollowTheLocalClockAndCalendar(
+ callable $policy,
+ string $now,
+ string $start,
+ string $end,
+ ): void {
+ $window = $policy()->window((int) CarbonImmutable::parse($now . ' UTC')->getPreciseTimestamp(6));
+
+ $this->assertSame(
+ [
+ CarbonImmutable::parse($start . ' UTC')->getTimestamp() * 1_000_000,
+ CarbonImmutable::parse($end . ' UTC')->getTimestamp() * 1_000_000,
+ ],
+ $window,
+ );
+ }
+
+ /**
+ * Get UTC times and the UTC start and end of the window containing them.
+ */
+ public static function windowProvider(): array
+ {
+ $minute = static fn (): CalendarWindow => CalendarWindow::perMinute(1)->timezone('UTC');
+ $kolkataHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Asia/Kolkata');
+ $newYorkHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('America/New_York');
+ $berlinHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Europe/Berlin');
+ $lordHoweHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Australia/Lord_Howe');
+ $utcDay = static fn (): CalendarWindow => CalendarWindow::perDay(1)->timezone('UTC');
+ $utcDayAtOne = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:00')->timezone('UTC');
+ $newYorkDay = static fn (): CalendarWindow => CalendarWindow::perDay(1)->timezone('America/New_York');
+ $newYorkDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:30')
+ ->timezone('America/New_York');
+ $newYorkDayAtSkipped = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30')
+ ->timezone('America/New_York');
+ $berlinDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30')
+ ->timezone('Europe/Berlin');
+ $sydneyDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30')
+ ->timezone('Australia/Sydney');
+ $utcMonth = static fn (): CalendarWindow => CalendarWindow::perMonth(1)->timezone('UTC');
+ $newYorkMonth = static fn (): CalendarWindow => CalendarWindow::perMonth(1)->timezone('America/New_York');
+
+ return [
+ 'minute' => [$minute, '2026-10-07 06:45:30', '2026-10-07 06:45:00', '2026-10-07 06:46:00'],
+ 'minute just before its end' => [$minute, '2026-10-07 06:45:59.999999', '2026-10-07 06:45:00', '2026-10-07 06:46:00'],
+ 'minute at its start' => [$minute, '2026-10-07 06:46:00', '2026-10-07 06:46:00', '2026-10-07 06:47:00'],
+ 'half-hour offset' => [$kolkataHour, '2026-10-07 06:45:00', '2026-10-07 06:30:00', '2026-10-07 07:30:00'],
+
+ // New York's clocks go back from 02:00 EDT to 01:00 EST at 06:00 UTC, so 01:00 local starts twice.
+ 'first 01:00 hour starts' => [$newYorkHour, '2026-11-01 05:00:00', '2026-11-01 05:00:00', '2026-11-01 06:00:00'],
+ 'first 01:00 hour ends' => [$newYorkHour, '2026-11-01 05:59:59', '2026-11-01 05:00:00', '2026-11-01 06:00:00'],
+ 'repeated 01:00 hour starts' => [$newYorkHour, '2026-11-01 06:00:00', '2026-11-01 06:00:00', '2026-11-01 07:00:00'],
+ 'repeated 01:00 hour' => [$newYorkHour, '2026-11-01 06:30:00', '2026-11-01 06:00:00', '2026-11-01 07:00:00'],
+
+ // New York's clocks go forward from 02:00 EST to 03:00 EDT at 07:00 UTC.
+ 'hour before skipped hour' => [$newYorkHour, '2026-03-08 06:59:59', '2026-03-08 06:00:00', '2026-03-08 07:00:00'],
+ 'hour after skipped hour' => [$newYorkHour, '2026-03-08 07:00:00', '2026-03-08 07:00:00', '2026-03-08 08:00:00'],
+ 'Berlin first 02:00 hour' => [$berlinHour, '2026-10-25 00:59:59', '2026-10-25 00:00:00', '2026-10-25 01:00:00'],
+ 'Berlin repeated 02:00 hour' => [$berlinHour, '2026-10-25 01:00:00', '2026-10-25 01:00:00', '2026-10-25 02:00:00'],
+
+ // Lord Howe Island's clocks go back half an hour at 15:00 UTC and forward half an hour at 15:30 UTC.
+ 'Lord Howe hour before going back' => [$lordHoweHour, '2026-04-04 14:59:59', '2026-04-04 14:00:00', '2026-04-04 15:00:00'],
+ 'Lord Howe half hour after going back' => [$lordHoweHour, '2026-04-04 15:00:00', '2026-04-04 15:00:00', '2026-04-04 15:30:00'],
+ 'Lord Howe hour after going back' => [$lordHoweHour, '2026-04-04 15:30:00', '2026-04-04 15:30:00', '2026-04-04 16:30:00'],
+ 'Lord Howe hour before going forward' => [$lordHoweHour, '2026-10-03 15:29:59', '2026-10-03 14:30:00', '2026-10-03 15:30:00'],
+ 'Lord Howe half hour after going forward' => [$lordHoweHour, '2026-10-03 15:30:00', '2026-10-03 15:30:00', '2026-10-03 16:00:00'],
+ 'Lord Howe hour after going forward' => [$lordHoweHour, '2026-10-03 16:00:00', '2026-10-03 16:00:00', '2026-10-03 17:00:00'],
+
+ 'day' => [$utcDay, '2026-10-07 12:00:00', '2026-10-07 00:00:00', '2026-10-08 00:00:00'],
+ 'day at midnight' => [$utcDay, '2026-10-08 00:00:00', '2026-10-08 00:00:00', '2026-10-09 00:00:00'],
+ 'day before its reset time' => [$utcDayAtOne, '2026-10-07 00:30:00', '2026-10-06 01:00:00', '2026-10-07 01:00:00'],
+ 'day at its reset time' => [$utcDayAtOne, '2026-10-07 01:00:00', '2026-10-07 01:00:00', '2026-10-08 01:00:00'],
+ '23-hour day' => [$newYorkDay, '2026-03-08 12:00:00', '2026-03-08 05:00:00', '2026-03-09 04:00:00'],
+
+ // A reset time repeated by a clock change resets at its first occurrence only.
+ 'New York before repeated reset' => [$newYorkDayAtRepeated, '2026-11-01 05:15:00', '2026-10-31 05:30:00', '2026-11-01 05:30:00'],
+ 'New York after first reset' => [$newYorkDayAtRepeated, '2026-11-01 05:45:00', '2026-11-01 05:30:00', '2026-11-02 06:30:00'],
+ 'New York during repeated reset' => [$newYorkDayAtRepeated, '2026-11-01 06:45:00', '2026-11-01 05:30:00', '2026-11-02 06:30:00'],
+ 'Berlin before repeated reset' => [$berlinDayAtRepeated, '2026-10-25 00:15:00', '2026-10-24 00:30:00', '2026-10-25 00:30:00'],
+ 'Berlin during repeated reset' => [$berlinDayAtRepeated, '2026-10-25 01:45:00', '2026-10-25 00:30:00', '2026-10-26 01:30:00'],
+ 'Sydney during repeated reset' => [$sydneyDayAtRepeated, '2026-04-04 16:45:00', '2026-04-04 15:30:00', '2026-04-05 16:30:00'],
+
+ // A reset time skipped by a clock change moves forward that day, then returns to the configured time.
+ 'before skipped reset' => [$newYorkDayAtSkipped, '2026-03-08 07:00:00', '2026-03-07 07:30:00', '2026-03-08 07:30:00'],
+ 'after skipped reset' => [$newYorkDayAtSkipped, '2026-03-08 08:00:00', '2026-03-08 07:30:00', '2026-03-09 06:30:00'],
+
+ 'month' => [$utcMonth, '2026-01-31 12:00:00', '2026-01-01 00:00:00', '2026-02-01 00:00:00'],
+ 'month at year end' => [$utcMonth, '2026-12-15 12:00:00', '2026-12-01 00:00:00', '2027-01-01 00:00:00'],
+ 'month across a clock change' => [$newYorkMonth, '2026-11-15 12:00:00', '2026-11-01 04:00:00', '2026-12-01 05:00:00'],
+ ];
+ }
+
+ public function testWindowsEndingBeyondTheSupportedRangeAreRejected(): void
+ {
+ $this->expectException(InvalidRateLimitException::class);
+ $this->expectExceptionMessage('The rate limiter timestamp exceeds the supported integer range.');
+
+ CalendarWindow::perMonth(1)->timezone('UTC')->window(AdmissionPolicy::MAX_INTEGER - 1);
+ }
+
+ /**
+ * Assert the policy's capacity, period and reset time.
+ */
+ private function assertPolicy(CalendarWindow $policy, int $maxAttempts, string $period, string $at): void
+ {
+ $this->assertSame($maxAttempts, $policy->maxAttempts);
+ $this->assertSame($period, $policy->period);
+ $this->assertSame($at, $policy->at);
+ }
+}
diff --git a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php
index 34838a9840..95e6200f25 100644
--- a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php
+++ b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php
@@ -6,11 +6,13 @@
use Closure;
use Hypervel\RateLimiter\Backoff;
+use Hypervel\RateLimiter\CalendarWindow;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\LeakyBucket;
use Hypervel\RateLimiter\Limit;
use Hypervel\RateLimiter\Limiter;
use Hypervel\RateLimiter\SlidingWindow;
+use Hypervel\Support\CarbonImmutable;
trait RateLimiterStoreContract
{
@@ -22,6 +24,7 @@ public function testStoreContractConsumesMixedPoliciesTogetherAndSharesScalarSta
Limit::perMinute(10)->cost(2)->by($key),
SlidingWindow::perMinute(10)->cost(3)->by($key),
LeakyBucket::perMinute(1)->burst(10)->cost(4)->by($key),
+ CalendarWindow::perMonth(10)->timezone('UTC')->cost(5)->by($key),
];
foreach ($policies as $policy) {
@@ -30,7 +33,7 @@ public function testStoreContractConsumesMixedPoliciesTogetherAndSharesScalarSta
$results = $limiter->consumeMany($policies);
- $this->assertCount(3, $results);
+ $this->assertCount(4, $results);
foreach ($results as $index => $result) {
$this->assertTrue($result->allowed());
@@ -71,6 +74,61 @@ public function testStoreContractRepeatedKeysConsumeTheCombinedCost(): void
$this->assertSame(8, $results[0]->remaining());
$this->assertSame(5, $results[1]->remaining());
$this->assertSame(5, $limiter->inspect($policy)->remaining());
+
+ $calendar = CalendarWindow::perMonth(10)
+ ->timezone('UTC')
+ ->by($this->rateLimiterStoreContractKey('group-repeated-calendar'));
+
+ $results = $limiter->consumeMany([$calendar->cost(2), $calendar->cost(3)]);
+
+ $this->assertCount(2, $results);
+ $this->assertSame(8, $results[0]->remaining());
+ $this->assertSame(5, $results[1]->remaining());
+ $this->assertSame(5, $limiter->inspect($calendar)->remaining());
+ }
+
+ public function testStoreContractCalendarWindowsEndAtTheCalendarReset(): void
+ {
+ $limiter = $this->rateLimiterStoreContract();
+ $policy = CalendarWindow::perMonth(5)
+ ->timezone('UTC')
+ ->cost(2)
+ ->by($this->rateLimiterStoreContractKey('calendar'));
+ $secondsUntilReset = static fn (): int => (int) ceil(
+ CarbonImmutable::now('UTC')->startOfMonth()->addMonth()->getTimestamp()
+ - CarbonImmutable::now()->getPreciseTimestamp(6) / 1_000_000
+ );
+
+ $missing = $limiter->inspect($policy);
+ $this->assertTrue($missing->allowed());
+ $this->assertSame(5, $missing->limit());
+ $this->assertSame(5, $missing->remaining());
+ $this->assertSame(0, $missing->resetAfter());
+
+ // Database and Redis stores use their own clocks, so allow for clock differences.
+ $accepted = $limiter->consume($policy);
+ $this->assertTrue($accepted->allowed());
+ $this->assertSame(3, $accepted->remaining());
+ $this->assertEqualsWithDelta($secondsUntilReset(), $accepted->resetAfter(), 2);
+
+ $denied = $limiter->consume($policy->cost(4));
+ $this->assertTrue($denied->denied());
+ $this->assertSame(3, $denied->remaining());
+ $this->assertSame($denied->resetAfter(), $denied->retryAfter());
+ $this->assertEqualsWithDelta($secondsUntilReset(), $denied->retryAfter(), 2);
+
+ if ($this->advanceRateLimiterStoreContractClock($accepted->resetAfter())) {
+ $expired = $limiter->inspect($policy);
+ $this->assertSame(5, $expired->remaining());
+ $this->assertSame(0, $expired->resetAfter());
+
+ $renewed = $limiter->consume($policy);
+ $this->assertSame(3, $renewed->remaining());
+ $this->assertSame($secondsUntilReset(), $renewed->resetAfter());
+ }
+
+ $this->assertTrue($limiter->clear($policy));
+ $this->assertSame(5, $limiter->inspect($policy)->remaining());
}
public function testStoreContractFixedWindowDecisionsAreAtomicAndComplete(): void
diff --git a/tests/RateLimiter/KeyResolverTest.php b/tests/RateLimiter/KeyResolverTest.php
index f6264b4947..5853a3aded 100644
--- a/tests/RateLimiter/KeyResolverTest.php
+++ b/tests/RateLimiter/KeyResolverTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\RateLimiter;
use Hypervel\RateLimiter\Backoff;
+use Hypervel\RateLimiter\CalendarWindow;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\KeyResolver;
use Hypervel\RateLimiter\LeakyBucket;
@@ -36,6 +37,13 @@ public function testCanonicalIdentityHasStableGoldenVectors(): void
'72519c9daf2298e61f4ce018cacde4ac',
$resolver->resolve(LeakyBucket::perSecond(100)->burst(200)->by('user:1'), 'api'),
);
+ $this->assertSame(
+ 'cb8b42d25a527a30dc32c02806cdee8a',
+ $resolver->resolve(
+ CalendarWindow::perDay(1000, at: '01:00')->timezone('America/New_York')->by('user:1'),
+ 'api',
+ ),
+ );
$this->assertSame(
'c80de4e32af3dae58519b7d54ceb3c12',
$resolver->resolve(Backoff::exponential(
@@ -96,6 +104,41 @@ public function testSlidingWindowIdentityIncludesStablePolicySettings(): void
$this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited')));
}
+ public function testLeakyBucketIdentityIncludesStablePolicySettings(): void
+ {
+ $resolver = new KeyResolver('app', static fn (): ?string => null);
+ $policy = LeakyBucket::perSecond(10)->burst(20)->by('user:1');
+ $key = $resolver->resolve($policy);
+
+ $this->assertNotSame($key, $resolver->resolve(LeakyBucket::perSecond(11)->burst(20)->by('user:1')));
+ $this->assertNotSame($key, $resolver->resolve(LeakyBucket::perSecond(10, 2)->burst(20)->by('user:1')));
+ $this->assertNotSame($key, $resolver->resolve($policy->burst(21)));
+ $this->assertNotSame($key, $resolver->resolve($policy->globally()));
+ $this->assertSame($key, $resolver->resolve($policy->cost(5)));
+ }
+
+ public function testCalendarWindowIdentityIncludesStablePolicySettings(): void
+ {
+ $resolver = new KeyResolver('app', static fn (): ?string => null);
+ $policy = CalendarWindow::perDay(60, at: '01:00')->timezone('UTC')->by('user:1');
+ $key = $resolver->resolve($policy);
+
+ $this->assertSame($key, $resolver->resolve(
+ CalendarWindow::perDay(60, at: '01:00:00')->timezone('UTC')->by('user:1'),
+ ));
+ $this->assertNotSame($key, $resolver->resolve(
+ CalendarWindow::perDay(61, at: '01:00')->timezone('UTC')->by('user:1'),
+ ));
+ $this->assertNotSame($key, $resolver->resolve(
+ CalendarWindow::perDay(60, at: '02:00')->timezone('UTC')->by('user:1'),
+ ));
+ $this->assertNotSame($key, $resolver->resolve(CalendarWindow::perHour(60)->timezone('UTC')->by('user:1')));
+ $this->assertNotSame($key, $resolver->resolve($policy->timezone('Europe/Berlin')));
+ $this->assertNotSame($key, $resolver->resolve($policy->globally()));
+ $this->assertNotSame($key, $resolver->resolve(Limit::perDay(60)->by('user:1')));
+ $this->assertSame($key, $resolver->resolve($policy->cost(5)));
+ }
+
public function testMissingScopeResolverMatchesAResolverReturningNull(): void
{
$policy = Limit::perMinute(60)->by('user:1');
diff --git a/tests/RateLimiter/LimiterTest.php b/tests/RateLimiter/LimiterTest.php
index f963f3e407..ef7eed5091 100644
--- a/tests/RateLimiter/LimiterTest.php
+++ b/tests/RateLimiter/LimiterTest.php
@@ -8,6 +8,7 @@
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\RateLimiter\Backoff;
use Hypervel\RateLimiter\BackoffResult;
+use Hypervel\RateLimiter\CalendarWindow;
use Hypervel\RateLimiter\Contracts\Store;
use Hypervel\RateLimiter\Cooldown;
use Hypervel\RateLimiter\CooldownResult;
@@ -103,6 +104,7 @@ public function testCrossFieldValidationHappensBeforeKeyOrStoreAccess(): void
foreach ([
Limit::perMinute(1)->cost(2),
+ CalendarWindow::perMinute(1)->cost(2),
SlidingWindow::perMinute(1)->cost(2),
LeakyBucket::perSecond(1)->cost(2),
] as $policy) {
diff --git a/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
index 9f1bff79b4..770bf57521 100644
--- a/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
+++ b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php
@@ -240,7 +240,6 @@ public static function unusableRetryAfterProvider(): array
'missing' => [null],
'malformed' => ['not-working'],
'not an HTTP date' => ['01/01/2023'],
- 'too long for the rate limiter' => [str_repeat('9', 30)],
];
}
diff --git a/tests/Saloon/RateLimit/RateLimitTest.php b/tests/Saloon/RateLimit/RateLimitTest.php
index 79a297d731..b75773df4a 100644
--- a/tests/Saloon/RateLimit/RateLimitTest.php
+++ b/tests/Saloon/RateLimit/RateLimitTest.php
@@ -40,7 +40,7 @@
use Mockery as m;
// Feature/HasRateLimitsTest covers consuming connector and request policies, denials before transport, cooldowns
-// recorded before response middleware and waiting.
+// recorded before response middleware and waiting, and Unit/HelperTest covers Retry-After parsing.
class RateLimitTest extends TestCase
{
public function testFakesAndCacheHitsDoNotConsumeAdmissionCapacity(): void
@@ -119,189 +119,6 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
$http->assertNothingSent();
}
- public function testRetryAfterHttpDatesAreParsed(): void
- {
- $now = CarbonImmutable::parse('2026-08-13 12:00:00 UTC');
- CarbonImmutable::setTestNow($now);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => Factory::response(
- body: [],
- status: 429,
- headers: ['Retry-After' => $now->addSeconds(5)->toRfc7231String()],
- )]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $dated = new DateCooldownRequestStub;
-
- $connector->send($dated);
-
- try {
- $connector->send($dated);
- $this->fail('A date-based cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame(5, $exception->result()->retryAfter());
- }
-
- $http->assertSentCount(1);
- }
-
- public function testObsoleteHttpDatesAndCheckedNumericDurationsAreParsed(): void
- {
- $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC');
- CarbonImmutable::setTestNow($now);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => $now->addSeconds(5)->format('l, d-M-y H:i:s \G\M\T')])
- ->push([], 429, ['Retry-After' => 'Thu Aug 6 12:00:06 2026'])
- ->push([], 429, ['Retry-After' => 'Fri, 06 Aug 2026 12:00:07 GMT'])
- ->push([], 429, ['Retry-After' => '0008'])]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $assertCooldown = function (Request $request, int $seconds) use ($connector): void {
- $connector->send($request);
-
- try {
- $connector->send($request);
- $this->fail('A parsed cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame($seconds, $exception->result()->retryAfter());
- }
- };
-
- $assertCooldown(new Rfc850CooldownRequestStub, 5);
- $assertCooldown(new AsctimeCooldownRequestStub, 6);
- $assertCooldown(new MismatchedWeekdayCooldownRequestStub, 7);
- $assertCooldown(new NumericCooldownRequestStub, 8);
- $http->assertSentCount(4);
- }
-
- public function testTwoDigitAsctimeDayIsParsed(): void
- {
- CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC');
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => Factory::response(
- body: [],
- status: 429,
- headers: ['Retry-After' => 'Thu Aug 13 12:00:07 2026'],
- )]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $request = new TwoDigitAsctimeCooldownRequestStub;
-
- $connector->send($request);
-
- try {
- $connector->send($request);
- $this->fail('A two-digit asctime cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame(7, $exception->result()->retryAfter());
- }
-
- $http->assertSentCount(1);
- }
-
- public function testHttpDateProtocolRulesAndInvalidCalendarValuesAreHandled(): void
- {
- $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC');
- CarbonImmutable::setTestNow($now);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => 'Sunday, 06-Nov-70 12:00:00 GMT'])
- ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 12:00:60 GMT'])
- ->push([], 429, ['Retry-After' => 'Thu, 31 Feb 2026 12:00:09 GMT'])
- ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 25:00:09 GMT'])]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $assertCooldown = function (Request $request, int $seconds) use ($connector): void {
- $connector->send($request);
-
- try {
- $connector->send($request);
- $this->fail('A parsed cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame($seconds, $exception->result()->retryAfter());
- }
- };
-
- $assertCooldown(
- new Rfc850FutureYearCooldownRequestStub,
- CarbonImmutable::parse('2070-11-06 12:00:00 UTC')->getTimestamp() - $now->getTimestamp(),
- );
- $assertCooldown(new LeapSecondCooldownRequestStub, 60);
-
- // Out-of-range fields are rejected instead of normalized into another date, so the default cooldown applies.
- $assertCooldown(new InvalidCalendarCooldownRequestStub, 60);
- $assertCooldown(new InvalidTimeCooldownRequestStub, 60);
- $http->assertSentCount(4);
- }
-
- public function testRfc850YearResolutionUsesGmtAtACenturyBoundary(): void
- {
- $previousTimezone = date_default_timezone_get();
- date_default_timezone_set('America/Los_Angeles');
-
- try {
- $now = CarbonImmutable::parse('2100-01-01 00:30:00 UTC');
- CarbonImmutable::setTestNow($now);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => Factory::response(
- body: [],
- status: 429,
- headers: ['Retry-After' => 'Sunday, 01-Jan-50 00:30:00 GMT'],
- )]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $request = new Rfc850FutureYearCooldownRequestStub;
-
- $connector->send($request);
-
- try {
- $connector->send($request);
- $this->fail('An RFC 850 cooldown resolved against the wrong timezone.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame(
- CarbonImmutable::parse('2150-01-01 00:30:00 UTC')->getTimestamp() - $now->getTimestamp(),
- $exception->result()->retryAfter(),
- );
- }
-
- $http->assertSentCount(1);
- } finally {
- date_default_timezone_set($previousTimezone);
- }
- }
-
- public function testRfc850YearResolutionUsesTheCompleteFiftyYearBoundary(): void
- {
- $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC');
- CarbonImmutable::setTestNow($now);
- [$manager, , $http] = $this->manager();
- $http->fake(['*' => $http->sequence()
- ->push([], 429, ['Retry-After' => 'Thursday, 31-Dec-76 12:00:00 GMT'])
- ->push(['ok' => true])
- ->push([], 429, ['Retry-After' => 'Thursday, 06-Aug-76 12:00:00 GMT'])
- ->push([], 429, ['Retry-After' => 'Thursday, 01-Jan-76 12:00:00 GMT'])
- ->push([], 429, ['Retry-After' => 'Thu, 31 Dec 2076 12:00:00 GMT'])]);
- $connector = new PlainRateLimitConnectorStub($manager);
- $assertCooldown = function (Request $request, string $availableAt) use ($connector, $now): void {
- $connector->send($request);
-
- try {
- $connector->send($request);
- $this->fail('A date-based cooldown reached transport.');
- } catch (RateLimitReachedException $exception) {
- $this->assertSame(
- CarbonImmutable::parse($availableAt)->getTimestamp() - $now->getTimestamp(),
- $exception->result()->retryAfter(),
- );
- }
- };
-
- $rolledBack = new Rfc850BeyondFiftyYearsCooldownRequestStub;
- $this->assertSame(429, $connector->send($rolledBack)->status());
- $this->assertTrue($connector->send($rolledBack)->successful());
-
- $assertCooldown(new Rfc850ExactFiftyYearsCooldownRequestStub, '2076-08-06 12:00:00 UTC');
- $assertCooldown(new Rfc850WithinFiftyYearsCooldownRequestStub, '2076-01-01 12:00:00 UTC');
- $assertCooldown(new ImfBeyondFiftyYearsCooldownRequestStub, '2076-12-31 12:00:00 UTC');
- $http->assertSentCount(5);
- }
-
public function testWaitingForAGroupRechecksCooldownBeforeChargingTheGroup(): void
{
CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC');
@@ -475,71 +292,6 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array
}
}
-class DateCooldownRequestStub extends PlainRateLimitRequestStub
-{
- use HasRateLimits;
-
- /**
- * Resolve no rate limits.
- */
- protected function resolveRateLimits(PendingRequest $pendingRequest): array
- {
- return [];
- }
-}
-
-class Rfc850CooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class AsctimeCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class TwoDigitAsctimeCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class MismatchedWeekdayCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class Rfc850FutureYearCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class Rfc850BeyondFiftyYearsCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class Rfc850ExactFiftyYearsCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class Rfc850WithinFiftyYearsCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class ImfBeyondFiftyYearsCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class LeapSecondCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class InvalidCalendarCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class InvalidTimeCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
-class NumericCooldownRequestStub extends DateCooldownRequestStub
-{
-}
-
class CallbackRateLimitRequestStub extends PlainRateLimitRequestStub
{
use HasRateLimits;
diff --git a/tests/Saloon/RateLimit/Unit/HelperTest.php b/tests/Saloon/RateLimit/Unit/HelperTest.php
new file mode 100644
index 0000000000..f5d68e5bf3
--- /dev/null
+++ b/tests/Saloon/RateLimit/Unit/HelperTest.php
@@ -0,0 +1,123 @@
+make('config')->set('saloon.rate_limiter.store', 'worker-array');
+ }
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Http::preventStrayRequests();
+ }
+
+ #[DataProvider('retryAfterProvider')]
+ public function testTheRetryAfterHelperCanParseDifferentValues(string $now, ?string $retryAfter, ?int $expected): void
+ {
+ $this->assertSame($expected, $this->cooldownAt($now, $retryAfter));
+ }
+
+ /**
+ * Get Retry-After values, the time they are received and the cooldown they give.
+ */
+ public static function retryAfterProvider(): array
+ {
+ $now = '2026-08-06 12:00:00';
+ $secondsUntil = static fn (string $time): int => CarbonImmutable::parse($time . ' UTC')->getTimestamp()
+ - CarbonImmutable::parse($now . ' UTC')->getTimestamp();
+
+ return [
+ 'missing' => [$now, null, 60],
+ 'zero seconds' => [$now, '0', null],
+ 'seconds' => [$now, '120', 120],
+ 'seconds with leading zeros' => [$now, '0008', 8],
+ 'too long for the rate limiter' => [$now, str_repeat('9', 30), 60],
+ 'unknown' => [$now, 'Unknown', 60],
+ 'not an HTTP date' => [$now, '2030-01-01 00:00:00', 60],
+ 'HTTP date' => [$now, 'Wed, 23 Oct 2030 06:28:00 GMT', $secondsUntil('2030-10-23 06:28:00')],
+ 'HTTP date with the wrong weekday' => [$now, 'Fri, 06 Aug 2026 12:00:07 GMT', 7],
+ 'HTTP date in the past' => [$now, 'Wed, 05 Aug 2026 12:00:00 GMT', null],
+ 'leap second' => [$now, 'Thu, 06 Aug 2026 12:01:60 GMT', 120],
+ 'invalid calendar date' => [$now, 'Thu, 31 Feb 2026 12:00:09 GMT', 60],
+ 'invalid time' => [$now, 'Thu, 06 Aug 2026 25:00:09 GMT', 60],
+ 'RFC 850 date' => [$now, 'Thursday, 06-Aug-26 12:00:05 GMT', 5],
+ 'asctime date' => [$now, 'Thu Aug 6 12:00:06 2026', 6],
+ 'asctime date with a two-digit day' => ['2026-08-13 12:00:00', 'Thu Aug 13 12:00:07 2026', 7],
+
+ // A two-digit RFC 850 year more than 50 years ahead means the most recent past year with those digits.
+ 'RFC 850 year within 50 years' => [$now, 'Sunday, 06-Nov-70 12:00:00 GMT', $secondsUntil('2070-11-06 12:00:00')],
+ 'RFC 850 year 50 years ahead' => [$now, 'Thursday, 06-Aug-76 12:00:00 GMT', $secondsUntil('2076-08-06 12:00:00')],
+ 'RFC 850 year earlier in that year' => [$now, 'Thursday, 01-Jan-76 12:00:00 GMT', $secondsUntil('2076-01-01 12:00:00')],
+ 'RFC 850 year beyond 50 years' => [$now, 'Thursday, 31-Dec-76 12:00:00 GMT', null],
+ 'four-digit year beyond 50 years' => [$now, 'Thu, 31 Dec 2076 12:00:00 GMT', $secondsUntil('2076-12-31 12:00:00')],
+ ];
+ }
+
+ public function testRfc850YearsResolveAgainstGmtAtACenturyBoundary(): void
+ {
+ $previousTimezone = date_default_timezone_get();
+ date_default_timezone_set('America/Los_Angeles');
+
+ try {
+ $this->assertSame(
+ CarbonImmutable::parse('2150-01-01 00:30:00 UTC')->getTimestamp()
+ - CarbonImmutable::parse('2100-01-01 00:30:00 UTC')->getTimestamp(),
+ $this->cooldownAt('2100-01-01 00:30:00', 'Sunday, 01-Jan-50 00:30:00 GMT'),
+ );
+ } finally {
+ date_default_timezone_set($previousTimezone);
+ }
+ }
+
+ // REMOVED: the limit helper clones the limits - rate-limit policies are immutable, so the configured policies
+ // are never changed (RateLimiter/LimitTest::testFluentModifiersReturnImmutableCopies).
+
+ // REMOVED: the limit helper throws an exception if two of the same limits have the same name - identical
+ // policies share one state and charge it once each, as RateLimiter/Fixtures/RateLimiterStoreContract's
+ // repeated-key case shows.
+
+ /**
+ * Get the cooldown a 429 with the given Retry-After header gives at a UTC time.
+ */
+ private function cooldownAt(string $now, ?string $retryAfter): ?int
+ {
+ CarbonImmutable::setTestNow($now . ' UTC');
+ Http::fake(['*' => Http::response([], 429, $retryAfter === null ? [] : ['Retry-After' => $retryAfter])]);
+ $connector = new TestConnector([]);
+
+ return $connector->resolveRateLimitCooldownFor($connector->send(new UserRequest));
+ }
+}
From 2b97a433078d9c8e9e1afb475853c5b3d3027137 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 03:50:09 +0000
Subject: [PATCH 16/26] Fix container-built Saloon requests and map the core
public surface
Requests were SelfBuilding with a newInstance() that called
new static(), so app()->make() on a request ignored make() parameters
and constructor injection and threw ArgumentCountError for any request
with constructor arguments. Request now implements Transient, which
the container added after the request lifecycle: unbound resolutions
stay fresh while dependencies are injected and parameters apply.
A comparison of saloonphp/saloon's public surface (v4 at 2f45e526f9d1)
with the port found:
- PendingRequest took a cache factory and rate limiter it never used,
through cache() and rateLimiter() getters with no callers. Both are
removed from its constructor. The lifecycle methods only the manager
calls are marked @internal, like createPsrRequest().
- PipeOrder had renamed upstream's FIRST and LAST cases without
approval; upstream's names are restored.
- withUserAgent() and withoutVerifying() had no tests.
- Several renamed or omitted upstream members were missing from the
README: the get-prefixed request and pending-request getters,
setBody(), hasFakeResponse(), getResponseClass(), sender and PSR
factory arguments, fromPsrResponse(), createException(),
withBufferedBody(), getSenderException(), getStatusMessage(),
send()'s $handleRetry argument, and request exceptions no longer
being SaloonExceptions. Source comments mark the omitted members.
The Laravel porting guide gains a Saloon section covering the
namespaces, read-only connectors and fluent methods, the transport
and the exception catch.
Validation: RequestTest, HeadersTest, ConfigTest and each changed test
file, the Saloon suite (ParaTest), tests/Data/Saloon, the Saloon
integration tests on the engine servers, composer analyse and
php-cs-fixer.
---
src/docs/porting-from-laravel.md | 8 +++
src/docs/saloon.md | 4 +-
src/saloon/README.md | 12 ++--
src/saloon/src/Enums/PipeOrder.php | 4 +-
.../Exceptions/Request/RequestException.php | 2 +
src/saloon/src/Http/Connector.php | 3 +
src/saloon/src/Http/PendingRequest.php | 58 ++++++++++++-------
src/saloon/src/Http/Pipeline.php | 4 +-
src/saloon/src/Http/Request.php | 12 +---
src/saloon/src/Http/Response.php | 3 +
src/saloon/src/SaloonManager.php | 2 +-
src/saloon/src/Traits/HasDebugging.php | 4 +-
.../Traits/Plugins/AlwaysThrowOnErrors.php | 2 +-
tests/Data/Saloon/SaloonIntegrationTest.php | 28 ++++++---
tests/Saloon/Cache/Feature/CacheTest.php | 7 +--
tests/Saloon/Feature/DelayRequestTest.php | 10 +---
tests/Saloon/SaloonManagerTest.php | 14 +----
tests/Saloon/Unit/MiddlewarePipelineTest.php | 14 ++---
tests/Saloon/Unit/Oauth2/OAuthRequestTest.php | 18 +-----
tests/Saloon/Unit/PendingRequestTest.php | 10 +---
tests/Saloon/Unit/PipelineTest.php | 12 ++--
.../Unit/RequestProperties/ConfigTest.php | 29 ++++------
.../Unit/RequestProperties/HeadersTest.php | 26 +++------
.../Unit/RequestProperties/QueryTest.php | 25 ++------
tests/Saloon/Unit/RequestTest.php | 29 +++++-----
tests/Saloon/Unit/ResponseTest.php | 9 +--
tests/Saloon/Unit/SenderTest.php | 9 +--
27 files changed, 151 insertions(+), 207 deletions(-)
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index c59ca2d954..1790156ff0 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -26,6 +26,7 @@
- [Scheduling](#scheduling)
- [Maintenance Mode](#maintenance-mode)
- [HTTP Client and Concurrency](#http-client-and-concurrency)
+ - [Saloon](#saloon)
- [Broadcasting](#broadcasting)
- [JSON:API Resources](#jsonapi-resources)
- [CSRF Protection](#csrf-protection)
@@ -514,6 +515,13 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p
Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver).
+
+### Saloon
+
+Integrations built with `saloonphp/saloon`, its Laravel plugin, and its cache, pagination, and rate limit plugins use the single `hypervel/saloon` package. Replace the `Saloon\` and `Saloon\Laravel\` namespaces with `Hypervel\Saloon\`, and the plugins' `Saloon\CachePlugin\`, `Saloon\PaginationPlugin\`, and `Saloon\RateLimitPlugin\` namespaces with `Hypervel\Saloon\Cache\`, `Hypervel\Saloon\Pagination\`, and `Hypervel\Saloon\RateLimit\`.
+
+Connectors may be shared by concurrent requests, so they are read-only. Move code that changes a connector's headers, query parameters, options, authenticator, delay, middleware, or mock client at runtime to the request, the `send` call, or the connector's `boot` method. Requests use fluent methods such as `withHeaders` and `withQueryParameters` instead of `headers()->add()` and `query()->add()`. Replace `sendAsync` and promises with pools, retry properties with `retry` or `defaultRetryPolicy`, custom senders with HTTP connections, and the rate limit plugin's limits and stores with rate limiter policies. Request exceptions extend the HTTP client's `RequestException`, so `catch (SaloonException $e)` no longer catches failed responses. See [Differences From Saloon](/docs/{{version}}/saloon#differences-from-saloon).
+
### Broadcasting
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index ca045dc335..0742d50edb 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -340,7 +340,7 @@ Requests are mutable while you prepare one operation. You may create them with `
$request = GetUser::make('hypervel');
```
-No-argument requests may also be resolved from the service container and are created fresh on each resolution. Requests that require caller-supplied values should be constructed directly, created with `make`, or given an explicit transient container binding.
+Requests may also be resolved from the service container, which creates a fresh request on each resolution and injects its constructor dependencies. Pass any other constructor values as parameters to the container's `make` method.
### Request Methods
@@ -959,7 +959,7 @@ class AddRequestId implements RequestMiddleware
Register the class in the same way as a closure: `$pendingRequest->middleware()->onRequest(new AddRequestId)`.
-Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::First` or `PipeOrder::Last` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline.
+Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::FIRST` or `PipeOrder::LAST` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline.
### Response Middleware
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 611988fd76..70a1dbc867 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -4,11 +4,11 @@ Documentation: https://hypervel.org/docs/saloon
## Differences From Laravel
-Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, and `uri()` replaces `getUrl()`, including the query string.
+Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available: `headers()` returns an array, `queryParameters()` and `delayMilliseconds()` replace the `query()` and `delay()` getters, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Getters drop upstream's `get` prefix, so requests have `method()` and `mockClient()`, and pending requests have `connector()`, `request()`, `method()` and `fakeResponse()`; compare `fakeResponse()` with `null` in place of `hasFakeResponse()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, `uri()` replaces `getUrl()`, including the query string, and `resolveResponseClass($response)` returns the custom response class, or `null` for the default, in place of `getResponseClass()`.
Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
-There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `defaultSender()`, sender resolvers and handler stacks are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
+There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
@@ -16,7 +16,7 @@ Connectors are read-only, so they have no `withMockClient()`: attach a mock clie
Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeResponse` and `ResponseMiddleware::__invoke()` returns `?Response`, so a ported middleware class declared `: void` returns `null` instead. A pending request returned from request middleware is ignored; change the one it receives. Pipelines expose `pipes()`, `requestPipeline()`, `responsePipeline()` and `fatalPipeline()` instead of upstream's `get` methods. Connector middleware is registered from `boot()` and runs after global and plugin middleware but before the request's own middleware.
-Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties and `handleRetry()` methods; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries).
+Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties, `handleRetry()` methods and `send()`'s `$handleRetry` argument; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries).
Pools send requests concurrently through coroutines instead of promises, so there is no `sendAsync()`, pending requests have no `isAsynchronous()` or `setAsynchronous()`, and pools accept requests but not promises. A pool's `send()` waits for every request and returns the successful responses keyed and ordered as the requests were given; `process()` sends them without keeping the responses. Concurrency is a positive integer: upstream's callable and unlimited `0` forms are not supported, because the coroutine pool has a fixed number of slots. Handlers receive the response or exception and the request's key, without upstream's aggregate promise. Ending a request generator stops further requests, but a request already waiting for a slot still starts and running requests are not canceled. Every response that `toException()` treats as failed, including through custom failure hooks, goes to the exception handler; upstream's real asynchronous path checks them only after Guzzle rejects a transfer. Unhandled request failures, handler failures and invalid items throw `PoolException` once every started request has finished, instead of being ignored or ending the pool early. `requests()` replaces `getRequests()` and calls a request callback each time the pool is sent, so a callback that returns a new iterable lets the pool be sent again. See [Concurrent Requests](https://hypervel.org/docs/saloon#concurrent-requests).
@@ -30,11 +30,11 @@ Connectors are read-only, so `rateLimitingEnabled(PendingRequest)` replaces `use
A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments.
-Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
+Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `fromResponse()` creates a response from an HTTP client response in place of `fromPsrResponse()`, and response classes override the HTTP client's `newRequestException()` in place of `createException()`. `withBufferedBody()` is not included, since `body()` buffers a stream that can't be rewound, and neither is `getSenderException()`, since an error status never throws in the transport. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`.
-Request exceptions extend the HTTP client's `RequestException`, so they use its message format and constructor, and expose `response()`, `pendingRequest()`, `status()` and `body()` instead of upstream's getters. `getRequestException(Response $response)` must return a Saloon request exception and receives no sender exception; override an exception's `prepareMessage()` to change its message. A transfer that fails after the response headers arrive throws `FatalRequestException` whatever its status, rather than returning the partial response. Debugging with `die: true` exits, which throws Swoole's `ExitException` inside a coroutine, so there is no `Debugger::$dieHandler`.
+Request exceptions extend the HTTP client's `RequestException`, so they use its message format and constructor, and expose `response()`, `pendingRequest()`, `status()` and `body()` instead of upstream's getters; use `response()->reason()` in place of `getStatusMessage()`. They are not `SaloonException`s, so catch `RequestException` for failed responses. `FatalRequestException` has `pendingRequest()` in place of `getPendingRequest()`. `getRequestException(Response $response)` must return a Saloon request exception and receives no sender exception; override an exception's `prepareMessage()` to change its message. A transfer that fails after the response headers arrive throws `FatalRequestException` whatever its status, rather than returning the partial response. Debugging with `die: true` exits, which throws Swoole's `ExitException` inside a coroutine, so there is no `Debugger::$dieHandler`.
-Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own.
+Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it in place of `setBody()`; a custom repository comes from `defaultBodyRepository()`. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own.
The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options).
diff --git a/src/saloon/src/Enums/PipeOrder.php b/src/saloon/src/Enums/PipeOrder.php
index 5d7ff3662c..30a66f6444 100644
--- a/src/saloon/src/Enums/PipeOrder.php
+++ b/src/saloon/src/Enums/PipeOrder.php
@@ -6,6 +6,6 @@
enum PipeOrder: string
{
- case First = 'first';
- case Last = 'last';
+ case FIRST = 'first';
+ case LAST = 'last';
}
diff --git a/src/saloon/src/Exceptions/Request/RequestException.php b/src/saloon/src/Exceptions/Request/RequestException.php
index 11a998ae08..3b71008e56 100644
--- a/src/saloon/src/Exceptions/Request/RequestException.php
+++ b/src/saloon/src/Exceptions/Request/RequestException.php
@@ -40,6 +40,8 @@ public function status(): int
return $this->response->status();
}
+ // getStatusMessage() is not included: use response()->reason().
+
/**
* Get the response body.
*/
diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php
index 0a0540304f..ea7b257126 100644
--- a/src/saloon/src/Http/Connector.php
+++ b/src/saloon/src/Http/Connector.php
@@ -114,6 +114,9 @@ public function resolveHttpConnection(): ?string
return null;
}
+ // sender() and defaultSender() are not included: requests are sent through the HTTP client connection. See the
+ // package README.
+
/**
* Resolve whether requests may replace this connector's base URL.
*/
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 9bac962e83..b0c38d1ee4 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -8,9 +8,7 @@
use DateInterval;
use DateTimeInterface;
use GuzzleHttp\Psr7\Request as PsrRequest;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Http\Client\Response as HttpResponse;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Cache\Contracts\Cacheable;
use Hypervel\Saloon\Cache\Exceptions\CachingException;
use Hypervel\Saloon\Contracts\Authenticator;
@@ -126,8 +124,6 @@ class PendingRequest
public function __construct(
protected Connector $connector,
protected Request $request,
- protected CacheFactory $cache,
- protected RateLimiter $rateLimiter,
) {
$this->method = $request->method();
$this->url = $request->url();
@@ -267,6 +263,8 @@ public function uri(): UriInterface
/**
* Finalize the request URI after ordinary request middleware.
+ *
+ * @internal
*/
public function finalizeUri(): static
{
@@ -339,6 +337,8 @@ public function authenticate(Authenticator $authenticator): static
/**
* Apply the selected authenticator.
+ *
+ * @internal
*/
public function applyAuthentication(): static
{
@@ -349,6 +349,8 @@ public function applyAuthentication(): static
/**
* Boot the connector and request plugins.
+ *
+ * @internal
*/
public function bootPlugins(): static
{
@@ -365,6 +367,8 @@ public function bootPlugins(): static
/**
* Merge global or request middleware into this operation's own pipeline.
+ *
+ * @internal
*/
public function mergeMiddleware(MiddlewarePipeline $middleware): static
{
@@ -375,6 +379,8 @@ public function mergeMiddleware(MiddlewarePipeline $middleware): static
/**
* Execute the request middleware pipeline.
+ *
+ * @internal
*/
public function executeRequestPipeline(): static
{
@@ -401,6 +407,8 @@ public function executeFatalPipeline(FatalRequestException $exception): void
/**
* Prepare the request body after ordinary request middleware.
+ *
+ * @internal
*/
public function prepareBody(): static
{
@@ -421,6 +429,8 @@ public function prepareBody(): static
/**
* Get the prepared request body.
+ *
+ * @internal
*/
public function preparedBody(): ?StreamInterface
{
@@ -477,6 +487,7 @@ public function certificate(): array|string|null
*
* @param callable(RequestInterface, PendingRequest): void $observer
* @return $this
+ * @internal
*/
public function observePsrRequest(callable $observer): static
{
@@ -497,6 +508,8 @@ public function handlePsrRequest(RequestInterface $request): RequestInterface
/**
* Notify the final PSR request observers.
+ *
+ * @internal
*/
public function notifyPsrRequestObservers(RequestInterface $request): void
{
@@ -535,6 +548,7 @@ public function createPsrRequest(): RequestInterface
* Set the final application-owned PSR request.
*
* @return $this
+ * @internal
*/
public function setPsrRequest(RequestInterface $request): static
{
@@ -579,6 +593,8 @@ public function toPsrRequest(): RequestInterface
/**
* Restore the attempt body before another attempt.
+ *
+ * @internal
*/
public function restoreAttemptBody(): bool
{
@@ -597,6 +613,9 @@ public function restoreAttemptBody(): bool
return true;
}
+ // setBody() is not included: the body methods change the body, and defaultBodyRepository() supplies a custom
+ // repository. See the package README.
+
/**
* Set the fake response.
*
@@ -617,11 +636,15 @@ public function fakeResponse(): ?FakeResponse
return $this->fakeResponse;
}
+ // hasFakeResponse() is not included: compare fakeResponse() with null.
+
// isAsynchronous() and setAsynchronous() are not included: every pending request is sent synchronously, and pools
// send concurrently through coroutines. See the package README.
/**
* Validate the request's caching configuration.
+ *
+ * @internal
*/
public function validateCachingConfiguration(): static
{
@@ -637,6 +660,8 @@ public function validateCachingConfiguration(): static
/**
* Determine if this operation is cacheable.
+ *
+ * @internal
*/
public function isCacheable(): bool
{
@@ -650,6 +675,8 @@ public function isCacheable(): bool
/**
* Determine if the matching cache entry should be invalidated.
+ *
+ * @internal
*/
public function shouldInvalidateCache(): bool
{
@@ -658,6 +685,8 @@ public function shouldInvalidateCache(): bool
/**
* Get the cache duration.
+ *
+ * @internal
*/
public function cacheFor(): DateInterval|DateTimeInterface|int
{
@@ -667,6 +696,8 @@ public function cacheFor(): DateInterval|DateTimeInterface|int
/**
* Get the selected cache store.
+ *
+ * @internal
*/
public function cacheStore(): UnitEnum|string|null
{
@@ -681,6 +712,8 @@ public function cacheStore(): UnitEnum|string|null
/**
* Resolve the custom cache key.
+ *
+ * @internal
*/
public function resolveCacheKey(): ?string
{
@@ -688,22 +721,6 @@ public function resolveCacheKey(): ?string
?? $this->connector->resolveCacheKey($this);
}
- /**
- * Get the cache factory.
- */
- public function cache(): CacheFactory
- {
- return $this->cache;
- }
-
- /**
- * Get the rate limiter manager.
- */
- public function rateLimiter(): RateLimiter
- {
- return $this->rateLimiter;
- }
-
/**
* Resolve the request or connector that owns cache duration and storage.
*/
@@ -720,6 +737,7 @@ protected function cacheableResource(): ?Cacheable
* Create the response selected by this operation.
*
* @return Response
+ * @internal
*/
public function createResponse(HttpResponse $response, RequestInterface $request): Response
{
diff --git a/src/saloon/src/Http/Pipeline.php b/src/saloon/src/Http/Pipeline.php
index 7d1c2216ff..f048e5f531 100644
--- a/src/saloon/src/Http/Pipeline.php
+++ b/src/saloon/src/Http/Pipeline.php
@@ -54,9 +54,9 @@ public function pipe(callable $callable, ?string $name = null, ?PipeOrder $order
$pipe = new Pipe($callable, $name, $order);
match ($order) {
- PipeOrder::First => $this->firstPipes[] = $pipe,
+ PipeOrder::FIRST => $this->firstPipes[] = $pipe,
null => $this->pipes[] = $pipe,
- PipeOrder::Last => $this->lastPipes[] = $pipe,
+ PipeOrder::LAST => $this->lastPipes[] = $pipe,
};
if ($name !== null) {
diff --git a/src/saloon/src/Http/Request.php b/src/saloon/src/Http/Request.php
index 9ace63bd6c..b027f110dd 100644
--- a/src/saloon/src/Http/Request.php
+++ b/src/saloon/src/Http/Request.php
@@ -4,7 +4,7 @@
namespace Hypervel\Saloon\Http;
-use Hypervel\Contracts\Container\SelfBuilding;
+use Hypervel\Contracts\Container\Transient;
use Hypervel\RateLimiter\AdmissionPolicy;
use Hypervel\RateLimiter\Contracts\Decision;
use Hypervel\RateLimiter\Cooldown;
@@ -26,7 +26,7 @@
use UnitEnum;
/** @template-covariant TDto */
-abstract class Request implements SelfBuilding
+abstract class Request implements Transient
{
/** @use CreatesDtoFromResponse */
use CreatesDtoFromResponse;
@@ -54,14 +54,6 @@ abstract class Request implements SelfBuilding
*/
protected ?string $url = null;
- /**
- * Create a fresh request for container resolution.
- */
- public static function newInstance(): static
- {
- return new static;
- }
-
/**
* Get the HTTP method used by the request.
*/
diff --git a/src/saloon/src/Http/Response.php b/src/saloon/src/Http/Response.php
index 50e7b81b51..0b94be4e36 100644
--- a/src/saloon/src/Http/Response.php
+++ b/src/saloon/src/Http/Response.php
@@ -116,6 +116,9 @@ public function stream(): StreamInterface
return $this->response->getBody();
}
+ // withBufferedBody() is not included: body() buffers a stream that can't be rewound. getSenderException() is not
+ // included either, since an error status never throws in the transport. See the package README.
+
/**
* Get the pending request.
*
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index e9c8961dba..290c026851 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -268,7 +268,7 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl
*/
public function createPendingRequest(Connector $connector, Request $request): PendingRequest
{
- $pendingRequest = new PendingRequest($connector, $request, $this->cache, $this->rateLimiter);
+ $pendingRequest = new PendingRequest($connector, $request);
$pendingRequest
->mergeMiddleware($this->middleware)
->bootPlugins()
diff --git a/src/saloon/src/Traits/HasDebugging.php b/src/saloon/src/Traits/HasDebugging.php
index 5bef60fd4c..adae18b63a 100644
--- a/src/saloon/src/Traits/HasDebugging.php
+++ b/src/saloon/src/Traits/HasDebugging.php
@@ -35,7 +35,7 @@ static function (RequestInterface $request, PendingRequest $pendingRequest) use
},
);
},
- order: PipeOrder::Last,
+ order: PipeOrder::LAST,
);
return $this;
@@ -65,7 +65,7 @@ static function (Response $response) use ($onResponse, $die): Response {
return $response;
},
- order: PipeOrder::First,
+ order: PipeOrder::FIRST,
);
return $this;
diff --git a/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php b/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php
index c44487e763..e4a43ebed8 100644
--- a/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php
+++ b/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php
@@ -17,7 +17,7 @@ public function bootAlwaysThrowOnErrors(PendingRequest $pendingRequest): void
{
$pendingRequest->middleware()->onResponse(
static fn (Response $response): Response => $response->throw(),
- order: PipeOrder::Last,
+ order: PipeOrder::LAST,
);
}
}
diff --git a/tests/Data/Saloon/SaloonIntegrationTest.php b/tests/Data/Saloon/SaloonIntegrationTest.php
index cabcd4c7eb..375f6e0f35 100644
--- a/tests/Data/Saloon/SaloonIntegrationTest.php
+++ b/tests/Data/Saloon/SaloonIntegrationTest.php
@@ -6,12 +6,10 @@
use GuzzleHttp\Psr7\Request as PsrRequest;
use GuzzleHttp\Psr7\Response as PsrResponse;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Foundation\Application;
use Hypervel\Data\Data;
use Hypervel\Data\DataServiceProvider;
use Hypervel\Http\Client\Response as HttpResponse;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Contracts\DataObjects\WithResponse;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Connector;
@@ -20,7 +18,6 @@
use Hypervel\Saloon\Http\Response;
use Hypervel\Saloon\Traits\Responses\HasResponse;
use Hypervel\Testbench\TestCase;
-use Mockery as m;
class SaloonIntegrationTest extends TestCase
{
@@ -65,12 +62,7 @@ public function testConnectorDataIsUsedWhenTheRequestReturnsNothing(): void
*/
protected function response(Connector $connector, Request $request): Response
{
- $pendingRequest = new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ $pendingRequest = new PendingRequest($connector, $request);
$psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users/7');
return Response::fromResponse(
@@ -88,11 +80,17 @@ protected function response(Connector $connector, Request $request): Response
/** @extends Connector */
class DataConnector extends Connector
{
+ /**
+ * Resolve the integration base URL.
+ */
public function resolveBaseUrl(): string
{
return 'https://api.example.com';
}
+ /**
+ * Create a data object from the response.
+ */
public function createDtoFromResponse(Response $response): SaloonUserData
{
return SaloonUserData::from([
@@ -107,11 +105,17 @@ class DataRequest extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users/7';
}
+ /**
+ * Create a data object from the response.
+ */
public function createDtoFromResponse(Response $response): SaloonUserData
{
return SaloonUserData::from([
@@ -126,6 +130,9 @@ class PlainDataRequest extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Resolve the request endpoint.
+ */
public function resolveEndpoint(): string
{
return '/users/7';
@@ -136,6 +143,9 @@ class SaloonUserData extends Data implements WithResponse
{
use HasResponse;
+ /**
+ * Create a new data object instance.
+ */
public function __construct(
public int $id,
public string $name,
diff --git a/tests/Saloon/Cache/Feature/CacheTest.php b/tests/Saloon/Cache/Feature/CacheTest.php
index bd3598f127..71e532286f 100644
--- a/tests/Saloon/Cache/Feature/CacheTest.php
+++ b/tests/Saloon/Cache/Feature/CacheTest.php
@@ -830,12 +830,7 @@ protected function manager(Factory $http, ?Dispatcher $events = null, ?Sender $s
*/
protected function pending(Connector $connector, Request $request): PendingRequest
{
- return (new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- ))->applyAuthentication()->finalizeUri()->prepareBody();
+ return (new PendingRequest($connector, $request))->applyAuthentication()->finalizeUri()->prepareBody();
}
}
diff --git a/tests/Saloon/Feature/DelayRequestTest.php b/tests/Saloon/Feature/DelayRequestTest.php
index d08909bdb7..8d91998017 100644
--- a/tests/Saloon/Feature/DelayRequestTest.php
+++ b/tests/Saloon/Feature/DelayRequestTest.php
@@ -7,10 +7,8 @@
use DateInterval;
use DateTimeInterface;
use Exception;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Http\Client\Factory;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Cache\Contracts\Cacheable;
use Hypervel\Saloon\Cache\Traits\HasCaching;
use Hypervel\Saloon\Enums\Method;
@@ -27,7 +25,6 @@
use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use InvalidArgumentException;
-use Mockery as m;
// REMOVED: upstream Unit/Body/IntegerBodyRepositoryTest. Delays are nullable integers, so there is no
// public integer store; the empty, default, set, and zero cases below cover the same delay semantics.
@@ -206,12 +203,7 @@ protected function request(): object
*/
protected function pendingRequest(Request $request): PendingRequest
{
- return new PendingRequest(
- new DelayConnectorStub,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest(new DelayConnectorStub, $request);
}
}
diff --git a/tests/Saloon/SaloonManagerTest.php b/tests/Saloon/SaloonManagerTest.php
index 6b549c3a9c..218af91495 100644
--- a/tests/Saloon/SaloonManagerTest.php
+++ b/tests/Saloon/SaloonManagerTest.php
@@ -225,12 +225,7 @@ public function testReturningAnotherPendingRequestCannotDivertBodyPreparation():
});
$request = (new BodyManagerRequestStub)->withData(['name' => 'Taylor']);
$request->middleware()->onRequest(function (): PendingRequest {
- return new PendingRequest(
- new ManagerConnectorStub,
- new ManagerRequestStub,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest(new ManagerConnectorStub, new ManagerRequestStub);
});
$this->manager($http)->send(new ManagerConnectorStub, $request);
@@ -245,12 +240,7 @@ public function testReturningAnotherPendingRequestCannotDivertALaterFake(): void
$request = new ManagerRequestStub;
$request->middleware()
->onRequest(function (): PendingRequest {
- return new PendingRequest(
- new ManagerConnectorStub,
- new ManagerRequestStub,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest(new ManagerConnectorStub, new ManagerRequestStub);
})
->onRequest(static fn (): FakeResponse => new MockResponse('middleware'));
diff --git a/tests/Saloon/Unit/MiddlewarePipelineTest.php b/tests/Saloon/Unit/MiddlewarePipelineTest.php
index f918d949f1..4a38b48d45 100644
--- a/tests/Saloon/Unit/MiddlewarePipelineTest.php
+++ b/tests/Saloon/Unit/MiddlewarePipelineTest.php
@@ -149,7 +149,7 @@ public function testRequestMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void
})
->onRequest(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::First)
+ }, order: PipeOrder::FIRST)
->onRequest(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -170,7 +170,7 @@ public function testRequestMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): v
})
->onRequest(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::Last)
+ }, order: PipeOrder::LAST)
->onRequest(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -280,7 +280,7 @@ public function testResponseMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): voi
})
->onResponse(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::First)
+ }, order: PipeOrder::FIRST)
->onResponse(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -301,7 +301,7 @@ public function testResponseMiddlewarePipeCanBeAddedToTheBottomOfThePipeline():
})
->onResponse(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::Last)
+ }, order: PipeOrder::LAST)
->onResponse(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -401,7 +401,7 @@ public function testFatalMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void
})
->onFatalException(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::First)
+ }, order: PipeOrder::FIRST)
->onFatalException(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -422,7 +422,7 @@ public function testFatalMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): voi
})
->onFatalException(function () use (&$names): void {
$names[] = 'Taylor';
- }, order: PipeOrder::Last)
+ }, order: PipeOrder::LAST)
->onFatalException(function () use (&$names): void {
$names[] = 'Andrew';
});
@@ -490,7 +490,7 @@ public function testAMiddlewarePipelineIsCorrectlyDestructedWhenFinished(): void
->onRequest(function (): void {
})
->onResponse(function (): void {
- }, order: PipeOrder::Last)
+ }, order: PipeOrder::LAST)
->onResponse(function (): void {
})
->onFatalException(function (): void {
diff --git a/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
index 3b685f4292..9510bda196 100644
--- a/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
+++ b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php
@@ -4,8 +4,6 @@
namespace Hypervel\Tests\Saloon\Unit\Oauth2;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Data\OAuthConfig;
use Hypervel\Saloon\Http\OAuth2\GetAccessTokenRequest;
use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenBasicAuthRequest;
@@ -15,7 +13,6 @@
use Hypervel\Saloon\Http\Request;
use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
use Hypervel\Tests\TestCase;
-use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
class OAuthRequestTest extends TestCase
@@ -23,7 +20,7 @@ class OAuthRequestTest extends TestCase
#[DataProvider('requestBodies')]
public function testTokenRequestsUseTheExpectedFormBody(Request $request, array $expected): void
{
- $pendingRequest = $this->pendingRequest($request)
+ $pendingRequest = (new PendingRequest(new TestConnector('https://api.example.com'), $request))
->bootPlugins()
->applyAuthentication()
->finalizeUri()
@@ -94,17 +91,4 @@ protected static function config(): OAuthConfig
defaultScopes: ['openid'],
);
}
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Request $request): PendingRequest
- {
- return new PendingRequest(
- new TestConnector('https://api.example.com'),
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
}
diff --git a/tests/Saloon/Unit/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
index 557d62412b..3b81203453 100644
--- a/tests/Saloon/Unit/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -4,10 +4,8 @@
namespace Hypervel\Tests\Saloon\Unit;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Http\Client\Request as HttpRequest;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Contracts\Body\BodyRepository;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Events\SendingSaloonRequest;
@@ -33,7 +31,6 @@
use Hypervel\Testbench\TestCase;
use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
-use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Http\Message\StreamInterface;
use stdClass;
@@ -393,12 +390,7 @@ protected function setUp(): void
*/
protected function pendingRequest(Connector $connector, Request $request): PendingRequest
{
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest($connector, $request);
}
}
diff --git a/tests/Saloon/Unit/PipelineTest.php b/tests/Saloon/Unit/PipelineTest.php
index 431ad3cc6b..253a1d2f9a 100644
--- a/tests/Saloon/Unit/PipelineTest.php
+++ b/tests/Saloon/Unit/PipelineTest.php
@@ -27,8 +27,8 @@ public function testPipesAreStoredInExecutionOrder(): void
{
$pipeline = (new Pipeline)
->pipe(fn (array $values): array => [...$values, 'default-one'], 'default-one')
- ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::Last)
- ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::First)
+ ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::LAST)
+ ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::FIRST)
->pipe(fn (array $values): array => [...$values, 'default-two'], 'default-two');
$this->assertSame(
@@ -43,18 +43,18 @@ public function testPipesAreStoredInExecutionOrder(): void
public function testDuplicateNamedPipeIsRejectedAcrossBuckets(): void
{
- $pipeline = (new Pipeline)->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::First);
+ $pipeline = (new Pipeline)->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::FIRST);
$this->expectException(DuplicatePipeNameException::class);
- $pipeline->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::Last);
+ $pipeline->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::LAST);
}
public function testPipesCanBeCopiedIntoAnotherPipeline(): void
{
$source = (new Pipeline)
- ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::Last)
- ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::First);
+ ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::LAST)
+ ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::FIRST);
$target = (new Pipeline)->setPipes($source->pipes());
diff --git a/tests/Saloon/Unit/RequestProperties/ConfigTest.php b/tests/Saloon/Unit/RequestProperties/ConfigTest.php
index 561d1cdda9..074e6ac555 100644
--- a/tests/Saloon/Unit/RequestProperties/ConfigTest.php
+++ b/tests/Saloon/Unit/RequestProperties/ConfigTest.php
@@ -5,17 +5,12 @@
namespace Hypervel\Tests\Saloon\Unit\RequestProperties;
use ErrorException;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
-use Hypervel\RateLimiter\RateLimiter;
-use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
-use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Traits\RequestProperties\HasOptions;
use Hypervel\Tests\Saloon\Fixtures\Connectors\ConfigConnector;
use Hypervel\Tests\Saloon\Fixtures\Requests\ConfigRequest;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use Hypervel\Tests\TestCase;
-use Mockery as m;
class ConfigTest extends TestCase
{
@@ -53,7 +48,7 @@ public function testConfigCanBeManagedOnAConnector(): void
// Connectors are read-only and may be shared between coroutines, so their default options are managed on
// the pending request that merges them.
$connector = new ConfigConnector;
- $pendingRequest = $this->pendingRequest($connector, new UserRequest);
+ $pendingRequest = new PendingRequest($connector, new UserRequest);
$pendingRequest->withOptions(['timeout' => 60]);
$pendingRequest->withOptions(['name' => 'Sam', 'category' => 'Cowboy', 'connect_timeout' => 200]);
@@ -126,6 +121,15 @@ public function testMaxRedirectsReplacesTheBooleanEnabledForm(): void
$this->assertSame(['max' => 3], $request->options()['allow_redirects']);
}
+ public function testWithoutVerifyingReplacesACertificateBundle(): void
+ {
+ $request = $this->request()
+ ->withOptions(['verify' => '/etc/ssl/certs/api.pem'])
+ ->withoutVerifying();
+
+ $this->assertFalse($request->options()['verify']);
+ }
+
/**
* Create an object with operation-owned request options.
*/
@@ -135,17 +139,4 @@ protected function request(): object
use HasOptions;
};
}
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
}
diff --git a/tests/Saloon/Unit/RequestProperties/HeadersTest.php b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
index 50b63a59fd..41f844a26f 100644
--- a/tests/Saloon/Unit/RequestProperties/HeadersTest.php
+++ b/tests/Saloon/Unit/RequestProperties/HeadersTest.php
@@ -4,16 +4,11 @@
namespace Hypervel\Tests\Saloon\Unit\RequestProperties;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
-use Hypervel\RateLimiter\RateLimiter;
-use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
-use Hypervel\Saloon\Http\Request;
use Hypervel\Tests\Saloon\Fixtures\Connectors\HeaderConnector;
use Hypervel\Tests\Saloon\Fixtures\Requests\HeaderRequest;
use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest;
use Hypervel\Tests\TestCase;
-use Mockery as m;
class HeadersTest extends TestCase
{
@@ -60,7 +55,7 @@ public function testHeadersCanBeManagedOnAConnector(): void
// Connectors are read-only and may be shared between coroutines, so their default headers are managed on
// the pending request that merges them.
$connector = new HeaderConnector;
- $pendingRequest = $this->pendingRequest($connector, new UserRequest);
+ $pendingRequest = new PendingRequest($connector, new UserRequest);
$pendingRequest->withHeader('Content-Type', 'custom/saloon');
$pendingRequest->replaceHeaders([
@@ -89,7 +84,7 @@ public function testHeadersCanBeManagedOnAConnector(): void
public function testRemovingAHeaderFromARequestDoesNotRemoveConnectorDefaults(): void
{
$request = (new HeaderRequest)->withoutHeader('X-Connector-Header');
- $pendingRequest = $this->pendingRequest(new HeaderConnector, $request);
+ $pendingRequest = new PendingRequest(new HeaderConnector, $request);
$this->assertSame('Sam', $pendingRequest->headers()['X-Connector-Header']);
@@ -159,16 +154,13 @@ public function testAcceptReplacesTheExistingAcceptHeader(): void
$this->assertSame('application/json', $request->headers()['Accept']);
}
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
+ public function testWithUserAgentReplacesTheExistingUserAgentHeader(): void
{
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ $request = (new UserRequest)
+ ->withHeaders(['user-agent' => 'Legacy'])
+ ->withUserAgent(' Hypervel ');
+
+ $this->assertSame(['User-Agent' => 'Hypervel'], $request->headers());
+ $this->assertSame('', $request->withUserAgent(false)->headers()['User-Agent']);
}
}
diff --git a/tests/Saloon/Unit/RequestProperties/QueryTest.php b/tests/Saloon/Unit/RequestProperties/QueryTest.php
index 95c6fd83b2..95c921a729 100644
--- a/tests/Saloon/Unit/RequestProperties/QueryTest.php
+++ b/tests/Saloon/Unit/RequestProperties/QueryTest.php
@@ -4,10 +4,7 @@
namespace Hypervel\Tests\Saloon\Unit\RequestProperties;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
-use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\Http\Request;
use Hypervel\Tests\Saloon\Fixtures\Connectors\QueryParameterConnector;
@@ -15,7 +12,6 @@
use Hypervel\Tests\Saloon\Fixtures\Requests\QueryParameterConnectorRequest;
use Hypervel\Tests\Saloon\Fixtures\Requests\QueryParameterRequest;
use Hypervel\Tests\TestCase;
-use Mockery as m;
class QueryTest extends TestCase
{
@@ -52,7 +48,7 @@ public function testQueryParametersCanBeManagedOnAConnector(): void
// Connectors are read-only and may be shared between coroutines, so their default query parameters are
// managed on the pending request that merges them.
$connector = new QueryParameterConnector;
- $pendingRequest = $this->pendingRequest($connector, new QueryParameterConnectorBlankRequest);
+ $pendingRequest = new PendingRequest($connector, new QueryParameterConnectorBlankRequest);
$pendingRequest->withQueryParameters(['page' => 1]);
$pendingRequest->withQueryParameters(['search' => 'Sam', 'category' => 'Cowboy', 'sort' => 'last_name']);
@@ -76,7 +72,7 @@ public function testQueryParametersCanBeManagedOnAConnector(): void
public function testRemovingAQueryParameterFromARequestDoesNotRemoveConnectorDefaults(): void
{
$request = (new QueryParameterConnectorRequest)->withoutQueryParameters('sort');
- $pendingRequest = $this->pendingRequest(new QueryParameterConnector, $request);
+ $pendingRequest = new PendingRequest(new QueryParameterConnector, $request);
$this->assertSame(['sort' => 'first_name', 'include' => 'user'], $pendingRequest->queryParameters());
@@ -87,7 +83,7 @@ public function testRemovingAQueryParameterFromARequestDoesNotRemoveConnectorDef
public function testRemovingAQueryParameterInvalidatesTheFinalizedUri(): void
{
- $pendingRequest = $this->pendingRequest(new QueryParameterConnector, new QueryParameterConnectorRequest)
+ $pendingRequest = (new PendingRequest(new QueryParameterConnector, new QueryParameterConnectorRequest))
->finalizeUri();
$this->assertSame('sort=first_name&include=user', $pendingRequest->uri()->getQuery());
@@ -103,7 +99,7 @@ public function testRemovingAQueryParameterInvalidatesTheFinalizedUri(): void
public function testRemovingAQueryParameterKeepsValuesWrittenInTheEndpointOrQueryString(): void
{
- $pendingRequest = $this->pendingRequest(
+ $pendingRequest = new PendingRequest(
new QueryParameterConnector,
new QueryParameterRequest('/user?sort=endpoint'),
);
@@ -132,19 +128,6 @@ public function testRawQueryDefaultsOverridesAndCloneIsolation(): void
$this->assertSame('cursor=a%2Fb', $request->queryString());
$this->assertSame(['limit' => 10], $clone->queryParameters());
}
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
- }
}
class RawQueryRequestStub extends Request
diff --git a/tests/Saloon/Unit/RequestTest.php b/tests/Saloon/Unit/RequestTest.php
index c939798a06..727883a969 100644
--- a/tests/Saloon/Unit/RequestTest.php
+++ b/tests/Saloon/Unit/RequestTest.php
@@ -4,9 +4,8 @@
namespace Hypervel\Tests\Saloon\Unit;
-use ArgumentCountError;
use GuzzleHttp\Cookie\SetCookie;
-use Hypervel\Container\Container;
+use Hypervel\Contracts\Config\Repository;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Exceptions\InvalidResponseClassException;
@@ -189,23 +188,20 @@ public function testItThrowsAnExceptionIfYouForgetToAddAMethod(): void
$connector->send($request);
}
- public function testContainerResolutionAlwaysReturnsAFreshRequest(): void
+ public function testContainerResolutionAlwaysReturnsAFreshRequestWithItsDependencies(): void
{
- $container = new Container;
-
- $first = $container->make(ContainerRequestStub::class);
- $second = $container->make(ContainerRequestStub::class);
+ $first = $this->app->make(ContainerRequestStub::class);
+ $second = $this->app->make(ContainerRequestStub::class);
$this->assertNotSame($first, $second);
+ $this->assertSame($this->app->make('config'), $first->config);
}
- public function testContainerResolutionOfRequiredArgumentRequestFailsNaturally(): void
+ public function testContainerResolutionPassesRequestParameters(): void
{
- $container = new Container;
-
- $this->expectException(ArgumentCountError::class);
+ $request = $this->app->make(RequiredArgumentRequestStub::class, ['endpoint' => 'users/1']);
- $container->make(RequiredArgumentRequestStub::class);
+ $this->assertSame('users/1', $request->resolveEndpoint());
}
#[DataProvider('invalidCookies')]
@@ -214,7 +210,7 @@ public function testWithCookieRejectsInvalidCookies(array $cookie, string $messa
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($message);
- (new ContainerRequestStub)->withCookie(new SetCookie($cookie));
+ (new UserRequest)->withCookie(new SetCookie($cookie));
}
/**
@@ -260,6 +256,13 @@ class ContainerRequestStub extends Request
{
protected Method $method = Method::GET;
+ /**
+ * Create a new request instance.
+ */
+ public function __construct(public readonly Repository $config)
+ {
+ }
+
/**
* Resolve the request endpoint.
*/
diff --git a/tests/Saloon/Unit/ResponseTest.php b/tests/Saloon/Unit/ResponseTest.php
index 0df61b4588..f1e24eec72 100644
--- a/tests/Saloon/Unit/ResponseTest.php
+++ b/tests/Saloon/Unit/ResponseTest.php
@@ -8,11 +8,9 @@
use GuzzleHttp\Psr7\Request as PsrRequest;
use GuzzleHttp\Psr7\Response as PsrResponse;
use GuzzleHttp\Psr7\Utils;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Http\Client\Response as HttpResponse;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Exceptions\Request\ClientException;
use Hypervel\Saloon\Exceptions\Request\RequestException;
@@ -770,12 +768,7 @@ protected function response(
*/
protected function pendingRequest(Connector $connector, Request $request): PendingRequest
{
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest($connector, $request);
}
}
diff --git a/tests/Saloon/Unit/SenderTest.php b/tests/Saloon/Unit/SenderTest.php
index 6c7838bfa9..7c4124d7af 100644
--- a/tests/Saloon/Unit/SenderTest.php
+++ b/tests/Saloon/Unit/SenderTest.php
@@ -6,13 +6,11 @@
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Promise\PromiseInterface;
-use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Contracts\Telescope\TelescopeTag;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Client\Request as HttpRequest;
-use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Facades\Saloon;
use Hypervel\Saloon\Http\Auth\BasicAuthenticator;
@@ -169,12 +167,7 @@ protected function setUp(): void
*/
protected function pendingRequest(Connector $connector, Request $request): PendingRequest
{
- return new PendingRequest(
- $connector,
- $request,
- m::mock(CacheFactory::class),
- m::mock(RateLimiter::class),
- );
+ return new PendingRequest($connector, $request);
}
/**
From 0a2cc836671926cb438994b047e58db431cc8615 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 04:21:54 +0000
Subject: [PATCH 17/26] Remove Saloon's generic service getters and restore
upstream stubs
SaloonManager exposed sender(), cache() and rateLimiter(), which
returned the injected HTTP sender, cache factory and rate limiter
unchanged. Nothing called them, and none carried Saloon-specific
configuration: the cache and rate-limiter services are available
through the framework's own facades and contracts, and Saloon's own
operations on them stay available through clearCache() and
inspectRateLimit(). The getters and their facade annotations are
removed.
The package no longer requires hypervel/reflection, which it never
used. The package metadata test now lists guzzlehttp/uri-template,
which the package already requires. Saloon's Guzzle 8 and PSR-7 3
constraints are unchanged; the suite runs on Guzzle 8.2.
The make:saloon stubs regain upstream's placeholder comments and the
authenticator's empty constructor. The HTTP connection config comment
now describes the connection plainly.
Three unit tests still built pending requests through a one-line
helper left from the previous constructor change; they now construct
PendingRequest directly.
A send benchmark without network access measured about 200 us per
Saloon request against about 130 us for the same HTTP client call,
spread over small steps; no change is needed.
Validation: each changed test file, the Saloon suite (ParaTest),
tests/Data/Saloon, FacadeDocblocksTest,
Composer/PackageManifestConsistencyTest, composer analyse and
php-cs-fixer.
---
src/saloon/composer.json | 1 -
src/saloon/config/saloon.php | 6 ++--
src/saloon/src/Facades/Saloon.php | 4 ---
src/saloon/src/SaloonManager.php | 24 ----------------
src/saloon/stubs/saloon.authenticator.stub | 9 ++++++
src/saloon/stubs/saloon.plugin.stub | 1 +
src/saloon/stubs/saloon.response.stub | 1 +
tests/Saloon/PackageMetadataTest.php | 2 +-
tests/Saloon/Unit/PendingRequestTest.php | 32 ++++++++--------------
tests/Saloon/Unit/ResponseTest.php | 12 ++------
tests/Saloon/Unit/SenderTest.php | 17 +++---------
11 files changed, 33 insertions(+), 76 deletions(-)
diff --git a/src/saloon/composer.json b/src/saloon/composer.json
index 8296398e13..f7cd319ba3 100644
--- a/src/saloon/composer.json
+++ b/src/saloon/composer.json
@@ -53,7 +53,6 @@
"hypervel/macroable": "^0.4",
"hypervel/prompts": "^0.4",
"hypervel/rate-limiter": "^0.4",
- "hypervel/reflection": "^0.4",
"hypervel/support": "^0.4",
"nesbot/carbon": "^3.13.1",
"psr/http-message": "^2.0",
diff --git a/src/saloon/config/saloon.php b/src/saloon/config/saloon.php
index e40eb10176..2cf479d5f7 100644
--- a/src/saloon/config/saloon.php
+++ b/src/saloon/config/saloon.php
@@ -10,9 +10,9 @@
| HTTP Connection
|--------------------------------------------------------------------------
|
- | Saloon sends requests through this named, worker-lifetime HTTP
- | connection. Its options are an open transport preset and may be
- | adjusted or removed independently.
+ | Saloon sends requests through this named HTTP client connection unless
+ | a connector selects another one. Its options are the defaults for
+ | every request sent through it, and you may change or remove them.
|
*/
diff --git a/src/saloon/src/Facades/Saloon.php b/src/saloon/src/Facades/Saloon.php
index 40f3780c90..246751db4c 100644
--- a/src/saloon/src/Facades/Saloon.php
+++ b/src/saloon/src/Facades/Saloon.php
@@ -5,7 +5,6 @@
namespace Hypervel\Saloon\Facades;
use Closure;
-use Hypervel\Saloon\Http\Faking\MockClient;
use Hypervel\Support\Facades\Facade;
/**
@@ -14,7 +13,6 @@
* @method static void assertSent(callable|string $value)
* @method static void assertSentCount(int $count, null|string $requestClass = null)
* @method static void assertSentInOrder(array $callbacks)
- * @method static \Hypervel\Contracts\Cache\Factory cache()
* @method static void clearCache(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request)
* @method static \Hypervel\Saloon\SaloonManager clearFake()
* @method static \Hypervel\Saloon\Http\PendingRequest createPendingRequest(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request)
@@ -24,11 +22,9 @@
* @method static ($policy is \Hypervel\RateLimiter\Cooldown ? \Hypervel\RateLimiter\CooldownResult : \Hypervel\RateLimiter\LimitResult) inspectRateLimit(\Hypervel\Saloon\Http\Connector|\Hypervel\Saloon\Http\Request $resource, \Hypervel\RateLimiter\AdmissionPolicy|\Hypervel\RateLimiter\Cooldown $policy)
* @method static \Hypervel\Saloon\Http\MiddlewarePipeline middleware()
* @method static \Hypervel\Saloon\Http\Faking\MockClient|null mockClient()
- * @method static \Hypervel\RateLimiter\RateLimiter rateLimiter()
* @method static string|null resolveCacheScope(\Hypervel\Saloon\Http\PendingRequest $pendingRequest)
* @method static \Hypervel\Saloon\SaloonManager resolveCacheScopeUsing(null|Closure $resolver)
* @method static \Hypervel\Saloon\Http\Response send(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request, \Hypervel\Saloon\Http\Faking\MockClient|null $mockClient = null)
- * @method static \Hypervel\Saloon\Http\Sender sender()
* @method static void setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events)
* @method static \Hypervel\Saloon\SaloonManager throwOnMissingFixtures(bool $throw = true)
* @method static bool throwsOnMissingFixtures()
diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php
index 290c026851..79c2247e8b 100644
--- a/src/saloon/src/SaloonManager.php
+++ b/src/saloon/src/SaloonManager.php
@@ -280,30 +280,6 @@ public function createPendingRequest(Connector $connector, Request $request): Pe
return $pendingRequest->executeRequestPipeline();
}
- /**
- * Get the Saloon sender.
- */
- public function sender(): Sender
- {
- return $this->sender;
- }
-
- /**
- * Get the cache factory.
- */
- public function cache(): CacheFactory
- {
- return $this->cache;
- }
-
- /**
- * Get the rate limiter manager.
- */
- public function rateLimiter(): RateLimiter
- {
- return $this->rateLimiter;
- }
-
/**
* Inspect a connector or request rate limit without consuming it.
*
diff --git a/src/saloon/stubs/saloon.authenticator.stub b/src/saloon/stubs/saloon.authenticator.stub
index cced117ecc..9cffc98729 100644
--- a/src/saloon/stubs/saloon.authenticator.stub
+++ b/src/saloon/stubs/saloon.authenticator.stub
@@ -9,10 +9,19 @@ use Hypervel\Saloon\Http\PendingRequest;
class {{ class }} implements Authenticator
{
+ /**
+ * Create a new authenticator instance.
+ */
+ public function __construct()
+ {
+ //
+ }
+
/**
* Apply the authentication to the request.
*/
public function set(PendingRequest $pendingRequest): void
{
+ //
}
}
diff --git a/src/saloon/stubs/saloon.plugin.stub b/src/saloon/stubs/saloon.plugin.stub
index 9b00e4b54a..3451012c52 100644
--- a/src/saloon/stubs/saloon.plugin.stub
+++ b/src/saloon/stubs/saloon.plugin.stub
@@ -13,5 +13,6 @@ trait {{ class }}
*/
public function boot{{ class }}(PendingRequest $pendingRequest): void
{
+ //
}
}
diff --git a/src/saloon/stubs/saloon.response.stub b/src/saloon/stubs/saloon.response.stub
index 1877ea0d01..dc2f30ff8b 100644
--- a/src/saloon/stubs/saloon.response.stub
+++ b/src/saloon/stubs/saloon.response.stub
@@ -8,4 +8,5 @@ use Hypervel\Saloon\Http\Response;
class {{ class }} extends Response
{
+ //
}
diff --git a/tests/Saloon/PackageMetadataTest.php b/tests/Saloon/PackageMetadataTest.php
index 798856ab94..d2326908e2 100644
--- a/tests/Saloon/PackageMetadataTest.php
+++ b/tests/Saloon/PackageMetadataTest.php
@@ -29,6 +29,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void
'ext-simplexml',
'guzzlehttp/guzzle',
'guzzlehttp/psr7',
+ 'guzzlehttp/uri-template',
'hypervel/cache',
'hypervel/collections',
'hypervel/conditionable',
@@ -43,7 +44,6 @@ public function testDirectRuntimeDependenciesAreDeclared(): void
'hypervel/macroable',
'hypervel/prompts',
'hypervel/rate-limiter',
- 'hypervel/reflection',
'hypervel/support',
'nesbot/carbon',
'psr/http-message',
diff --git a/tests/Saloon/Unit/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
index 3b81203453..d89766c46a 100644
--- a/tests/Saloon/Unit/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -154,7 +154,7 @@ public function testUrlParametersAreExpandedWithoutChangingTheRequest(): void
$request = (new PendingRequestRequestStub)
->withUrl('https://{region}.example.com/{version}/users')
->withUrlParameters(['region' => 'eu', 'version' => 'v1']);
- $pendingRequest = $this->pendingRequest(new PendingRequestConnectorWithoutBodyStub, $request);
+ $pendingRequest = new PendingRequest(new PendingRequestConnectorWithoutBodyStub, $request);
$this->assertSame('https://eu.example.com/v1/users', (string) $pendingRequest->finalizeUri()->uri());
@@ -167,7 +167,7 @@ public function testUrlParametersAreExpandedWithoutChangingTheRequest(): void
public function testUrlParametersAreExpandedInTheBaseUrlAndEndpoint(): void
{
$request = (new PendingTemplateRequestStub)->withUrlParameters(['tenant' => 'acme', 'id' => 'a b/c']);
- $pendingRequest = $this->pendingRequest(new PendingTemplateConnectorStub, $request);
+ $pendingRequest = new PendingRequest(new PendingTemplateConnectorStub, $request);
$this->assertSame('https://acme.example.com/v1/users/a%20b%2Fc', (string) $pendingRequest->uri());
}
@@ -185,7 +185,7 @@ public function testConstructionOnlySnapshotsOperationState(): void
++PendingRequestRequestStub::$middlewareCalls;
});
- $pendingRequest = $this->pendingRequest($connector, $request);
+ $pendingRequest = new PendingRequest($connector, $request);
$this->assertSame(0, PendingRequestConnectorStub::$bootCalls);
$this->assertSame(0, PendingRequestRequestStub::$bootCalls);
@@ -222,7 +222,7 @@ public function testUriAndBodyAreFinalizedAfterRequestMiddleware(): void
public function testRawQuerySnapshotsDefaultsAndReplacesBothUrlQueries(): void
{
$request = new PendingRawQueryRequestStub;
- $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, $request);
+ $pendingRequest = new PendingRequest(new PendingRawQueryConnectorStub, $request);
$request->withQueryString('changed=after-snapshot');
$pendingRequest->finalizeUri();
@@ -242,7 +242,7 @@ public function testRawQueryReplacementRetainsArrayAndAuthenticationOverlays():
$request = (new PendingRawQueryRequestStub)
->withQueryString('tag=a&tag=b&token=old&token=older')
->withQueryParameters(['limit' => 10]);
- $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request);
+ $pendingRequest = new PendingRequest(new PendingRequestConnectorStub, $request);
$pendingRequest->authenticate(new QueryAuthenticator('token', 'secret'))->finalizeUri();
@@ -257,7 +257,7 @@ public function testRawQueryReplacementRetainsArrayAndAuthenticationOverlays():
public function testNullRawQueryKeepsTheOriginalUrlQuery(): void
{
- $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, new PendingRequestRequestStub);
+ $pendingRequest = new PendingRequest(new PendingRawQueryConnectorStub, new PendingRequestRequestStub);
$this->assertNull($pendingRequest->queryString());
$this->assertSame('base=old', $pendingRequest->uri()->getQuery());
@@ -268,7 +268,7 @@ public function testFullUrlOverrideIsVisibleToAuthenticationAndKeepsQueryOverlay
$request = (new PendingRequestRequestStub)
->withUrl('https://uploads.example.com/files?tag=a&tag=b&token=old')
->withQueryParameters(['limit' => 10]);
- $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request);
+ $pendingRequest = new PendingRequest(new PendingRequestConnectorStub, $request);
$pendingRequest
->authenticate(new CookieAuthenticator('session', 'secret'))
@@ -287,7 +287,7 @@ public function testFullUrlOverrideIsVisibleToAuthenticationAndKeepsQueryOverlay
#[DataProvider('invalidFullUrls')]
public function testFullUrlOverrideRequiresAnAbsoluteHttpUrl(string $url): void
{
- $pendingRequest = $this->pendingRequest(
+ $pendingRequest = new PendingRequest(
new PendingRequestConnectorStub,
(new PendingRequestRequestStub)->withUrl($url),
);
@@ -310,13 +310,13 @@ public function testConnectorAndRequestBodyTypesMustMatch(): void
$this->expectException(PendingRequestException::class);
$this->expectExceptionMessage('Connector and request body types must be the same.');
- $this->pendingRequest(new PendingRequestConnectorStub, new PendingRequestStringBodyStub);
+ new PendingRequest(new PendingRequestConnectorStub, new PendingRequestStringBodyStub);
}
public function testLogicalPsrRequestReusesAnAlreadyPreparedBody(): void
{
CountingBodyRepository::$streamCalls = 0;
- $pendingRequest = $this->pendingRequest(
+ $pendingRequest = new PendingRequest(
new PendingRequestConnectorWithoutBodyStub,
new PendingRequestCountingBodyStub,
);
@@ -330,7 +330,7 @@ public function testLogicalPsrRequestReusesAnAlreadyPreparedBody(): void
public function testRequiresAuthRetainsItsProtectedMessageHook(): void
{
- $pendingRequest = $this->pendingRequest(
+ $pendingRequest = new PendingRequest(
new PendingRequestConnectorWithoutBodyStub,
new CustomRequiresAuthRequestStub,
);
@@ -343,7 +343,7 @@ public function testRequiresAuthRetainsItsProtectedMessageHook(): void
public function testAuthenticatorsReplaceLogicalHeadersRegardlessOfCase(): void
{
- $pendingRequest = $this->pendingRequest(
+ $pendingRequest = new PendingRequest(
new PendingRequestConnectorWithoutBodyStub,
new PendingRequestRequestStub,
);
@@ -384,14 +384,6 @@ protected function setUp(): void
PendingRequestRequestStub::$bootCalls = 0;
PendingRequestRequestStub::$middlewareCalls = 0;
}
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest($connector, $request);
- }
}
class PendingRequestConnectorStub extends Connector
diff --git a/tests/Saloon/Unit/ResponseTest.php b/tests/Saloon/Unit/ResponseTest.php
index f1e24eec72..2855690fb1 100644
--- a/tests/Saloon/Unit/ResponseTest.php
+++ b/tests/Saloon/Unit/ResponseTest.php
@@ -631,7 +631,7 @@ public function testThrowPolicyCanSuppressAResponseThatStillReportsFailed(): voi
public function testNonSeekableBodyIsBufferedOnce(): void
{
- $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub);
+ $pendingRequest = new PendingRequest(new ResponseConnectorStub, new ResponseRequestStub);
$psrRequest = new PsrRequest('GET', 'https://api.example.com/users');
$httpResponse = new HttpResponse(new PsrResponse(
body: new NoSeekStream(Utils::streamFor('response body')),
@@ -753,7 +753,7 @@ protected function response(
): Response {
$connector ??= new ResponseConnectorStub;
$request ??= new ResponseRequestStub;
- $pendingRequest ??= $this->pendingRequest($connector, $request);
+ $pendingRequest ??= new PendingRequest($connector, $request);
$psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users');
return Response::fromResponse(
@@ -762,14 +762,6 @@ protected function response(
$psrRequest,
);
}
-
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest($connector, $request);
- }
}
class ResponseConnectorStub extends Connector
diff --git a/tests/Saloon/Unit/SenderTest.php b/tests/Saloon/Unit/SenderTest.php
index 7c4124d7af..0ae623c608 100644
--- a/tests/Saloon/Unit/SenderTest.php
+++ b/tests/Saloon/Unit/SenderTest.php
@@ -12,7 +12,6 @@
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Client\Request as HttpRequest;
use Hypervel\Saloon\Enums\Method;
-use Hypervel\Saloon\Facades\Saloon;
use Hypervel\Saloon\Http\Auth\BasicAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
@@ -34,7 +33,7 @@ class SenderTest extends TestCase
public function testTheDefaultConnectionOnAllConnectorsIsTheSaloonConnection(): void
{
$connector = new TestConnector;
- $sender = Saloon::sender();
+ $sender = $this->app->make(Sender::class);
$pendingRequest = $connector->createPendingRequest(new UserRequest);
@@ -42,7 +41,7 @@ public function testTheDefaultConnectionOnAllConnectorsIsTheSaloonConnection():
// Test the same instance is re-used
- $this->assertSame($sender, Saloon::sender());
+ $this->assertSame($sender, $this->app->make(Sender::class));
}
// Upstream also overrides the sender through a connector property; connectors select their connection only
@@ -95,7 +94,7 @@ public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection():
->withTelescopeTags(['operation'])
->authenticate(new BasicAuthenticator('taylor', 'secret'))
->withData(['name' => 'Taylor']);
- $pendingRequest = $this->pendingRequest(new SenderConnectorStub, $request)
+ $pendingRequest = (new PendingRequest(new SenderConnectorStub, $request))
->applyAuthentication()
->executeRequestPipeline()
->finalizeUri()
@@ -133,7 +132,7 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag():
return Factory::response();
});
$request = (new SenderRequestStub)->withoutTelescope();
- $pendingRequest = $this->pendingRequest(new SenderConnectorStub, $request)
+ $pendingRequest = (new PendingRequest(new SenderConnectorStub, $request))
->finalizeUri()
->prepareBody();
$sender = new Sender($http, $this->config());
@@ -162,14 +161,6 @@ protected function setUp(): void
SenderRequestStub::$psrHookCalls = 0;
}
- /**
- * Create a pending request with isolated framework dependencies.
- */
- protected function pendingRequest(Connector $connector, Request $request): PendingRequest
- {
- return new PendingRequest($connector, $request);
- }
-
/**
* Create package configuration for the sender.
*/
From f524cf2ba80a2761b3130192d8657a96d6780e6e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 04:46:40 +0000
Subject: [PATCH 18/26] Map the remaining Saloon plugin surfaces and open
CacheKey
Comparing the public and protected surfaces of upstream's cache,
pagination and rate-limit plugins with Hypervel's found no code gaps,
but the README did not name the Hypervel equivalents for several
upstream APIs. It now covers CacheKeyHelper, the cache middleware and
expiry methods, LimitException, Limit::allow()'s threshold,
Limit::custom(), RetryAfterHelper, $detectTooManyAttempts, the
exceeded-limit hooks and per-user limit names.
CacheKey is no longer final, since it protects no invariant. A short
comment explains why it hashes with sha256 instead of xxh128: the
request identity includes credentials, so a forged collision would
return another caller's cached response.
The private SDK generator and generated SDKs were verified against
this branch, with two test updates made in that repository.
Validation: the Saloon suite (ParaTest), tests/Saloon/Cache,
composer analyse and php-cs-fixer; the private SDK generator suite,
its analysis configurations, the seven generated SDK test suites and
generated-code analysis.
---
src/saloon/README.md | 4 ++--
src/saloon/src/Cache/CacheKey.php | 4 +++-
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 70a1dbc867..8fa31ea927 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -20,11 +20,11 @@ Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRet
Pools send requests concurrently through coroutines instead of promises, so there is no `sendAsync()`, pending requests have no `isAsynchronous()` or `setAsynchronous()`, and pools accept requests but not promises. A pool's `send()` waits for every request and returns the successful responses keyed and ordered as the requests were given; `process()` sends them without keeping the responses. Concurrency is a positive integer: upstream's callable and unlimited `0` forms are not supported, because the coroutine pool has a fixed number of slots. Handlers receive the response or exception and the request's key, without upstream's aggregate promise. Ending a request generator stops further requests, but a request already waiting for a slot still starts and running requests are not canceled. Every response that `toException()` treats as failed, including through custom failure hooks, goes to the exception handler; upstream's real asynchronous path checks them only after Guzzle rejects a transfer. Unhandled request failures, handler failures and invalid items throw `PoolException` once every started request has finished, instead of being ignored or ending the pool early. `requests()` replaces `getRequests()` and calls a request callback each time the pool is sent, so a callback that returns a new iterable lets the pool be sent again. See [Concurrent Requests](https://hypervel.org/docs/saloon#concurrent-requests).
-Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cacheStore()` instead of upstream's `resolveCacheDriver()` and `cacheExpiryInSeconds()`, so there is no `Driver` contract and no Flysystem, PSR or Laravel driver; add other caches as framework cache stores. `CachingException` replaces `HasCachingException`. Connectors are read-only, so they implement `Cacheable` without the `HasCaching` request controls and override `cacheKey()` and `cacheableMethods()`, which replaces `getCacheableMethods()`. GET, HEAD, OPTIONS and QUERY requests are cacheable by default. See [Caching](https://hypervel.org/docs/saloon#caching).
+Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cacheStore()` instead of upstream's `resolveCacheDriver()` and `cacheExpiryInSeconds()`, so there is no `Driver` contract and no Flysystem, PSR or Laravel driver; add other caches as framework cache stores. `CachingException` replaces `HasCachingException`. Connectors are read-only, so they implement `Cacheable` without the `HasCaching` request controls and override `cacheKey()` and `cacheableMethods()`, which replaces `getCacheableMethods()`. GET, HEAD, OPTIONS and QUERY requests are cacheable by default. There is no `CacheKeyHelper`: a custom `cacheKey()` replaces the default request identity, and `Saloon::resolveCacheScopeUsing()` adds a scope, such as the tenant, to every key. Cached entries are `CachedResponse` values holding the status, headers and body, and they expire through the cache store, so `CacheMiddleware`, `CacheRecorderMiddleware`, `hasExpired()` and `getFakeResponse()` are not included. See [Caching](https://hypervel.org/docs/saloon#caching).
Paginators set their values with `maxPages()`, `perPageLimit()` and `startPage()`, and `totalResults()`, `request()` and `currentPage()` replace `getTotalResults()`, `getOriginalRequest()` and `getCurrentPage()`. The deprecated `getPage()` and `$page` are not included; custom `applyPagination()` methods read the protected `$pageNumber`. Page and offset paginators send their pages concurrently with `pool()`, which sends the first page, then the rest through a coroutine pool, and returns the responses keyed by page position, so there is no `async()`, `isAsyncPaginationEnabled()` or `HasAsyncPagination` and no promises to iterate. Cursor paginators can't be pooled because each cursor comes from the previous response. Iterator keys and `currentPage()` count from zero at the start page, and `maxPages()` counts pages from the start page. Upstream starts keys at `startPage - 1` and compares `maxPages` with page numbers, so `startPage(3)` with `maxPages(2)` returns no pages there. See [API Pagination](https://hypervel.org/docs/saloon#api-pagination).
-Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract or plugin store. `Limit::perMinute()` and the other `Limit` factories replace upstream's `every*()` intervals, `LeakyBucket` replaces `Bucket`, and `CalendarWindow` replaces `untilEndOfMinute()`, `untilEndOfHour()`, `untilMidnightTonight()`, `everyDayUntil()` and `untilEndOfMonth()`. The rate limiter's Redis, Swoole, database and worker-array stores replace upstream's stores. There are no file, Laravel cache or PSR cache stores, since a general cache can't apply these policies atomically, and no Predis store, since the Redis store uses the framework's PhpRedis connections. Identical policies share one state and each charges it, rather than throwing upstream's duplicate-name exception. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()` and `$detectTooManyAttempts`. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again.
+Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` and `getLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract, `rateLimitStore()` or plugin store. `LimitException` is not included; invalid policies and store state throw the rate limiter's own exceptions. `Limit::perMinute()` and the other `Limit` factories replace upstream's `every*()` intervals, `LeakyBucket` replaces `Bucket`, and `CalendarWindow` replaces `untilEndOfMinute()`, `untilEndOfHour()`, `untilMidnightTonight()`, `everyDayUntil()` and `untilEndOfMonth()`. Set a lower limit in place of `Limit::allow()`'s threshold. The rate limiter's Redis, Swoole, database and worker-array stores replace upstream's stores. There are no file, Laravel cache or PSR cache stores, since a general cache can't apply these policies atomically, and no Predis store, since the Redis store uses the framework's PhpRedis connections. Identical policies share one state and each charges it, rather than throwing upstream's duplicate-name exception. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit and the `handleExceededLimit()` and `throwLimitException()` hooks, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys in place of `name()` for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()`, `Limit::custom()`, `RetryAfterHelper` and `$detectTooManyAttempts`; override it to read other responses, or return `null` to record nothing. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again.
Connectors are read-only, so `rateLimitingEnabled(PendingRequest)` replaces `useRateLimitPlugin()` and `$rateLimitingEnabled`, and `Saloon::inspectRateLimit()` replaces `hasReachedRateLimit()` and `getExceededLimit()`; compare `remaining()` with `limit()` in place of a threshold. `RateLimitReachedException` exposes `policy()` and `result()` instead of `getLimit()`, and the job middleware is `Queue\ReleaseOnRateLimit` instead of `Helpers\ApiRateLimited`. See [Rate Limiting](https://hypervel.org/docs/saloon#rate-limiting).
diff --git a/src/saloon/src/Cache/CacheKey.php b/src/saloon/src/Cache/CacheKey.php
index b47bcf6b4f..dd5bc98383 100644
--- a/src/saloon/src/Cache/CacheKey.php
+++ b/src/saloon/src/Cache/CacheKey.php
@@ -9,7 +9,7 @@
use Hypervel\Saloon\Http\HeaderNormalizer;
use Hypervel\Saloon\Http\PendingRequest;
-final class CacheKey
+class CacheKey
{
/**
* Options that can change a successful response.
@@ -50,6 +50,8 @@ public function make(
? $this->requestIdentity($pendingRequest, $transportOptions)
: ['custom' => $customKey];
+ // sha256 rather than xxh128: the identity includes credentials, and a forged collision would return another
+ // caller's cached response.
return 'saloon:' . hash('sha256', $this->encode([$identity, 'scope' => $scope]));
}
From 2fcdbcaa8095853aa76579f1609ded48a0a3d657 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:08:38 +0000
Subject: [PATCH 19/26] Reconcile the core Saloon guide with upstream's
documentation
The guide was compared with Saloon v3's documentation from connectors
through concurrency and testing, and every claim and example was
checked against Hypervel's source.
Corrections: the shouldThrowRequestException example could never stop
a 404 from throwing, since a response throws when either the request's
or the connector's hook returns true. The guide now separates failure
classification from throwing, retries and caching. Request data
conversion applies to Hypervel's Stringable, not any stringable value.
The differences list no longer mentions OAuth 1, which upstream never
had.
Added coverage includes the HTTP connection defaults and rejected
request-shaping options, standalone request credentials, query string
encoding, built-in and combined authenticators, multipart bodies,
request-instance middleware and fake responses, data objects, the
status exception table, retry forms and exhaustion, debug output,
OAuth endpoint defaults, scopes, state and token storage, pool
concurrency, mock client assertions and fixture recording.
Fixture recording stays subject to Http::preventStrayRequests(); the
guide and README say to allow the API's URLs while recording.
Validation: documentation only; every in-page link resolves to an
anchor.
---
src/docs/saloon.md | 309 +++++++++++++++++++++++++++++++++++--------
src/saloon/README.md | 2 +-
2 files changed, 257 insertions(+), 54 deletions(-)
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 0742d50edb..3642f947a4 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -252,6 +252,17 @@ public function boot(PendingRequest $pendingRequest): void
### Organizing SDKs
+A connector may serve as an SDK's entry point by offering methods that send its requests:
+
+```php
+use Hypervel\Saloon\Http\Response;
+
+public function user(string $username): Response
+{
+ return $this->send(new GetUser($username));
+}
+```
+
When an integration contains many endpoints, you may group related requests into resource classes. Extend `BaseResource` and use the `@extends` annotation to specify your connector type:
```php
@@ -288,9 +299,18 @@ Your resource may access the connector through its protected, readonly `$connect
### HTTP Connections
-Saloon registers one named Hypervel HTTP connection, named `saloon` by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar.
+Saloon registers one named Hypervel [HTTP connection](/docs/{{version}}/http-client#connections), named `saloon` by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar.
-You may publish the configuration file to change the default connection options. A connector may also select another connection that your application registered during worker boot:
+The `saloon` connection uses a 10-second connection timeout and a 30-second request timeout. You may [publish the configuration file](#publishing-configuration-and-stubs) to change its options. A connector may also select another connection that your application registers during worker boot:
+
+```php
+use Hypervel\Support\Facades\Http;
+
+Http::registerConnection('github', [
+ 'connect_timeout' => 5,
+ 'timeout' => 60,
+]);
+```
```php
public function resolveHttpConnection(): ?string
@@ -299,6 +319,8 @@ public function resolveHttpConnection(): ?string
}
```
+A connection used by Saloon may not contain options that shape the request, such as `headers`, `query`, `cookies`, `json`, and `auth`, because Saloon builds those from the connector and request. Define them on the connector instead. Saloon requests also receive the HTTP client's [global options](/docs/{{version}}/http-client#global-options) and [global middleware](/docs/{{version}}/http-client#global-middleware).
+
Connection names must come from a fixed, bounded set. Do not create a connection name from a tenant ID, hostname, or credential, since registered connections and their handlers remain in memory for the worker lifetime. One fixed connection may safely send requests to many tenant-selected hosts.
Connection options do not provide an aggregate connection cap. The unsafe Guzzle `max_host_connections` and `max_total_connections` options are rejected. Bound each concurrent operation with a [Saloon pool](#concurrent-requests), and use [rate limits](#rate-limiting) to control request rates. Saloon does not use object pooling because each request needs its own client, middleware stack, and cookie jar.
@@ -410,6 +432,11 @@ class GetStatus extends SoloRequest
$response = (new GetStatus)->send();
```
+Standalone requests define their headers, query parameters, options, authentication, and body using the same [default methods](#request-defaults) as other requests.
+
+> [!WARNING]
+> A standalone request sends its headers and credentials to whatever URL `resolveEndpoint` returns. Do not build the endpoint from user input.
+
A request that always uses the same connector may send itself with the `HasConnector` trait. Define the connector class in a `$connector` property:
```php
@@ -564,6 +591,8 @@ $request->withQueryParameters([
Request values replace connector values with the same key. Values added later by middleware replace earlier values. Query parameters already present in the connector base URL or request endpoint are preserved unless the request contains the same top-level key.
+Query parameters are encoded using PHP's `http_build_query` function, so arrays use bracketed names such as `filter[active]=1`, booleans become `1` or `0`, and `null` values are left out. When an API expects another format, use the `withQueryString` method described below.
+
The `withoutQueryParameters` method removes parameters added through `withQueryParameters` or `defaultQuery`. As with headers, removing a parameter from a request does not remove a connector default; remove it from the pending request instead. Values embedded in the base URL, endpoint, or `withQueryString` query are unchanged:
```php
@@ -616,6 +645,31 @@ $request->withBasicAuth($username, $password);
$request->withDigestAuth($username, $password);
```
+These methods create authenticators, which you may also use directly. Return an authenticator from a connector or request's `defaultAuth` method, or pass one to `authenticate`. Saloon includes header, query, cookie, token, basic, digest, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`:
+
+```php
+use Hypervel\Saloon\Contracts\Authenticator;
+use Hypervel\Saloon\Http\Auth\HeaderAuthenticator;
+
+protected function defaultAuth(): ?Authenticator
+{
+ return new HeaderAuthenticator($this->key, 'X-Api-Key');
+}
+```
+
+A request's authenticator replaces its connector's. When an API requires more than one form of authentication, such as a client certificate and a token, combine them using `MultiAuthenticator`:
+
+```php
+use Hypervel\Saloon\Http\Auth\CertificateAuthenticator;
+use Hypervel\Saloon\Http\Auth\MultiAuthenticator;
+use Hypervel\Saloon\Http\Auth\QueryAuthenticator;
+
+$request->authenticate(new MultiAuthenticator(
+ new CertificateAuthenticator('/path/to/client.pem', $password),
+ new QueryAuthenticator('api_key', $key),
+));
+```
+
Built-in NTLM authentication is not provided. Integrations requiring NTLM must supply their own authenticator and transport middleware.
You may create a reusable authenticator by implementing `Hypervel\Saloon\Contracts\Authenticator`:
@@ -628,24 +682,26 @@ class ApiKeyAuthenticator implements Authenticator
{
public function __construct(
private readonly string $key,
+ private readonly string $accountId,
) {
}
public function set(PendingRequest $pendingRequest): void
{
- $pendingRequest->withHeader('X-Api-Key', $this->key);
+ $pendingRequest->withHeaders([
+ 'X-Api-Key' => $this->key,
+ 'X-Account-Id' => $this->accountId,
+ ]);
}
}
```
-Apply a custom authenticator using `authenticate`, or return it from a connector's `defaultAuth` method:
+Use a custom authenticator like the built-in ones:
```php
-$request->authenticate(new ApiKeyAuthenticator($key));
+$request->authenticate(new ApiKeyAuthenticator($key, $accountId));
```
-Saloon also includes header, query, cookie, token, basic, digest, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`.
-
For APIs that authenticate using a cookie, use `CookieAuthenticator`:
```php
@@ -704,7 +760,7 @@ class CreateIssue extends Request
}
```
-The JSON, form, and XML traits supply the appropriate content type unless you already defined one. A multipart body always receives `multipart/form-data` with its boundary unless the content type you define declares a boundary. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format; request body values take precedence over connector values.
+The JSON, form, and XML traits supply the appropriate content type unless you already defined one. A multipart body always receives `multipart/form-data` with its boundary unless the content type you define declares a boundary. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format. A request's JSON and form values are merged into the connector's, replacing values with the same key, and its multipart parts are added after the connector's. A request's string, XML, or stream body replaces the connector's.
When both a connector and request define a body, their body repositories must be the same type. For example, a request using `HasFormBody` cannot be sent through a connector using `HasJsonBody`. Saloon throws a `PendingRequestException` when the body types do not match.
@@ -717,6 +773,8 @@ $request->withBody($stream, 'application/octet-stream');
$request->withBody($stream, null);
```
+The `withBody` method accepts a string, stream, or resource. Like the HTTP client's method of the same name, it sets an `application/json` content type unless you pass another one, or `null` to leave the content type unchanged.
+
The `withData` method merges structured values into the current JSON or form body. If the request has no JSON, form, or multipart body, the method creates a JSON body:
```php
@@ -725,7 +783,7 @@ $request->withData(['active' => true]);
On a multipart body, `withData` adds each value as a field after the existing parts. Fields are appended, so repeating a name adds another field instead of replacing the first. Nested arrays are sent as bracketed field names such as `roles[0]`, `true` is sent as `1`, and `false` and `null` are sent as empty fields.
-Laravel `Arrayable` objects, `JsonSerializable` objects, and stringable values are normalized recursively in JSON, form, and query data.
+`Arrayable` and `JsonSerializable` objects, and the fluent strings returned by `Str::of`, are converted recursively in JSON, form, and query data.
JSON bodies use `JSON_THROW_ON_ERROR` by default. To use other encoding flags, return a configured `JsonBodyRepository` from `defaultBodyRepository`:
@@ -769,6 +827,30 @@ When a request has a body in another format, `attach` replaces it with a multipa
Saloon preserves multipart streams, filenames, headers, order, and the generated boundary. Caller-owned streams are not buffered automatically.
+A request using the `HasMultipartBody` trait returns its default parts from `defaultBody` as `MultipartValue` instances, each containing the part's name, contents, and an optional filename and headers. String contents are sent as they are, so open files using `fopen`:
+
+```php
+use Hypervel\Saloon\Data\MultipartValue;
+use Hypervel\Saloon\Traits\Body\HasMultipartBody;
+
+class UploadAvatar extends Request
+{
+ use HasMultipartBody;
+
+ // ...
+
+ protected function defaultBody(): array
+ {
+ return [
+ new MultipartValue('avatar', fopen($this->path, 'rb'), 'avatar.png', [
+ 'Content-Type' => 'image/png',
+ ]),
+ new MultipartValue('description', 'Profile photo'),
+ ];
+ }
+}
+```
+
### Custom Body Repositories
@@ -869,6 +951,8 @@ $request
The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds. Saloon waits for the delay before every attempt, including attempts answered by a mock or fake response, so `Sleep::fake()` can assert it in your tests. Responses served from the cache are not delayed.
+The `withoutRedirecting` method disables redirects, and the `sink` method writes the response body to a path or resource as it is received.
+
The `withoutOptions` method removes options from the request, so the connector's value or the HTTP connection's configured value applies again. To remove a connector default, call `withoutOptions` on the pending request:
```php
@@ -914,7 +998,7 @@ Request-shaping options such as `headers`, `query`, `cookies`, `body`, `json`, `
## Middleware
-Middleware may inspect or change the pending request, response, or fatal transport exception. You may register middleware from a connector or request's `boot` method, through a reusable plugin, or globally during application boot.
+Middleware may inspect or change the pending request, response, or fatal transport exception. You may register middleware on a request before sending it, from a connector or request's `boot` method, through a reusable plugin, or globally during application boot.
### Request Middleware
@@ -936,7 +1020,17 @@ public function boot(PendingRequest $pendingRequest): void
}
```
-Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request. Other return values are ignored, so change the pending request it receives rather than returning a new one.
+Requests have the same middleware pipeline, so you may also add middleware to a request before sending it:
+
+```php
+$request = GetUser::make('hypervel');
+
+$request->middleware()->onRequest(function (PendingRequest $pendingRequest) use ($traceId): void {
+ $pendingRequest->withHeader('X-Trace-ID', $traceId);
+});
+```
+
+Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request. The remaining request middleware still runs, and the last fake response returned is used. A fake response supplied by middleware bypasses the [response cache](#caching) and [mock clients](#faking-responses), and consumes no [rate limit](#rate-limiting) capacity. Other return values are ignored, so change the pending request it receives rather than returning a new one.
For reusable middleware, create an invokable class that implements `RequestMiddleware`:
@@ -959,7 +1053,9 @@ class AddRequestId implements RequestMiddleware
Register the class in the same way as a closure: `$pendingRequest->middleware()->onRequest(new AddRequestId)`.
-Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::FIRST` or `PipeOrder::LAST` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline.
+Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::FIRST` or `PipeOrder::LAST` using the `order` argument when a middleware must run before or after the normal group.
+
+These sources are merged into the operation's pipelines, so a name may be used only once among its request middleware, and likewise among its response and fatal exception middleware. A duplicate name throws a `DuplicatePipeNameException`. Request middleware may register response middleware, but request middleware added while the request pipeline is running does not run for that operation.
### Response Middleware
@@ -1174,7 +1270,7 @@ class GetUser extends Request
}
```
-Call `dto` to create the configured value. The `dtoOrFail` method refuses to create it when the integration considers the response failed:
+Call `dto` to create the configured value. The `dto` method converts every response, including failed ones, so you may also map error responses. The `dtoOrFail` method instead throws a `LogicException` when the integration considers the response [failed](#error-handling). When the response would also throw, its request exception is available as the `LogicException`'s previous exception:
```php
$user = $github->send(new GetUser('hypervel'))->dtoOrFail();
@@ -1239,27 +1335,45 @@ if ($response->failed()) {
$response->throw();
```
-Common statuses throw an exception named after them from the `Hypervel\Saloon\Exceptions\Request\Statuses` namespace, such as `NotFoundException` or `TooManyRequestsException`. These extend `ClientException` for 4xx responses and `ServerException` for 5xx responses, which other 4xx and 5xx responses throw directly. Other integration-defined failures throw `RequestException`.
+Common statuses throw an exception named after them from the `Hypervel\Saloon\Exceptions\Request\Statuses` namespace:
+
+| Status | Exception |
+|---|---|
+| 400 | `BadRequestException` |
+| 401 | `UnauthorizedException` |
+| 402 | `PaymentRequiredException` |
+| 403 | `ForbiddenException` |
+| 404 | `NotFoundException` |
+| 405 | `MethodNotAllowedException` |
+| 408 | `RequestTimeOutException` |
+| 409 | `ConflictException` |
+| 422 | `UnprocessableEntityException` |
+| 429 | `TooManyRequestsException` |
+| 500 | `InternalServerErrorException` |
+| 502 | `BadGatewayException` |
+| 503 | `ServiceUnavailableException` |
+| 504 | `GatewayTimeoutException` |
+
+These extend `ClientException` for 4xx responses and `ServerException` for 5xx responses, which other 4xx and 5xx responses throw directly. All of them extend `RequestException`, which other integration-defined failures throw.
A request that could not complete throws `FatalRequestException`. This includes failed connections and transfers that break while the response is being received, whatever status the partial response has.
-You may define provider-specific failure behavior on a request or connector:
+Some APIs report errors in a successful response. A request or connector may override `hasRequestFailed` to decide whether a response failed, returning `null` to fall back to the status code:
```php
+use Hypervel\Saloon\Http\Response;
+
public function hasRequestFailed(Response $response): ?bool
{
return $response->json('ok') === false ? true : null;
}
-
-public function shouldThrowRequestException(Response $response): bool
-{
- return $response->status() !== 404 && $response->failed();
-}
```
-In this example, the integration treats a 404 response as an empty result instead of an exception.
+In this example, a response whose body contains `"ok": false` is treated as failed whatever its status. A request's non-null decision takes precedence over its connector's. Failed responses are not [cached](#caching), and by default they throw from `throw` and trigger [retries](#retries).
-A non-null request failure decision takes precedence over the connector decision, which takes precedence over the status-code fallback. You may return a custom Saloon request exception from `getRequestException`, and a request's exception takes precedence over its connector's. To give a custom exception its own message, override its `prepareMessage` method:
+A response throws when the `shouldThrowRequestException` method of either its request or its connector returns `true`. Both return `failed()` by default. To stop a failed response from throwing while keeping it failed, return `false` from both methods. Returning `false` from `hasRequestFailed` instead makes the response successful, so it may also be cached and converted by `dtoOrFail`.
+
+You may return a custom Saloon request exception from `getRequestException`, and a request's exception takes precedence over its connector's. To give a custom exception its own message, override its `prepareMessage` method:
```php
use Hypervel\Http\Client\Response;
@@ -1274,28 +1388,41 @@ class GitHubException extends RequestException
}
```
+Then return it from the request or connector:
+
+```php
+use Hypervel\Saloon\Exceptions\Request\RequestException;
+use Hypervel\Saloon\Http\Response;
+
+public function getRequestException(Response $response): ?RequestException
+{
+ return new GitHubException($response);
+}
+```
+
The `AlwaysThrowOnErrors` plugin calls `throw` automatically after each response.
### Retries
-Use `retry` to specify the number of attempts and delay between attempts:
+The `retry` method accepts the maximum number of times a request should be attempted and the number of milliseconds to wait between attempts. A request is retried when its response would [throw](#error-handling) or the request could not complete:
```php
$request->retry(3, 100);
```
-You may supply an array of delays or calculate the delay using a closure:
+You may calculate the delay using a closure, or pass an array of delays as the first argument, which makes one more attempt than it contains delays:
```php
+$request->retry(3, fn (int $attempt) => $attempt * 100);
+
$request->retry([100, 250, 500]);
+```
-$request->retry(
- 4,
- fn (int $attempt) => $attempt * 100,
- when: fn ($exception, $pendingRequest) => $exception->getCode() !== 401,
- throw: false,
-);
+When a request with more than one attempt runs out of attempts, Saloon throws the last attempt's `RequestException` or `FatalRequestException`. A request with one attempt, such as `retry(1)`, returns a failed response as usual. Pass `throw: false` to return the last response instead of throwing. A `FatalRequestException` is still thrown, because there is no response to return, as is a request exception thrown by response middleware, such as the `AlwaysThrowOnErrors` plugin's:
+
+```php
+$request->retry(3, 100, throw: false);
```
Each attempt receives a fresh pending request, so middleware and authentication run again. Seekable request bodies are restored before another attempt. Saloon throws a `BodyException` rather than retrying a consumed non-seekable body.
@@ -1341,7 +1468,26 @@ $request->debugRequest();
$request->debugResponse();
```
-To debug every request sent through a connector, call the same methods on the pending request from the connector's `boot` method. The `debugRequest` and `debugResponse` methods accept a custom callback. All three methods accept `die: true`, which stops the current request or command after the output and is intended only for local debugging.
+To debug every request sent through a connector, call the same methods on the pending request from the connector's `boot` method. All three methods accept `die: true`, which stops the current request or command after the output and is intended only for local debugging.
+
+Saloon dumps the request and response using Symfony's VarDumper. The request is shown as Saloon passes it to the HTTP client, after [PSR-7 request hooks](#psr-request-hooks) have run, so headers that HTTP client middleware or Guzzle add later are not included. The response is shown before response middleware that uses the default [order](#middleware).
+
+The `debugRequest` and `debugResponse` methods also accept a callback, such as one that writes to your logs instead:
+
+```php
+use Hypervel\Saloon\Http\PendingRequest;
+use Hypervel\Saloon\Http\Response;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+
+$request->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest): void {
+ logger()->debug('Saloon request', ['uri' => (string) $psrRequest->getUri()]);
+});
+
+$request->debugResponse(function (Response $response, ResponseInterface $psrResponse): void {
+ logger()->debug('Saloon response', ['status' => $response->status()]);
+});
+```
> [!WARNING]
> Debug output contains raw headers and bodies and may expose credentials or other sensitive values. Custom callbacks are also responsible for any stream reads they perform.
@@ -1382,7 +1528,9 @@ class GitHubConnector extends Connector
}
```
-The `allowBaseUrlOverride` option is required here because GitHub's trusted authorization and token endpoints use a different host from the connector's API base URL. Leave this option disabled when the OAuth endpoints are relative to the connector base URL.
+The authorize, token, and user endpoints default to `authorize`, `token`, and `user`, relative to the connector's base URL. The `allowBaseUrlOverride` option is required here because GitHub's trusted authorization and token endpoints use a different host from the connector's API base URL. Leave this option disabled when the OAuth endpoints are relative to the connector base URL.
+
+If each tenant has its own OAuth client, pass the client credentials to the connector's constructor and use them in `defaultOAuthConfig`.
The `authorizationUrl` method returns the URL together with its generated state. Store the state for the current authorization attempt before redirecting the user:
@@ -1394,7 +1542,7 @@ session(['github_oauth_state' => $authorization->state]);
return redirect((string) $authorization);
```
-The `authorizationUrl` method also accepts a `scopeSeparator` and an array of `additionalQueryParameters`. Additional parameters may replace the standard ones, such as `response_type` for an OpenID Connect hybrid flow, but the URL's `state` always matches the returned state.
+The given scopes are added to the configuration's `defaultScopes`. The state is a random 32-character string unless you pass your own `state` argument. The `authorizationUrl` method also accepts a `scopeSeparator` and an array of `additionalQueryParameters`. Additional parameters may replace the standard ones, such as `response_type` for an OpenID Connect hybrid flow, but the URL's `state` always matches the returned state.
After the provider redirects to your application, exchange the code and validate the returned state:
@@ -1410,7 +1558,7 @@ $response = $github->send(
);
```
-If either state value is supplied, both values must be non-empty and equal. Keeping the state beside each authorization attempt allows one connector to support concurrent tabs and users without shared mutable state.
+If either state value is supplied, both values must be non-empty and equal, or Saloon throws an `InvalidStateException`. If the provider rejects the code, `getAccessToken` throws the token response's [request exception](#error-handling). Keeping the state beside each authorization attempt allows one connector to support concurrent tabs and users without shared mutable state.
You may pass a PKCE code challenge to `authorizationUrl` and the corresponding verifier to `getAccessToken`:
@@ -1448,7 +1596,7 @@ class ServiceConnector extends Connector
$authenticator = $connector->getAccessToken(['reports:read']);
```
-Use `ClientCredentialsBasicAuthGrant` when the provider requires the client credentials in an HTTP Basic authentication header.
+The given scopes are added to the configuration's `defaultScopes` and separated by spaces unless you pass a `scopeSeparator`. Client credentials connectors need no `redirectUri`. Use `ClientCredentialsBasicAuthGrant` when the provider requires the client credentials in an HTTP Basic authentication header.
OAuth token grant methods return an OAuth authenticator by default. Pass `returnResponse: true` when you need the original Saloon response instead:
@@ -1470,6 +1618,18 @@ if ($authenticator->hasExpired() && $authenticator->isRefreshable()) {
}
```
+To keep tokens between requests, store the authenticator's `accessToken`, `refreshToken`, and `expiresAt` properties and create a new authenticator from them when you need it. For example, you may store the tokens in [encrypted](/docs/{{version}}/eloquent-mutators#encrypted-casting) model attributes and [cast](/docs/{{version}}/eloquent-mutators#date-casting) the expiry attribute to `immutable_datetime`:
+
+```php
+use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
+
+$authenticator = new AccessTokenAuthenticator(
+ $user->github_access_token,
+ $user->github_refresh_token,
+ $user->github_token_expires_at,
+);
+```
+
OAuth token expiry values use immutable dates. Saloon rejects negative or unrepresentable expiry durations instead of creating an already-expired token.
Refresh requests use the configured `tokenEndpoint` by default. If your provider uses a separate endpoint, set `refreshEndpoint` on your `OAuthConfig`:
@@ -1563,7 +1723,7 @@ $responses = $github->pool(function (Connector $connector) use ($usernames) {
The callback runs each time the pool is sent, so a pool whose callback returns a new iterable each time may be sent again. A generator passed directly can only be sent once.
-The concurrency value must be a positive integer. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines.
+The concurrency defaults to 5 and must be a positive integer. Each request is sent as the connector's `send` method would send it, so its retry policy, middleware, caching, and rate limits still apply. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines.
You may also create an empty pool and configure it fluently before sending:
@@ -1585,7 +1745,7 @@ If a response handler owns each result and you do not need a response array, cal
$github->pool(
requests: $requests,
concurrency: 10,
- responseHandler: function (Response $response, string $key): void {
+ responseHandler: function (Response $response, int|string $key): void {
ProcessImportedUser::dispatch($key, $response->json());
},
)->process();
@@ -1601,7 +1761,7 @@ You may handle request failures while allowing the remaining requests to finish:
```php
$responses = $github->pool(
requests: $requests,
- exceptionHandler: function (Throwable $exception, string $key): void {
+ exceptionHandler: function (Throwable $exception, int|string $key): void {
report($exception);
},
)->send();
@@ -2303,7 +2463,25 @@ Saloon::fake([
]);
```
-Calling `fake` again adds responses to the same mock client, so a test may extend the fakes registered in its `setUp` method. To replace the global mock client instead, pass a `MockClient` instance to `fake`.
+The `make` method accepts a body, status code, and headers. An array body is encoded as JSON, while a string body is returned as it is:
+
+```php
+MockResponse::make(['message' => 'Not Found'], 404, [
+ 'Content-Type' => 'application/json',
+]);
+```
+
+To simulate a failed connection, pass an exception to the mock response's `throw` method. Saloon reports a `ConnectionException` as a `FatalRequestException`, just like a real connection failure:
+
+```php
+use Hypervel\Http\Client\ConnectionException;
+
+Saloon::fake([
+ GetUser::class => MockResponse::make()->throw(new ConnectionException('Connection refused')),
+]);
+```
+
+The fake belongs to the test application, so each test starts without one. Calling `fake` again adds responses to the same mock client, so a test may extend the fakes registered in its `setUp` method. To replace the global mock client instead, pass a `MockClient` instance to `fake`.
Responses may be matched by request class, connector class, or wildcard URL. Request matches take precedence over connector matches, followed by URL matches and sequence responses:
@@ -2321,6 +2499,21 @@ Each matching value may also be a callback that receives the pending request and
You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. Mock responses are matched after request middleware has run and the URL is final, so URL matches see any changes middleware made.
+A local mock client is useful when testing an SDK's requests directly. It records every request sent with it, so you may make assertions on it:
+
+```php
+use Hypervel\Saloon\Http\Faking\MockClient;
+use Hypervel\Saloon\Http\Faking\MockResponse;
+
+$mockClient = new MockClient([
+ CreateIssue::class => MockResponse::make(['id' => 1], 201),
+]);
+
+$github->send(new CreateIssue('Bug report'), $mockClient);
+
+$mockClient->assertSent(fn (CreateIssue $request) => $request->body() === ['title' => 'Bug report']);
+```
+
Mock clients are strict by default. An unmatched request throws `NoMockResponseFoundException` instead of reaching the network.
To prevent every unmocked HTTP request from reaching the network, including a Saloon request sent without a mock client, use Hypervel HTTP's application-wide guard:
@@ -2374,6 +2567,7 @@ Saloon::assertSent(GetUser::class);
Saloon::assertSent('https://api.github.com/users/*');
Saloon::assertNotSent(DeleteUser::class);
Saloon::assertSentCount(1);
+Saloon::assertSentCount(1, GetUser::class);
Saloon::assertNothingSent();
```
@@ -2401,7 +2595,11 @@ Saloon::fake([
]);
```
-By default, fixtures are stored under `tests/Fixtures/Saloon`. Fixture names use portable path segments separated by forward slashes. Absolute paths, backslashes, empty segments, and `.` or `..` segments are rejected.
+You may also create a fixture using `MockResponse::fixture('github/users/hypervel')`. By default, fixtures are stored as JSON files under `tests/Fixtures/Saloon`, so this fixture is written to `tests/Fixtures/Saloon/github/users/hypervel.json`. To record a fixture again, delete its file and run the test.
+
+Fixture names use portable path segments separated by forward slashes. Absolute paths, backslashes, empty segments, and `.` or `..` segments are rejected. Lowercase names avoid fixtures going missing when a test suite moves between case-insensitive and case-sensitive filesystems.
+
+When `Http::preventStrayRequests()` is active, recording a missing fixture is a stray request and throws. Allow the API's URLs while recording, such as by calling `Http::allowStrayRequests(['https://api.github.com/*'])`.
To redact sensitive response data, extend `Fixture` and define header, JSON, or regular-expression replacement rules:
@@ -2425,7 +2623,7 @@ class GitHubFixture extends Fixture
}
```
-Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata.
+Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. The rules change the stored file, so the test that records a fixture still receives the real response. A fixture class may also override `defineName` to return its name, so it can be created without arguments. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata.
Mock responses and fixtures are cached like network responses. Once a cacheable request has been cached, later sends return the cached response instead of the next mock response or an updated fixture file, and a missing fixture is not recorded. Call `withoutCache` on the mock client to keep its requests from reading, writing, or invalidating the cache:
@@ -2442,7 +2640,7 @@ Saloon::fixturePath(base_path('tests/Fixtures/Api'));
Saloon::throwOnMissingFixtures();
```
-These are tests-only settings and are cleared when the test application is destroyed.
+These are tests-only settings and are cleared when the test application is destroyed. To fail on missing fixtures in every run, such as in CI, set the `saloon.fixtures.throw_on_missing` configuration option instead.
## Publishing Configuration and Stubs
@@ -2466,21 +2664,26 @@ Set `saloon.integrations_path` to change where generated files are written. When
## Differences From Saloon
-Hypervel Saloon keeps the connector, request, middleware, authentication, response, testing, OAuth 2, caching, pagination, and rate-limit concepts of Saloon while using Hypervel's framework services directly.
+Hypervel Saloon keeps the connector, request, middleware, authentication, response, testing, OAuth 2, caching, pagination, and rate-limit concepts of Saloon while using Hypervel's framework services directly. When porting an existing integration, note the following differences:
+
+
-- Hypervel HTTP is the only transport. Sender factories, configurable Guzzle senders, and process-global Saloon configuration are not included.
-- Request customization uses Hypervel HTTP names such as `withHeader`, `withQueryParameters`, `withOptions`, `timeout`, `withToken`, and `retry`.
-- Connectors contain stable defaults. Operation-specific authentication, caching, debugging, middleware, retries, and mocks belong to requests and pending requests.
-- Coroutine-native pools replace promises and `sendAsync`. Paginator concurrency uses the same bounded pool.
-- Cache, pagination, and rate limiting are included in `hypervel/saloon` and use Hypervel's cache, collections, coroutine, and rate-limiter services.
+- The Laravel plugin and the cache, pagination, and rate-limit plugins are built in, using Hypervel's cache, collections, coroutine, and rate-limiter services.
+- Hypervel HTTP is the only transport. Requests are sent through a named [HTTP connection](#http-connections) instead of a sender, and there is no process-global `Config` class; register global middleware through `Saloon::middleware()`.
+- Requests and pending requests use the HTTP client's method names, such as `withHeader`, `withQueryParameters`, `withOptions`, `timeout`, and `withToken`, and their getters return plain values instead of upstream's repositories.
+- Connectors are read-only, since one connector may be shared by concurrent requests. Operation-specific authentication, middleware, debugging, mocks, and retries belong to requests, the `send` call, or the connector's `boot` method.
+- Retries use `retry` and `defaultRetryPolicy` instead of the `$tries`, `$retryInterval`, `$useExponentialBackoff`, and `$throwOnMaxTries` properties.
+- Coroutine-native [pools](#concurrent-requests) replace `sendAsync` and promises. Paginator concurrency uses the same bounded pool.
+- Responses extend Hypervel HTTP responses. Request exceptions extend the HTTP client's `RequestException` rather than `SaloonException`, so catch `RequestException` for failed responses.
+- Mock clients are matched when the request is sent, after request middleware has run. Fixture settings are configured through the `Saloon` facade and configuration file, and application-wide stray-request protection uses `Http::preventStrayRequests()`, which also applies to fixture recording.
+- OAuth 2 configuration is immutable, and authorization URLs return their paired state.
+- NTLM authentication is not included.
- Rate limits use Hypervel `AdmissionPolicy` instances instead of Saloon's mutable limit and store abstractions.
-- OAuth 2 configuration is immutable, authorization URLs return their paired state, and OAuth 1 is not included.
-- Saloon responses extend Hypervel HTTP responses rather than forwarding a selected subset of methods.
-- Test fixture settings are configured through the `Saloon` facade instead of a process-global mock configuration object.
-- Application-wide stray-request protection uses `Http::preventStrayRequests()`. Saloon mock clients separately control unmatched requests while they are active.
- The `xmlReader` response method is not included. Use the `xml` or `dom` methods, or install [XML Wrangler](https://github.com/saloonphp/xml-wrangler) and pass `$response->toPsrResponse()` to `XmlReader::fromPsrResponse()`.
-These differences remove framework-neutral adapter layers while retaining the public concepts needed to build complete integrations and reusable SDKs for Hypervel.
+
+
+The [package README](https://github.com/hypervel/components/blob/0.4/src/saloon/README.md) documents API differences and their Hypervel equivalents.
## Credits
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 8fa31ea927..5322137e8f 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -8,7 +8,7 @@ Requests and pending requests use fluent methods in the style of Hypervel's HTTP
Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
-There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
+There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, including for a request that would record a missing fixture, so allow the API's URLs with `Http::allowStrayRequests()` while recording. Fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
From e7d907fa781e67e8beed306202348952f43bf772 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:25:20 +0000
Subject: [PATCH 20/26] Reconcile the remaining Saloon documentation with
upstream
The rest of Saloon v3's documentation was compared with the guide:
pagination, caching, rate limits, the Laravel plugin, building
plugins, the how-to guides and the upgrade and known-issue pages.
Every claim and example was checked against Hypervel's source.
Added: plugin boot methods must be public, plugins change the pending
request, and a plugin on both a connector and its request boots twice.
Fetching a token from a connector's boot method, which can't be
combined with RequiresAuth because its check runs first. Choosing the
cacheable methods. Paginators yield one page response at a time, keep
only that page in memory and throw for a failed page; iterator_count
cannot find the last page; the default page size, offsets and custom
paginators. Saloon accepts fixed, calendar and sliding windows and
leaky buckets, per-user by keys, the exception's wait time, the
default store and disabling 429 cooldowns. Requests require TLS 1.2
unless the connection changes crypto_method.
The README's differences heading now names Saloon and lists all five
upstream repositories. The sync notes say where future upstream
changes, plugin test cases and documentation belong, and which
upstream pages have no Hypervel equivalent. A colon in one note made
sync.yaml invalid YAML; it parses again.
Validation: documentation only. A nested send from a connector's boot
method was confirmed with a temporary test. Every guide link and README
anchor resolves.
---
docs/upstream-sync/sync.yaml | 6 ++--
src/docs/saloon.md | 61 +++++++++++++++++++++++++++---------
src/saloon/README.md | 10 ++++--
3 files changed, 58 insertions(+), 19 deletions(-)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 55cb1da254..749c225473 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -182,14 +182,14 @@ saloonphp/saloon:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Helpers/OAuth2/OAuthConfig maps to the immutable Data/OAuthConfig, and token-response parsing shared by the OAuth grant traits lives in Traits/OAuth2/ParsesOAuthTokenResponses. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. Public contract differences belong in src/saloon/README.md.
+ notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Helpers/OAuth2/OAuthConfig maps to the immutable Data/OAuthConfig, and token-response parsing shared by the OAuth grant traits lives in Traits/OAuth2/ParsesOAuthTokenResponses. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. src/saloon/README.md lists the deliberate public differences across all five entries, with their reasons and Hypervel equivalents; check them against current source before applying an upstream change they affect, and keep the README current.
saloonphp/laravel-plugin:
branch: v4
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's HTTP layer is a mock client and middleware, not a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel and use the core fixtures, which the plugin's fixtures copy; the plugin's Feature/PoolTest and Feature/RequestTest repeat core cases and are covered by the core tests.
+ notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's Http directory holds a MockClient subclass and observability middleware rather than a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel and use the core fixtures, which the plugin's fixtures copy. The plugin's Feature/PoolTest and Feature/RequestTest repeat core cases, so merge their new or changed cases into tests/Saloon/Feature/PoolTest and tests/Integration/Saloon/Feature/{PoolTest,RequestTest}, keeping plugin-specific setup such as Saloon::fake(), rather than adding separate copies.
saloonphp/cache-plugin:
branch: v3
@@ -217,7 +217,7 @@ Sammyjo20/saloon-docs:
checked_through: null
last_reviewed_pr: null
sync_date: null
- notes: Shared documentation for the unified src/saloon package and all four plugin entries above, mapped to src/docs/saloon.md. The v3 branch includes v4 guidance; verify examples against the tracked source and Hypervel's integrations, using Laravel-style prose.
+ notes: Shared documentation for the unified src/saloon package and all four plugin entries above. Reconcile applicable core and plugin pages into the existing sections of the single src/docs/saloon.md rather than separate plugin guides, keeping Hypervel's additions and checking examples against current source, in Laravel-style prose matching the guide. The v3 branch includes v4 guidance. Installation commands, supported versions, upgrade guides, release notes, showcase, tutorials, the book and the third-party Lawman and SDK generator pages have no Hypervel equivalent; check them only for behavior the guide must still describe.
getsentry/sentry-laravel:
branch: master
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 3642f947a4..7844fef415 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -301,7 +301,7 @@ Your resource may access the connector through its protected, readonly `$connect
Saloon registers one named Hypervel [HTTP connection](/docs/{{version}}/http-client#connections), named `saloon` by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar.
-The `saloon` connection uses a 10-second connection timeout and a 30-second request timeout. You may [publish the configuration file](#publishing-configuration-and-stubs) to change its options. A connector may also select another connection that your application registers during worker boot:
+The `saloon` connection uses a 10-second connection timeout and a 30-second request timeout. Like other HTTP client requests, Saloon requests require TLS 1.2 or later unless the connection sets another `crypto_method` option. You may [publish the configuration file](#publishing-configuration-and-stubs) to change its options. A connector may also select another connection that your application registers during worker boot:
```php
use Hypervel\Support\Facades\Http;
@@ -727,6 +727,28 @@ class GetPrivateReport extends Request
You may also add the trait to a connector to require authentication for every request in that integration. Saloon throws `MissingAuthenticatorException` before sending an unauthenticated request. You may override `getRequiresAuthMessage(PendingRequest $pendingRequest): string` when the integration needs a more specific message.
+Some APIs require you to request a token before calling other endpoints. A connector's `boot` method may send that request and authenticate the pending request with the result:
+
+```php
+use Hypervel\Saloon\Http\Auth\TokenAuthenticator;
+use Hypervel\Saloon\Http\PendingRequest;
+
+public function boot(PendingRequest $pendingRequest): void
+{
+ if ($pendingRequest->request() instanceof GetAccessToken) {
+ return;
+ }
+
+ $token = $this->send(new GetAccessToken($this->username, $this->password))
+ ->throw()
+ ->json('token');
+
+ $pendingRequest->authenticate(new TokenAuthenticator($token));
+}
+```
+
+The early return stops the token request from requesting a token itself. Don't combine this with `RequiresAuth`, which checks for an authenticator before `boot` runs. When the API's tokens last longer than one request, you may store the token in your application's [cache](/docs/{{version}}/cache) until it expires.
+
### Request Bodies
@@ -1154,7 +1176,7 @@ The connector hook runs before the request hook. Each hook runs once on the fina
### Plugins
-Plugins are traits with a boot method named after the trait. They allow connector and request behavior to be shared without another service layer:
+Plugins are traits with a public boot method named after the trait. They allow connector and request behavior to be shared without another service layer:
```php
use Hypervel\Saloon\Http\PendingRequest;
@@ -1169,7 +1191,7 @@ trait AddsRequestId
}
```
-Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation.
+Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation. Change the pending request in a plugin rather than the connector or request, since a connector may be shared by concurrent operations. A plugin used by both a connector and its request boots twice.
Connector plugins boot before request plugins, and the request is authenticated once every plugin has booted. This lets an authenticator use the URL and headers set by plugins, such as an [API version](#api-versions) in the host. A plugin may select the authenticator by calling `authenticate` on the pending request; Saloon applies it after the remaining plugins boot. The connector and request `boot` methods and request middleware run after authentication, so calling `authenticate` there applies the authenticator immediately.
@@ -1809,7 +1831,18 @@ A null store uses `saloon.cache.store`, which itself falls back to Hypervel's de
An integer cache duration is measured in seconds. You may also return a `DateInterval` or `DateTimeInterface` instance.
-GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. A response is written unless it failed, including failures defined by a request or connector's [failure check](#error-handling). Mock responses and fixtures are cached like network responses, so a cached response is returned before the mock client is consulted. See [Fixtures](#fixtures) for keeping tests out of the cache.
+GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. To change which methods are eligible, override `cacheableMethods` on the connector, or on a request using the [`HasCaching` trait](#request-cache-controls). A request's list takes precedence over its connector's:
+
+```php
+use Hypervel\Saloon\Enums\Method;
+
+protected function cacheableMethods(): array
+{
+ return [Method::GET, Method::POST];
+}
+```
+
+A response is written unless it failed, including failures defined by a request or connector's [failure check](#error-handling). Mock responses and fixtures are cached like network responses, so a cached response is returned before the mock client is consulted. See [Fixtures](#fixtures) for keeping tests out of the cache.
The default cache key includes the connector and request classes, method, final URI, headers, cookies, authentication and certificate state, prepared body, and response-affecting transport options. This means a QUERY request's body is part of its identity. Responses cached from streaming bodies are buffered because the cache must retain their bytes.
@@ -1876,8 +1909,6 @@ protected function cacheKey(PendingRequest $pendingRequest): ?string
Saloon hashes custom keys before passing them to the cache backend, so raw credentials and tenant identifiers are not exposed in backend key names. A custom key must include every value that can change the successful response. It is required when a non-seekable request body is cached or when lower-level PSR or HTTP middleware changes response identity after Saloon finalizes the operation.
-You may override `cacheableMethods` to change which methods are eligible. A request override takes precedence over the connector's list.
-
### Cache Scopes
@@ -1914,7 +1945,7 @@ Authentication state already separates tenant-owned credentials, and the final U
Saloon pagination iterates through pages returned by a remote API. It is separate from Hypervel's [application pagination](/docs/{{version}}/pagination), which prepares local data for views and JSON responses.
-A paginated request must implement the `Paginatable` marker contract. Create a paginator class for the remote API's pagination format.
+A paginated request must implement the `Paginatable` marker contract. Create a paginator class for the remote API's pagination format. Saloon includes page, offset, cursor, link, and link header paginators. For another format, extend `Paginator` and implement `applyPagination`, `isLastPage`, and `getPageItems`, using the protected `$pageNumber` and `$perPageLimit` properties to build each page's request.
### Page Pagination
@@ -1988,9 +2019,9 @@ $users = $paginator->collect();
The `HasPagination` contract provides the conventional connector entry point. A request that needs its own paginator may implement `HasRequestPagination` and define `paginate(Connector $connector): Paginator`; the connector can delegate to it as shown above. Declare the request's item type with `@implements HasRequestPagination` and `@return Paginator` on its `paginate` method.
-The `collect(false)` method returns a lazy collection of page responses instead of items. Declare the paginator's item type with `@extends PagedPaginator` (or the matching base class) to preserve it through `items` and `collect`. You may also inspect `totalResults`, `request`, and the zero-based iterator position returned by `currentPage`. Use `startPage` to configure the first remote page number.
+Iterating the paginator itself yields each page's response, and only the current page is kept in memory. A page response that would [throw](#error-handling) stops iteration with its request exception. The `collect(false)` method returns a lazy collection of page responses instead of items. To return data objects, map them in `getPageItems` and declare the paginator's item type with `@extends PagedPaginator` (or the matching base class) to preserve it through `items` and `collect`. You may also inspect `totalResults`, `request`, and the zero-based iterator position returned by `currentPage`. Use `startPage` to configure the first remote page number.
-Calling `count($paginator)` counts remote pages by requesting each page. It is not a metadata-only operation.
+Calling `count($paginator)` counts remote pages by requesting each page. It is not a metadata-only operation. Do not use `iterator_count`, which never loads a page and so cannot find the last one.
Override the protected query-name properties when an API uses different names:
@@ -2000,7 +2031,7 @@ protected string $pageName = 'currentPage';
protected string $perPageName = 'pageSize';
```
-`PagedPaginator` defaults to `page` and `per_page`. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure.
+`PagedPaginator` defaults to `page` and `per_page`, and sends a page size only when a per-page limit is set. To send one by default, declare `protected ?int $perPageLimit = 100;` on your paginator. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure.
During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages return the same response body. Check that your paginator correctly identifies the last page. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator.
@@ -2018,7 +2049,7 @@ If a request implements `MapPaginatedResponseItems`, its `mapPaginatedResponseIt
### Offset and Cursor Pagination
-Extend `OffsetPaginator` for APIs that use `limit` and `offset`. A per-page limit must be configured before iteration. Extend `CursorPaginator` for APIs where each response supplies the next cursor, and implement `getNextCursor`.
+Extend `OffsetPaginator` for APIs that use `limit` and `offset`. A per-page limit must be configured before iteration, and the protected `getOffset` method returns the current page's offset, such as for comparison with a total in `isLastPage`. Extend `CursorPaginator` for APIs where each response supplies the next cursor, and implement `getNextCursor`.
Cursors may be strings, numbers, booleans, or null. The default query encoding omits null and sends booleans as `1` or `0`. Override `applyPagination` when your API requires another encoding, and use `isLastPage` to determine when pagination ends.
@@ -2136,7 +2167,9 @@ class GitHubConnector extends Connector
}
```
-By default, a denied policy throws `RateLimitReachedException` before reaching the network. Override `waitForRateLimits` to sleep until capacity is available instead. It receives the denied policy and its decision, so you may wait out a short burst limit while still throwing for a daily quota:
+A policy may be a [fixed, calendar or sliding window, or a leaky bucket](/docs/{{version}}/rate-limiting#choosing-a-rate-limit). For example, a quota that resets at the end of each minute or day suits a calendar window, while an API that refills capacity continuously suits a leaky bucket. Give each user or API key its own limit with a distinct `by` key.
+
+By default, a denied policy throws `RateLimitReachedException` before reaching the network. The exception's `policy` and `result` methods return the denied policy and its decision, so `$exception->result()->retryAfter()` gives the number of seconds to wait. Override `waitForRateLimits` to sleep until capacity is available instead. It receives the denied policy and its decision, so you may wait out a short burst limit while still throwing for a daily quota:
```php
use Hypervel\RateLimiter\AdmissionPolicy;
@@ -2149,7 +2182,7 @@ protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision
}
```
-You may select another configured rate-limiter store by overriding `resolveRateLimitStore`. Fakes and cache hits do not consume capacity.
+Limits are stored in the rate-limiter store named by the `saloon.rate_limiter.store` configuration value, or the rate limiter's default store when it is `null`. A connector or request may return another configured [store](/docs/{{version}}/rate-limiting#available-stores) name from `resolveRateLimitStore`. Fakes and cache hits do not consume capacity.
Saloon [consumes each resource's policies together](/docs/{{version}}/rate-limiting#consuming-multiple-limits), subject to the documented Redis Cluster limitation. Connector and request limits are separate: the connector is admitted first, so its charge remains if the request's limits deny the operation. After waiting for capacity, Saloon checks the resource's cooldown again before retrying its policies.
@@ -2208,7 +2241,7 @@ protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest):
Do not infer this key from a hostname or assume every provider limits by token. Use the boundary documented by the provider.
-You may override `resolveRateLimitCooldown` to support a provider-specific response or clamp a provider-defined maximum:
+You may override `resolveRateLimitCooldown` to support a provider-specific response or clamp a provider-defined maximum. Returning `null` records no cooldown:
```php
use Hypervel\RateLimiter\AdmissionPolicy;
diff --git a/src/saloon/README.md b/src/saloon/README.md
index 5322137e8f..f36f51d8f9 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -2,7 +2,7 @@
Documentation: https://hypervel.org/docs/saloon
-## Differences From Laravel
+## Differences From Saloon
Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available: `headers()` returns an array, `queryParameters()` and `delayMilliseconds()` replace the `query()` and `delay()` getters, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Getters drop upstream's `get` prefix, so requests have `method()` and `mockClient()`, and pending requests have `connector()`, `request()`, `method()` and `fakeResponse()`; compare `fakeResponse()` with `null` in place of `hasFakeResponse()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, `uri()` replaces `getUrl()`, including the query string, and `resolveResponseClass($response)` returns the custom response class, or `null` for the default, in place of `getResponseClass()`.
@@ -44,4 +44,10 @@ The upstream NTLM authenticator is omitted. Integrations requiring NTLM must sup
OAuth 2 configuration is an immutable `Data\OAuthConfig` built from constructor arguments and read through public properties, replacing upstream's `Helpers\OAuth2\OAuthConfig` setters and getters, so connectors define `defaultOAuthConfig()` and per-call changes use the grant methods' `requestModifier`. `invokeRequestModifier()` is `modify()`. `authorizationUrl()` returns an `AuthorizationUrl` holding the URL and its state, instead of `getAuthorizationUrl()` storing the state on the shared connector for `getState()`. Additional authorization parameters cannot replace that state, and if `getAccessToken()` receives either state, both must be non-empty and equal. `getUser()` applies the configuration's request modifier before the per-call one, as the token methods do, and its request sends no form `Content-Type`. See [OAuth 2](https://hypervel.org/docs/saloon#oauth2).
-Ported from: https://github.com/saloonphp/saloon
+Ported from:
+
+- https://github.com/saloonphp/saloon
+- https://github.com/saloonphp/laravel-plugin
+- https://github.com/saloonphp/cache-plugin
+- https://github.com/saloonphp/pagination-plugin
+- https://github.com/saloonphp/rate-limit-plugin
From d899a52163d48ac6feb4fbb244ab7dfbbe3e3abf Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 05:44:03 +0000
Subject: [PATCH 21/26] Replace deprecated exception message expectations in
Saloon tests
PHPUnit 13.3 deprecates expectExceptionMessage(). The Saloon and
calendar-window tests changed on this branch now use
expectExceptionMessageIs() for complete messages, which is stricter,
and expectExceptionMessageIsOrContains() where a test checks part of a
message: delay validation, the base-URL replacement message's first
sentence, and a missing fixture's environment-dependent path. The
fixture-name and duplicate-pipe expectations now include the messages'
final period.
---
tests/RateLimiter/CalendarWindowTest.php | 2 +-
tests/Saloon/Cache/Feature/CacheTest.php | 2 +-
tests/Saloon/Feature/Body/HasJsonBodyTest.php | 2 +-
tests/Saloon/Feature/DelayRequestTest.php | 4 ++--
tests/Saloon/Feature/MockRequestTest.php | 4 ++--
.../Feature/Oauth2/AuthCodeFlowConnectorTest.php | 14 +++++++-------
.../Oauth2/ClientCredentialsFlowConnectorTest.php | 4 ++--
tests/Saloon/Feature/RetryConnectorTest.php | 4 ++--
tests/Saloon/Feature/RetryRequestTest.php | 6 +++---
.../Hypervel/Unit/MockClientAssertionsTest.php | 4 ++--
.../Pagination/Feature/AsyncPaginationTest.php | 2 +-
tests/Saloon/Pagination/Feature/PaginationTest.php | 2 +-
tests/Saloon/Unit/AuthenticatorTest.php | 2 +-
tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php | 2 +-
.../Unit/Body/MultipartBodyRepositoryTest.php | 6 +++---
tests/Saloon/Unit/ConfigTest.php | 2 +-
tests/Saloon/Unit/MiddlewarePipelineTest.php | 8 ++++----
tests/Saloon/Unit/MockClientTest.php | 6 +++---
tests/Saloon/Unit/MockConfigTest.php | 2 +-
tests/Saloon/Unit/MultipartValueTest.php | 4 ++--
.../Unit/Oauth2/AuthCodeFlowConnectorTest.php | 6 +++---
tests/Saloon/Unit/Oauth2/OAuthConfigTest.php | 6 +++---
tests/Saloon/Unit/PendingRequestTest.php | 4 ++--
tests/Saloon/Unit/RequestTest.php | 6 +++---
tests/Saloon/Unit/URLHelperTest.php | 8 ++++----
25 files changed, 56 insertions(+), 56 deletions(-)
diff --git a/tests/RateLimiter/CalendarWindowTest.php b/tests/RateLimiter/CalendarWindowTest.php
index 276b6aa1c6..60fee66d31 100644
--- a/tests/RateLimiter/CalendarWindowTest.php
+++ b/tests/RateLimiter/CalendarWindowTest.php
@@ -197,7 +197,7 @@ public static function windowProvider(): array
public function testWindowsEndingBeyondTheSupportedRangeAreRejected(): void
{
$this->expectException(InvalidRateLimitException::class);
- $this->expectExceptionMessage('The rate limiter timestamp exceeds the supported integer range.');
+ $this->expectExceptionMessageIs('The rate limiter timestamp exceeds the supported integer range.');
CalendarWindow::perMonth(1)->timezone('UTC')->window(AdmissionPolicy::MAX_INTEGER - 1);
}
diff --git a/tests/Saloon/Cache/Feature/CacheTest.php b/tests/Saloon/Cache/Feature/CacheTest.php
index 71e532286f..cc3a84f9c3 100644
--- a/tests/Saloon/Cache/Feature/CacheTest.php
+++ b/tests/Saloon/Cache/Feature/CacheTest.php
@@ -441,7 +441,7 @@ public function testItThrowsAnExceptionIfYouUseTheHasCachingTraitWithoutTheCache
$request = new CachedUserRequestWithoutCacheable;
$this->expectException(CachingException::class);
- $this->expectExceptionMessage('The request or connector must implement [Hypervel\Saloon\Cache\Contracts\Cacheable] when using request cache controls.');
+ $this->expectExceptionMessageIs('The request or connector must implement [Hypervel\Saloon\Cache\Contracts\Cacheable] when using request cache controls.');
$connector->send($request, $mockClient);
}
diff --git a/tests/Saloon/Feature/Body/HasJsonBodyTest.php b/tests/Saloon/Feature/Body/HasJsonBodyTest.php
index 3718359e21..5af4e80d3d 100644
--- a/tests/Saloon/Feature/Body/HasJsonBodyTest.php
+++ b/tests/Saloon/Feature/Body/HasJsonBodyTest.php
@@ -104,7 +104,7 @@ public function testWhenBothTheConnectorAndTheRequestHaveTheSameRequestBodiesThe
public function testIfTheConnectorAndRequestImplementDifferentBodyRepositoriesThenAnExceptionIsThrown(): void
{
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('Connector and request body types must be the same.');
+ $this->expectExceptionMessageIs('Connector and request body types must be the same.');
$this->pendingRequest(new HasJsonBodyConnector, new HasMultipartBodyRequest);
}
diff --git a/tests/Saloon/Feature/DelayRequestTest.php b/tests/Saloon/Feature/DelayRequestTest.php
index 8d91998017..6b2f12804c 100644
--- a/tests/Saloon/Feature/DelayRequestTest.php
+++ b/tests/Saloon/Feature/DelayRequestTest.php
@@ -175,7 +175,7 @@ public function testAnExplicitZeroDelayOverridesTheConnectorDelay(): void
public function testItRejectsNegativeDelays(): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('representable non-negative');
+ $this->expectExceptionMessageIsOrContains('representable non-negative');
$this->request()->delay(-1);
}
@@ -183,7 +183,7 @@ public function testItRejectsNegativeDelays(): void
public function testItRejectsDelaysThatOverflowMicroseconds(): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('representable non-negative');
+ $this->expectExceptionMessageIsOrContains('representable non-negative');
$this->request()->delay(intdiv(PHP_INT_MAX, 1000) + 1);
}
diff --git a/tests/Saloon/Feature/MockRequestTest.php b/tests/Saloon/Feature/MockRequestTest.php
index 57a74182a2..44a1a420da 100644
--- a/tests/Saloon/Feature/MockRequestTest.php
+++ b/tests/Saloon/Feature/MockRequestTest.php
@@ -148,7 +148,7 @@ public function testARequestCanBeMockedWithASequence(): void
$this->assertInstanceOf(MockResponse::class, $responseC->fakeResponse());
$this->expectException(NoMockResponseFoundException::class);
- $this->expectExceptionMessage('Saloon was unable to guess a mock response for your request [https://tests.saloon.dev/api/user], consider using a wildcard url mock or a connector mock.');
+ $this->expectExceptionMessageIs('Saloon was unable to guess a mock response for your request [https://tests.saloon.dev/api/user], consider using a wildcard url mock or a connector mock.');
$connector->send(new UserRequest);
}
@@ -598,7 +598,7 @@ public function testItWillThrowAnExceptionIfTheCustomFixtureClassIsMissingAName(
]);
$this->expectException(FixtureException::class);
- $this->expectExceptionMessage('The fixture must have a name');
+ $this->expectExceptionMessageIs('The fixture must have a name.');
(new TestConnector)->send(new UserRequest, $mockClient);
}
diff --git a/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
index cd73fe6a1e..7da2cfb3a8 100644
--- a/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
+++ b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php
@@ -342,7 +342,7 @@ public function testAnInvalidRefreshTokenIsRejected(): void
Saloon::fake([MockResponse::make(['access_token' => 'access', 'refresh_token' => []])]);
$this->expectException(UnexpectedValueException::class);
- $this->expectExceptionMessage('The OAuth token response contains an invalid refresh token.');
+ $this->expectExceptionMessageIs('The OAuth token response contains an invalid refresh token.');
(new OAuth2Connector)->getAccessToken('code');
}
@@ -355,7 +355,7 @@ public function testItWillThrowAnExceptionIfStateIsInvalid(): void
$connector->authorizationUrl(['scope-1', 'scope-2'], $state);
$this->expectException(InvalidStateException::class);
- $this->expectExceptionMessage('Invalid state.');
+ $this->expectExceptionMessageIs('Invalid state.');
$connector->getAccessToken('code', 'invalid', $state);
}
@@ -445,7 +445,7 @@ public function testTheRefreshAccessTokenMethodThrowsAnExceptionIfYouProvideItAn
$authenticator = new AccessTokenAuthenticator('access', null, Date::now()->addSeconds(3600)->toDateTimeImmutable());
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The provided OAuthAuthenticator does not contain a refresh token.');
+ $this->expectExceptionMessageIs('The provided OAuthAuthenticator does not contain a refresh token.');
$connector->refreshAccessToken($authenticator);
}
@@ -523,7 +523,7 @@ public function testAbsoluteRefreshEndpointRequiresOverridePermission(): void
$connector = new OAuth2Connector(['refreshEndpoint' => 'https://oauth.example.net/refresh']);
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+ $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.');
try {
$connector->refreshAccessToken('refresh');
@@ -665,7 +665,7 @@ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutAClientIdItW
Saloon::fake($mockClient);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$connector->getAccessToken('code');
}
@@ -680,7 +680,7 @@ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutASecretItWil
Saloon::fake($mockClient);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.');
$connector->getAccessToken('code');
}
@@ -695,7 +695,7 @@ public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutARedirectUri
Saloon::fake($mockClient);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Redirect URI is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Redirect URI is empty or has not been provided.');
$connector->getAccessToken('code');
}
diff --git a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
index 8ebb695124..c2982f9625 100644
--- a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
+++ b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php
@@ -229,7 +229,7 @@ public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutAClientIdItW
Saloon::fake($mockClient);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$connector->getAccessToken();
}
@@ -244,7 +244,7 @@ public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutASecretItWil
Saloon::fake($mockClient);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.');
$connector->getAccessToken();
}
diff --git a/tests/Saloon/Feature/RetryConnectorTest.php b/tests/Saloon/Feature/RetryConnectorTest.php
index af879065e7..9ba2a99fcd 100644
--- a/tests/Saloon/Feature/RetryConnectorTest.php
+++ b/tests/Saloon/Feature/RetryConnectorTest.php
@@ -150,7 +150,7 @@ public function testYouCanCustomiseIfTheMethodShouldRetry(): void
});
$this->expectException(InternalServerErrorException::class);
- $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
+ $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
$connector->send(new UserRequest, $mockClient);
}
@@ -164,7 +164,7 @@ public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void
]);
$this->expectException(InternalServerErrorException::class);
- $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
+ $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
(new RetryConnector(3, when: fn (): bool => false))->send(new UserRequest, $mockClient);
}
diff --git a/tests/Saloon/Feature/RetryRequestTest.php b/tests/Saloon/Feature/RetryRequestTest.php
index bb9dcd4f35..54e1cf78ce 100644
--- a/tests/Saloon/Feature/RetryRequestTest.php
+++ b/tests/Saloon/Feature/RetryRequestTest.php
@@ -205,7 +205,7 @@ public function testYouCanCustomiseIfTheMethodShouldRetry(): void
]);
$this->expectException(InternalServerErrorException::class);
- $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
+ $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n");
(new TestConnector)->send(new RetryUserRequest(3, when: function (RequestException $exception): bool {
return $exception->response()->json() !== ['name' => 'Gareth'];
@@ -221,7 +221,7 @@ public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void
]);
$this->expectException(InternalServerErrorException::class);
- $this->expectExceptionMessage("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
+ $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n");
(new TestConnector)->send(new RetryUserRequest(3, when: fn (): bool => false), $mockClient);
}
@@ -344,7 +344,7 @@ public function testAnExplicitRetryReplacesTheRequestsDefaultRetryPolicy(): void
public function testInvalidAttemptsOrIntervalsAreRejected(int $times, int $sleepMilliseconds, string $message): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage($message);
+ $this->expectExceptionMessageIs($message);
(new UserRequest)->retry($times, $sleepMilliseconds);
}
diff --git a/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
index 2fe82b2b87..fe6b8625d9 100644
--- a/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
+++ b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php
@@ -151,7 +151,7 @@ public function testYouCanMockExceptions(): void
$this->assertSame(['name' => 'Sam'], $okResponse->json());
$this->expectException(Exception::class);
- $this->expectExceptionMessage('Unable to connect!');
+ $this->expectExceptionMessageIs('Unable to connect!');
TestConnector::make()->send(new UserRequest);
}
@@ -163,7 +163,7 @@ public function testYouCanMockNormalExceptions(): void
]);
$this->expectException(Exception::class);
- $this->expectExceptionMessage('Custom Exception!');
+ $this->expectExceptionMessageIs('Custom Exception!');
TestConnector::make()->send(new UserRequest);
}
diff --git a/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
index a5596a5322..33a7e69d2f 100644
--- a/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
+++ b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php
@@ -91,7 +91,7 @@ public function testIfYouDontImplementTheGetTotalPagesMethodOnAPaginatorItWillTh
$paginator = $connector->paginate($request);
$this->expectException(LogicException::class);
- $this->expectExceptionMessage('Implement [getTotalPages] to use pooled pagination.');
+ $this->expectExceptionMessageIs('Implement [getTotalPages] to use pooled pagination.');
$paginator->pool();
}
diff --git a/tests/Saloon/Pagination/Feature/PaginationTest.php b/tests/Saloon/Pagination/Feature/PaginationTest.php
index d776ad6555..8e2efa0286 100644
--- a/tests/Saloon/Pagination/Feature/PaginationTest.php
+++ b/tests/Saloon/Pagination/Feature/PaginationTest.php
@@ -160,7 +160,7 @@ public function testThePaginatorWillThrowAnExceptionIfYouUseARequestThatIsNotPag
$request = new UserRequest;
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The request must implement [' . Paginatable::class . '] to be used with a paginator.');
+ $this->expectExceptionMessageIs('The request must implement [' . Paginatable::class . '] to be used with a paginator.');
$connector->paginate($request);
}
diff --git a/tests/Saloon/Unit/AuthenticatorTest.php b/tests/Saloon/Unit/AuthenticatorTest.php
index 9af914b61e..1903aad3cb 100644
--- a/tests/Saloon/Unit/AuthenticatorTest.php
+++ b/tests/Saloon/Unit/AuthenticatorTest.php
@@ -79,7 +79,7 @@ public function testTheRequiresAuthTraitWillThrowAnExceptionIfAnAuthenticatorIsN
]);
$this->expectException(MissingAuthenticatorException::class);
- $this->expectExceptionMessage('The "Hypervel\Tests\Saloon\Fixtures\Requests\RequiresAuthRequest" request requires authentication.');
+ $this->expectExceptionMessageIs('The "Hypervel\Tests\Saloon\Fixtures\Requests\RequiresAuthRequest" request requires authentication.');
$request = new RequiresAuthRequest;
diff --git a/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
index 6f258d3730..97b1c37767 100644
--- a/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
+++ b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php
@@ -45,7 +45,7 @@ public function testYouCanSetIt(): void
public function testItWillThrowAnExceptionIfYouSetANonArray(): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The body value must be an array.');
+ $this->expectExceptionMessageIs('The body value must be an array.');
$body = new FormBodyRepository;
$body->set('Sam');
diff --git a/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
index 816d09bef6..02652de0d7 100644
--- a/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
+++ b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php
@@ -35,7 +35,7 @@ public function testTheStoreCanHaveAnArrayOfMultipartValuesProvided(): void
public function testTheStoreWillThrowAnExceptionIfSetValueIsNotAnArray(): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The multipart body value must be an array.');
+ $this->expectExceptionMessageIs('The multipart body value must be an array.');
$body = new MultipartBodyRepository;
$body->set('123');
@@ -44,7 +44,7 @@ public function testTheStoreWillThrowAnExceptionIfSetValueIsNotAnArray(): void
public function testTheStoreWillThrowAnExceptionIfTheArrayDoesNotContainMultipartValues(): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
+ $this->expectExceptionMessageIs('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
new MultipartBodyRepository([
'name' => 'Sam',
@@ -173,7 +173,7 @@ public function testItWillThrowAnExceptionIfTheMergedItemsAreNotMultipartValueOb
$body = new MultipartBodyRepository;
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
+ $this->expectExceptionMessageIs('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.');
$body->merge([new MultipartValue('sidekick', 'Gareth')], ['superhero' => 'Black Widow']);
}
diff --git a/tests/Saloon/Unit/ConfigTest.php b/tests/Saloon/Unit/ConfigTest.php
index 81271e53da..2e5bb541d8 100644
--- a/tests/Saloon/Unit/ConfigTest.php
+++ b/tests/Saloon/Unit/ConfigTest.php
@@ -83,7 +83,7 @@ public function testYouCanPreventStrayApiRequests(): void
Http::preventStrayRequests();
$this->expectException(StrayRequestException::class);
- $this->expectExceptionMessage('Attempted request to [https://tests.saloon.dev/api/user] without a matching fake.');
+ $this->expectExceptionMessageIs('Attempted request to [https://tests.saloon.dev/api/user] without a matching fake.');
TestConnector::make()->send(new UserRequest);
}
diff --git a/tests/Saloon/Unit/MiddlewarePipelineTest.php b/tests/Saloon/Unit/MiddlewarePipelineTest.php
index 4a38b48d45..998ad40319 100644
--- a/tests/Saloon/Unit/MiddlewarePipelineTest.php
+++ b/tests/Saloon/Unit/MiddlewarePipelineTest.php
@@ -84,7 +84,7 @@ public function testRequestMiddlewareNamedPipeMustBeUnique(): void
);
$this->expectException(DuplicatePipeNameException::class);
- $this->expectExceptionMessage('The "YeeHawPipe" pipe already exists on the pipeline');
+ $this->expectExceptionMessageIs('The "YeeHawPipe" pipe already exists on the pipeline.');
$pipeline->onRequest(
callable: function (PendingRequest $request): void {
@@ -208,7 +208,7 @@ public function testResponseMiddlewareNamedPipeMustBeUnique(): void
}, 'ResponsePipe');
$this->expectException(DuplicatePipeNameException::class);
- $this->expectExceptionMessage('The "ResponsePipe" pipe already exists on the pipeline');
+ $this->expectExceptionMessageIs('The "ResponsePipe" pipe already exists on the pipeline.');
$pipeline->onResponse(function (): void {
}, 'ResponsePipe');
@@ -363,7 +363,7 @@ public function testFatalMiddlewareNamedPipeMustBeUnique(): void
);
$this->expectException(DuplicatePipeNameException::class);
- $this->expectExceptionMessage('The "YeeHawPipe" pipe already exists on the pipeline');
+ $this->expectExceptionMessageIs('The "YeeHawPipe" pipe already exists on the pipeline.');
$pipeline->onFatalException(
callable: function (): void {
@@ -476,7 +476,7 @@ public function testWhenMergingAMiddlewarePipelineTogetherIfTwoPipelinesExistWit
$pipelineB->onRequest(fn (): null => null, 'howdy');
$this->expectException(DuplicatePipeNameException::class);
- $this->expectExceptionMessage('The "howdy" pipe already exists on the pipeline');
+ $this->expectExceptionMessageIs('The "howdy" pipe already exists on the pipeline.');
$pipelineA->merge($pipelineB);
}
diff --git a/tests/Saloon/Unit/MockClientTest.php b/tests/Saloon/Unit/MockClientTest.php
index 720d88c965..d2964bd71a 100644
--- a/tests/Saloon/Unit/MockClientTest.php
+++ b/tests/Saloon/Unit/MockClientTest.php
@@ -161,7 +161,7 @@ public function testSaloonThrowsAnExceptionIfItCantWorkOutTheUrlResponse(): void
$this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB)));
$this->expectException(NoMockResponseFoundException::class);
- $this->expectExceptionMessage('Saloon was unable to guess a mock response for your request [https://google.com/user], consider using a wildcard url mock or a connector mock.');
+ $this->expectExceptionMessageIs('Saloon was unable to guess a mock response for your request [https://google.com/user], consider using a wildcard url mock or a connector mock.');
$mockClient->match($connectorB->createPendingRequest($requestC));
}
@@ -336,7 +336,7 @@ public function testYouCanMockExceptionsWithAClosure(): void
$this->assertSame(['name' => 'Sam'], $okResponse->json());
$this->expectException(TestResponseException::class);
- $this->expectExceptionMessage('Unable to connect!');
+ $this->expectExceptionMessageIs('Unable to connect!');
(new TestConnector)->send(new UserRequest, $mockClient);
}
@@ -348,7 +348,7 @@ public function testYouCanMockNormalExceptions(): void
]);
$this->expectException(Exception::class);
- $this->expectExceptionMessage('Custom Exception!');
+ $this->expectExceptionMessageIs('Custom Exception!');
(new TestConnector)->send(new UserRequest, $mockClient);
}
diff --git a/tests/Saloon/Unit/MockConfigTest.php b/tests/Saloon/Unit/MockConfigTest.php
index a9170bcef5..15f5c144b8 100644
--- a/tests/Saloon/Unit/MockConfigTest.php
+++ b/tests/Saloon/Unit/MockConfigTest.php
@@ -76,7 +76,7 @@ public function testYouCanThrowAnExceptionIfTheFixtureDoesNotExist(): void
]);
$this->expectException(FixtureMissingException::class);
- $this->expectExceptionMessage('example.json" could not be found in storage.');
+ $this->expectExceptionMessageIsOrContains('example.json" could not be found in storage.');
(new TestConnector)->send(new UserRequest, $mockClient);
}
diff --git a/tests/Saloon/Unit/MultipartValueTest.php b/tests/Saloon/Unit/MultipartValueTest.php
index 838de8cd91..d82247c4f8 100644
--- a/tests/Saloon/Unit/MultipartValueTest.php
+++ b/tests/Saloon/Unit/MultipartValueTest.php
@@ -51,7 +51,7 @@ public static function validValues(): iterable
public function testItWillThrowAnExceptionOnInvalidValues(mixed $value): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The value property must be either a Psr\Http\Message\StreamInterface, resource, string, finite number, boolean, null, or array.');
+ $this->expectExceptionMessageIs('The value property must be either a Psr\Http\Message\StreamInterface, resource, string, finite number, boolean, null, or array.');
new MultipartValue('test', $value);
}
@@ -72,7 +72,7 @@ public static function invalidValues(): iterable
public function testAnArrayValueCannotHaveAFilenameOrHeaders(?string $filename, array $headers): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('An array value cannot have a filename or headers.');
+ $this->expectExceptionMessageIs('An array value cannot have a filename or headers.');
new MultipartValue('roles', ['admin'], $filename, $headers);
}
diff --git a/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
index 3eb00d693f..1e210bfb82 100644
--- a/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
+++ b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php
@@ -45,7 +45,7 @@ public function testTheOauthConfigIsValidatedWhenGeneratingAnAuthorizationUrl():
$connector = new OAuth2Connector(['clientId' => '']);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$connector->authorizationUrl();
}
@@ -55,7 +55,7 @@ public function testTheOauthConfigIsValidatedWhenCreatingAccessTokens(): void
$connector = new OAuth2Connector(['clientId' => '']);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$connector->getAccessToken('code');
}
@@ -65,7 +65,7 @@ public function testTheOauthConfigIsValidatedWhenRefreshingAccessTokens(): void
$connector = new OAuth2Connector(['clientId' => '']);
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$connector->refreshAccessToken('');
}
diff --git a/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
index 5399318474..6e7edfbffa 100644
--- a/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
+++ b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php
@@ -58,7 +58,7 @@ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientId(): void
$config = new OAuthConfig('', 'client-secret', 'https://my-app.saloon.dev/auth/callback');
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client ID is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.');
$config->validate();
}
@@ -68,7 +68,7 @@ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientSecret(): vo
$config = new OAuthConfig('client-id', '', 'https://my-app.saloon.dev/auth/callback');
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Client Secret is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.');
$config->validate();
}
@@ -78,7 +78,7 @@ public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheRedirectUri(): voi
$config = new OAuthConfig('client-id', 'client-secret');
$this->expectException(OAuthConfigValidationException::class);
- $this->expectExceptionMessage('The Redirect URI is empty or has not been provided.');
+ $this->expectExceptionMessageIs('The Redirect URI is empty or has not been provided.');
$config->validate();
}
diff --git a/tests/Saloon/Unit/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php
index e8fcfe45c8..2ac2d16022 100644
--- a/tests/Saloon/Unit/PendingRequestTest.php
+++ b/tests/Saloon/Unit/PendingRequestTest.php
@@ -79,7 +79,7 @@ public function testThePendingRequestValidatesProperlyFormedHeaders(): void
]);
$this->expectException(InvalidHeaderException::class);
- $this->expectExceptionMessage('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].');
+ $this->expectExceptionMessageIs('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].');
(new TestConnector)->createPendingRequest($request);
}
@@ -123,7 +123,7 @@ public function testHeaderValuesMustBeScalarNullOrStringable(): void
);
$this->expectException(InvalidHeaderException::class);
- $this->expectExceptionMessage('HTTP header values must be scalar, null, Hypervel Stringable, or arrays of those values.');
+ $this->expectExceptionMessageIs('HTTP header values must be scalar, null, Hypervel Stringable, or arrays of those values.');
$pendingRequest->toPsrRequest();
}
diff --git a/tests/Saloon/Unit/RequestTest.php b/tests/Saloon/Unit/RequestTest.php
index 727883a969..8e2ea35f06 100644
--- a/tests/Saloon/Unit/RequestTest.php
+++ b/tests/Saloon/Unit/RequestTest.php
@@ -172,7 +172,7 @@ public function testARelativeEndpointCannotBeJoinedToAnEmptyBaseUrl(): void
$pendingRequest = (new CustomBaseUrlConnector)->createPendingRequest($request);
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
+ $this->expectExceptionMessageIs('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
$pendingRequest->uri();
}
@@ -183,7 +183,7 @@ public function testItThrowsAnExceptionIfYouForgetToAddAMethod(): void
$request = new MissingMethodRequest;
$this->expectException(LogicException::class);
- $this->expectExceptionMessage('Your request is missing an HTTP method. Add a method property such as [protected Method $method = Method::GET].');
+ $this->expectExceptionMessageIs('Your request is missing an HTTP method. Add a method property such as [protected Method $method = Method::GET].');
$connector->send($request);
}
@@ -208,7 +208,7 @@ public function testContainerResolutionPassesRequestParameters(): void
public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage($message);
+ $this->expectExceptionMessageIs($message);
(new UserRequest)->withCookie(new SetCookie($cookie));
}
diff --git a/tests/Saloon/Unit/URLHelperTest.php b/tests/Saloon/Unit/URLHelperTest.php
index 2438e29a1b..7b99a6bc48 100644
--- a/tests/Saloon/Unit/URLHelperTest.php
+++ b/tests/Saloon/Unit/URLHelperTest.php
@@ -58,7 +58,7 @@ public function testJoinThrowsWhenEndpointIsAnAbsoluteUrlToPreventSsrfAndCredent
$attackerUrl = 'https://attacker.example.com/steal';
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+ $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.');
UrlResolver::resolve($trustedBaseUrl, $attackerUrl, false);
}
@@ -85,7 +85,7 @@ public function testCreatingAPendingRequestWithARequestThatReturnsAbsoluteUrlFro
$request = new AbsoluteEndpointRequest('https://attacker.example.com/callback');
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('The request endpoint cannot replace the connector base URL.');
+ $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.');
// The URL is resolved when it is first read rather than when the pending request is created.
$connector->createPendingRequest($request)->uri();
@@ -204,7 +204,7 @@ public function testBaseAndEndpointQueriesAreRetainedBeforeRepositoryOverrides()
public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void
{
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
+ $this->expectExceptionMessageIs('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.');
UrlResolver::resolve('', 'google.com/search', true);
}
@@ -213,7 +213,7 @@ public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void
public function testAlternateSchemesAreRejected(string $baseUrl): void
{
$this->expectException(PendingRequestException::class);
- $this->expectExceptionMessage('The request endpoint must be an absolute HTTP or HTTPS URI.');
+ $this->expectExceptionMessageIs('The request endpoint must be an absolute HTTP or HTTPS URI.');
UrlResolver::resolve($baseUrl, 'file:///etc/passwd', true);
}
From 77f6ec42115bcba1d467f4b4529ac1717a7a5ad2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 06:20:15 +0000
Subject: [PATCH 22/26] Record that Laravel Idea metadata is not shipped
Upstream Saloon's Laravel plugin, laravel-data and laravel-permission each
ship a root ide.json with code-generation templates for the Laravel Idea
PhpStorm plugin. Hypervel does not ship this metadata for any of them.
The Saloon README lists it with the other plugin features that are not
included and points to the saloon:* generator commands instead. The Data
and Permission sync notes record it, since neither README covers it.
---
docs/upstream-sync/sync.yaml | 4 ++--
src/saloon/README.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 749c225473..e82628b512 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -156,14 +156,14 @@ spatie/laravel-data:
checked_through: ce296f22861dc1237ce468754cc7f46d3ac34ad5
last_reviewed_pr: null
sync_date: '2026-10-03'
- notes: Hypervel replaces upstream's data pipes and resolvers with a fixed engine. The pipes and DataFromSomethingResolver map to Support/Creation/DataCreator and its per-operation ConstructionState; the validation resolvers and built-in rule inferrers map to Support/Validation/DataValidationCompiler and DataValidator; TransformedDataResolver and VisibleDataFieldsResolver map to Support/Transformation/DataTransformer and PartialTree. DataCreator's direct construction from DataClass creation recipes and DataTransformer's bulk copy are deliberate fast paths, so apply upstream behavior changes to them as well as to the general paths, and compare tests/Benchmarks/Data/benchmark.php results before and after. Upstream tests for those replaced classes live in the matching upstream feature tests or in the owning engine's tests under tests/Data/Support.
+ notes: Hypervel replaces upstream's data pipes and resolvers with a fixed engine. The pipes and DataFromSomethingResolver map to Support/Creation/DataCreator and its per-operation ConstructionState; the validation resolvers and built-in rule inferrers map to Support/Validation/DataValidationCompiler and DataValidator; TransformedDataResolver and VisibleDataFieldsResolver map to Support/Transformation/DataTransformer and PartialTree. DataCreator's direct construction from DataClass creation recipes and DataTransformer's bulk copy are deliberate fast paths, so apply upstream behavior changes to them as well as to the general paths, and compare tests/Benchmarks/Data/benchmark.php results before and after. Upstream tests for those replaced classes live in the matching upstream feature tests or in the owning engine's tests under tests/Data/Support. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported.
spatie/laravel-permission:
branch: main
checked_through: 6615eefac655efcd652fe394a20cbdf4f72c609f
last_reviewed_pr: null
sync_date: '2026-10-05'
- notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported.
+ notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported either.
aimeos/laravel-nestedset:
branch: master
diff --git a/src/saloon/README.md b/src/saloon/README.md
index f36f51d8f9..678328265b 100644
--- a/src/saloon/README.md
+++ b/src/saloon/README.md
@@ -8,7 +8,7 @@ Requests and pending requests use fluent methods in the style of Hypervel's HTTP
Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`.
-There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, including for a request that would record a missing fixture, so allow the API's URLs with `Http::allowStrayRequests()` while recording. Fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
+There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. The plugin's `ide.json` code-generation metadata for the Laravel Idea PhpStorm plugin is not included; generate classes with the `saloon:*` commands (see [Generating Integrations](https://hypervel.org/docs/saloon#generating-integrations)). Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, including for a request that would record a missing fixture, so allow the API's URLs with `Http::allowStrayRequests()` while recording. Fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock.
`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`.
From 0f7eff50f2725df0df2fb90b82bf78efc34fa24d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 06:20:52 +0000
Subject: [PATCH 23/26] Record the Saloon sync checkpoints
Set the checkpoints for Saloon and its four plugins (Laravel, cache,
pagination and rate limiting), plus the shared Saloon docs:
- saloonphp/saloon v4 through 2f45e526f9d1 (#564)
- saloonphp/laravel-plugin v4 through 47d06ef8621a (#89)
- saloonphp/cache-plugin v3 through 5a036bae401d (#17)
- saloonphp/pagination-plugin v2 through a61822ca93c1 (#17)
- saloonphp/rate-limit-plugin v2 through aed5ff488e4d (#31)
- Sammyjo20/saloon-docs v3 through 8ea96847141f (#92)
---
docs/upstream-sync/sync.yaml | 36 ++++++++++++++++++------------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index e82628b512..60fd2ca3ba 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -179,44 +179,44 @@ PHP-Open-Source-Saver/jwt-auth:
saloonphp/saloon:
branch: v4
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: 2f45e526f9d1d10a138f864492b074b046d2e52e
+ last_reviewed_pr: 564
+ sync_date: '2026-10-07'
notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Helpers/OAuth2/OAuthConfig maps to the immutable Data/OAuthConfig, and token-response parsing shared by the OAuth grant traits lives in Traits/OAuth2/ParsesOAuthTokenResponses. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. src/saloon/README.md lists the deliberate public differences across all five entries, with their reasons and Hypervel equivalents; check them against current source before applying an upstream change they affect, and keep the README current.
saloonphp/laravel-plugin:
branch: v4
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: 47d06ef8621a5a25a2041e4a9004d96539db3f34
+ last_reviewed_pr: 89
+ sync_date: '2026-10-07'
notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's Http directory holds a MockClient subclass and observability middleware rather than a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel and use the core fixtures, which the plugin's fixtures copy. The plugin's Feature/PoolTest and Feature/RequestTest repeat core cases, so merge their new or changed cases into tests/Saloon/Feature/PoolTest and tests/Integration/Saloon/Feature/{PoolTest,RequestTest}, keeping plugin-specific setup such as Saloon::fake(), rather than adding separate copies.
saloonphp/cache-plugin:
branch: v3
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: 5a036bae401dab95b41c514dcd87b25bc4600b24
+ last_reviewed_pr: 17
+ sync_date: '2026-10-07'
notes: Part of the unified src/saloon package. Upstream's CacheMiddleware and CacheRecorderMiddleware map to SaloonManager::send(), which reads the cache before matching the mock client and writes after the response, and clearCache() maps to SaloonManager::clearCache(); keys come from Cache/CacheKey. Drivers map to framework cache stores selected by Cacheable::cacheStore(); assess driver changes against that integration instead of duplicating them. Connectors implement Cacheable and override cacheKey() and cacheableMethods() rather than using HasCaching. SaloonServiceProvider allows CachedResponse through the cache's serializable-class policy. Tests map to tests/Saloon/Cache/{Feature,Fixtures}, with the real-request case in tests/Integration/Saloon/Cache.
saloonphp/pagination-plugin:
branch: v2
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: a61822ca93c1207008941822b00df1d8740a4096
+ last_reviewed_pr: 17
+ sync_date: '2026-10-07'
notes: Part of the unified src/saloon package, under Pagination. Request-owned pagination, link paginators and the protected paginator state ($pageNumber, $startPage, $perPageLimit, $currentResponse, $currentPageLoaded, $pooling) are used by SDK generators; check affected consumers when adapting upstream changes. Upstream's async iteration maps to Paginator::pool(), so async test cases traverse through pool(). The infinite-loop check runs in Paginator::current(), not request middleware, because a middleware closure would keep the paginator alive through its own request. Upstream tests map to tests/Saloon/Pagination/Feature, with the public superhero API faked by Fixtures/SuperheroApi; Hypervel-only cases are in tests/Saloon/Pagination/PaginatorTest.
saloonphp/rate-limit-plugin:
branch: v2
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: aed5ff488e4dc2b7c1fc19ba40e16f139d658126
+ last_reviewed_pr: 31
+ sync_date: '2026-10-07'
notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Upstream's limiter middleware, too-many-attempts limit and store records become admission before transport, a cooldown recorded from the transport response, and Saloon::inspectRateLimit(). Feature/HasRateLimitsTest, Unit/{Destruct,Helper}Test and Laravel/Feature/JobMiddlewareTest map to tests/Saloon/RateLimit/{Feature,Unit,Hypervel/Feature}; ported cases fake the transport with Http::fake(), because Saloon fakes consume no capacity, and assert through inspectRateLimit() instead of store records. Unit/{Limit,Bucket,LimitStore}Test, Feature/Stores and Laravel/Feature/LaravelStoreTest test classes replaced by the rate limiter's Limit, CalendarWindow and LeakyBucket policies and its stores, so compare their changes with tests/RateLimiter (including its shared store contract) and tests/Integration/RateLimiter instead of porting them. RateLimitTest keeps the fake, cache and policy-group cases.
Sammyjo20/saloon-docs:
branch: v3
- checked_through: null
- last_reviewed_pr: null
- sync_date: null
+ checked_through: 8ea96847141fe60e24eb9dda2eb0e27a59c6bebd
+ last_reviewed_pr: 92
+ sync_date: '2026-10-07'
notes: Shared documentation for the unified src/saloon package and all four plugin entries above. Reconcile applicable core and plugin pages into the existing sections of the single src/docs/saloon.md rather than separate plugin guides, keeping Hypervel's additions and checking examples against current source, in Laravel-style prose matching the guide. The v3 branch includes v4 guidance. Installation commands, supported versions, upgrade guides, release notes, showcase, tutorials, the book and the third-party Lawman and SDK generator pages have no Hypervel equivalent; check them only for behavior the guide must still describe.
getsentry/sentry-laravel:
From 88d238adf4d3268016dabf97bbafc06dcf1a5fd6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 07:05:54 +0000
Subject: [PATCH 24/26] Fix the cluster calendar test and cover retried request
bodies
The Redis store test for calendar windows read the server clock with
time() without a node. PhpRedis's RedisCluster::time() needs a key or
address, so the test failed on the Redis Cluster job. It now reads the
clock of the node that holds the window's key. The store itself reads
TIME inside its Lua script and was not affected.
Retrying a request rewinds its body to where the first attempt started,
but nothing tested it. New retry tests send a resource body that starts
part way through, attach a resource file, and use a non-seekable body.
The first two fail if the rewind is removed. The third checks that a
non-seekable body throws BodyException with the failed response's
exception as its previous one, instead of resending an empty body.
Verified on a three-node Redis Cluster and a standalone Redis 8 server.
---
.../RateLimiter/Redis/RedisStoreTest.php | 8 ++-
tests/Saloon/Feature/RetryRequestTest.php | 69 +++++++++++++++++++
2 files changed, 75 insertions(+), 2 deletions(-)
diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
index d6e9e0eda8..32e8e29691 100644
--- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
+++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php
@@ -177,13 +177,17 @@ public function testCalendarWindowsFollowRedisTimeWhenThePhpClockIsInAnotherPeri
$first = $limiter->consume($policy);
$second = $limiter->consume($policy);
- $redisSeconds = (int) $this->redisClient()->time()[0];
+ $redis = $this->redisClient();
+
+ // RedisCluster::time() needs a node, so read the clock of the node that holds the window's key.
+ $redisTime = $this->usingRedisCluster() ? $redis->time($this->physicalKey($policy)) : $redis->time();
+ $redisSeconds = (int) $redisTime[0];
$this->assertTrue($first->allowed());
$this->assertSame(4, $first->remaining());
$this->assertSame(3, $second->remaining());
$this->assertEqualsWithDelta(3_600 - $redisSeconds % 3_600, $second->resetAfter(), 1);
- $this->assertLessThanOrEqual(3_600_000, $this->redisClient()->pttl($this->physicalKey($policy)));
+ $this->assertLessThanOrEqual(3_600_000, $redis->pttl($this->physicalKey($policy)));
}
/**
diff --git a/tests/Saloon/Feature/RetryRequestTest.php b/tests/Saloon/Feature/RetryRequestTest.php
index 54e1cf78ce..284a93a3b3 100644
--- a/tests/Saloon/Feature/RetryRequestTest.php
+++ b/tests/Saloon/Feature/RetryRequestTest.php
@@ -4,8 +4,14 @@
namespace Hypervel\Tests\Saloon\Feature;
+use ArrayObject;
use Exception;
+use GuzzleHttp\Promise\PromiseInterface;
+use GuzzleHttp\Psr7\NoSeekStream;
+use GuzzleHttp\Psr7\Utils;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Http\Client\Request as HttpRequest;
+use Hypervel\Saloon\Exceptions\BodyException;
use Hypervel\Saloon\Exceptions\Request\FatalRequestException;
use Hypervel\Saloon\Exceptions\Request\RequestException;
use Hypervel\Saloon\Exceptions\Request\Statuses\InternalServerErrorException;
@@ -14,6 +20,7 @@
use Hypervel\Saloon\Http\Faking\MockResponse;
use Hypervel\Saloon\Http\PendingRequest;
use Hypervel\Saloon\SaloonServiceProvider;
+use Hypervel\Support\Facades\Http;
use Hypervel\Support\Sleep;
use Hypervel\Testbench\TestCase;
use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector;
@@ -338,6 +345,50 @@ public function testAnExplicitRetryReplacesTheRequestsDefaultRetryPolicy(): void
$mockClient->assertSentCount(1);
}
+ public function testARetrySendsAResourceBodyAgainFromWhereTheFirstAttemptStarted(): void
+ {
+ $resource = fopen('php://memory', 'rw+');
+ fwrite($resource, 'skipped:payload');
+ fseek($resource, 8);
+ $sent = $this->fakeOneFailedUpload();
+
+ $response = (new TestConnector)->send((new UserRequest)->withBody($resource, 'text/plain')->retry(2));
+
+ $this->assertSame(200, $response->status());
+ $this->assertSame(['payload', 'payload'], $sent->getArrayCopy());
+ }
+
+ public function testARetrySendsAttachedResourcesAgain(): void
+ {
+ $resource = fopen('php://memory', 'rw+');
+ fwrite($resource, 'payload');
+ rewind($resource);
+ $sent = $this->fakeOneFailedUpload();
+
+ $response = (new TestConnector)->send((new UserRequest)->attach('file', $resource, 'file.txt')->retry(2));
+
+ $this->assertSame(200, $response->status());
+ $this->assertCount(2, $sent);
+ $this->assertStringContainsString('name="file"; filename="file.txt"', $sent[0]);
+ $this->assertStringContainsString("\r\n\r\npayload\r\n", $sent[0]);
+ $this->assertSame($sent[0], $sent[1]);
+ }
+
+ public function testANonSeekableBodyIsNotRetried(): void
+ {
+ $sent = $this->fakeOneFailedUpload();
+ $request = (new UserRequest)->withBody(new NoSeekStream(Utils::streamFor('payload')), 'text/plain')->retry(2);
+
+ try {
+ (new TestConnector)->send($request);
+ $this->fail('A non-seekable body was retried.');
+ } catch (BodyException $exception) {
+ $this->assertSame('The request body is not seekable and cannot be retried safely.', $exception->getMessage());
+ $this->assertInstanceOf(InternalServerErrorException::class, $exception->getPrevious());
+ $this->assertSame(['payload'], $sent->getArrayCopy());
+ }
+ }
+
// Upstream raises negative attempts and intervals to their minimum. Hypervel rejects them; a zero interval is
// valid and is the default.
#[DataProvider('invalidRetryPolicies')]
@@ -362,4 +413,22 @@ public static function invalidRetryPolicies(): array
'negative interval' => [2, -1, 'The retry delay must be a non-negative integer.'],
];
}
+
+ /**
+ * Fake a transport that reads each request body from its current position and fails the first attempt.
+ *
+ * @return ArrayObject
+ */
+ protected function fakeOneFailedUpload(): ArrayObject
+ {
+ $sent = new ArrayObject;
+
+ Http::fake(function (HttpRequest $request) use ($sent): PromiseInterface {
+ $sent[] = $request->toPsrRequest()->getBody()->getContents();
+
+ return Http::response(status: count($sent) === 1 ? 500 : 200);
+ });
+
+ return $sent;
+ }
}
From 9f088f9ef61646dd550add8be9a3d04a0eb806e0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 07:06:01 +0000
Subject: [PATCH 25/26] Resolve dot segments in Saloon's integrations path
Without a configured integrations namespace, the generator commands map
the integrations path beneath the app directory to a namespace. They
compared the raw configured path, so app_path('Http/../Integrations')
produced a namespace containing "..", and app_path('../src/Integrations')
passed the inside-the-app check and generated an invalid namespace
instead of asking for one.
The commands now canonicalize the configured path with Symfony's
Path::canonicalize() and use it for the namespace, the destination file
and the integration suggestions. The app path is canonicalized the same
way before the comparison. Saloon now requires symfony/filesystem
directly; the root package already did.
The generator tests cover an in-app path with dot segments and one that
leaves the app directory.
---
src/saloon/composer.json | 1 +
.../src/Console/Commands/MakeCommand.php | 26 ++++++++++++-------
.../Saloon/Console/GeneratorCommandsTest.php | 20 +++++++++++---
3 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/src/saloon/composer.json b/src/saloon/composer.json
index f7cd319ba3..a54bc99f6c 100644
--- a/src/saloon/composer.json
+++ b/src/saloon/composer.json
@@ -58,6 +58,7 @@
"psr/http-message": "^2.0",
"symfony/console": "^8.1.2",
"symfony/dom-crawler": "^8.1",
+ "symfony/filesystem": "^8.1",
"symfony/finder": "^8.1",
"symfony/var-dumper": "^8.1"
},
diff --git a/src/saloon/src/Console/Commands/MakeCommand.php b/src/saloon/src/Console/Commands/MakeCommand.php
index 25e0833785..04371a4725 100644
--- a/src/saloon/src/Console/Commands/MakeCommand.php
+++ b/src/saloon/src/Console/Commands/MakeCommand.php
@@ -11,6 +11,7 @@
use Hypervel\Support\Str;
use LogicException;
use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Filesystem\Path;
use function Hypervel\Prompts\suggest;
@@ -95,6 +96,16 @@ protected function qualifyClass(string $name): string
: $namespace . '\\' . $name;
}
+ /**
+ * Get the configured integrations path.
+ *
+ * Dot segments are removed so the path can be compared with the application directory and mapped to a namespace.
+ */
+ protected function integrationsPath(): string
+ {
+ return Path::canonicalize($this->config->string('saloon.integrations_path'));
+ }
+
/**
* Get the configured integrations namespace.
*
@@ -107,18 +118,18 @@ protected function integrationNamespace(): string
if ($namespace === null) {
$rootNamespace = rtrim($this->rootNamespace(), '\\');
- $appPath = rtrim($this->hypervel->path(), '/\\');
- $integrationsPath = rtrim($this->config->string('saloon.integrations_path'), '/\\');
+ $appPath = Path::canonicalize($this->hypervel->path());
+ $integrationsPath = $this->integrationsPath();
if ($integrationsPath === $appPath) {
return $rootNamespace;
}
- if (! str_starts_with($integrationsPath, $appPath . DIRECTORY_SEPARATOR)) {
+ if (! str_starts_with($integrationsPath, $appPath . '/')) {
throw new LogicException('The [saloon.integrations_namespace] configuration value is required when the integrations path is outside the application directory.');
}
- return $rootNamespace . '\\' . str_replace(DIRECTORY_SEPARATOR, '\\', substr($integrationsPath, strlen($appPath) + 1));
+ return $rootNamespace . '\\' . str_replace('/', '\\', substr($integrationsPath, strlen($appPath) + 1));
}
if (! is_string($namespace) || $namespace === '') {
@@ -179,7 +190,7 @@ protected function promptForMissingArgumentsUsing(): array
*/
protected function getExistingIntegrations(string $search = ''): array
{
- $integrationsPath = $this->config->string('saloon.integrations_path');
+ $integrationsPath = $this->integrationsPath();
if (! $this->files->isDirectory($integrationsPath)) {
return [];
@@ -216,9 +227,6 @@ protected function getPath(string $name): string
$relativeName = Str::after($name, $this->integrationNamespace() . '\\');
}
- return rtrim($this->config->string('saloon.integrations_path'), '/\\')
- . DIRECTORY_SEPARATOR
- . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativeName)
- . '.php';
+ return $this->integrationsPath() . '/' . str_replace('\\', '/', $relativeName) . '.php';
}
}
diff --git a/tests/Saloon/Console/GeneratorCommandsTest.php b/tests/Saloon/Console/GeneratorCommandsTest.php
index 573e4281ad..3862413766 100644
--- a/tests/Saloon/Console/GeneratorCommandsTest.php
+++ b/tests/Saloon/Console/GeneratorCommandsTest.php
@@ -211,13 +211,14 @@ public static function integrationsPathsInsideTheAppDirectory(): array
return [
'application directory' => ['', 'App\Stripe'],
'subdirectory' => ['Integrations', 'App\Integrations\Stripe'],
+ 'path with dot segments' => ['Http/../Integrations', 'App\Integrations\Stripe'],
];
}
- public function testAnIntegrationsPathOutsideTheAppDirectoryRequiresANamespace(): void
+ #[DataProvider('integrationsPathsOutsideTheAppDirectory')]
+ public function testAnIntegrationsPathOutsideTheAppDirectoryRequiresANamespace(string $relativePath): void
{
- // A sibling whose name starts with the app directory's name is still outside it.
- config()->set('saloon.integrations_path', base_path('app-other/Integrations'));
+ config()->set('saloon.integrations_path', base_path($relativePath));
$this->generatedDirectories[] = base_path('app-other');
try {
@@ -237,6 +238,19 @@ public function testAnIntegrationsPathOutsideTheAppDirectoryRequiresANamespace()
$this->assertDirectoryDoesNotExist(base_path('app-other'));
}
+ /**
+ * Get integrations paths, relative to the base path, that are outside the app directory.
+ *
+ * @return array
+ */
+ public static function integrationsPathsOutsideTheAppDirectory(): array
+ {
+ return [
+ 'sibling whose name starts with the app directory name' => ['app-other/Integrations'],
+ 'dot segments leaving the app directory' => ['app/../app-other/Integrations'],
+ ];
+ }
+
public function testCommandOptionsOverrideConfiguredPathAndNamespace(): void
{
config()->set('saloon.integrations_path', base_path('ignored/integrations'));
From 380709f5a296cbfdcf8ead29c2cd3d5bf24b3063 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 7 Oct 2026 07:06:01 +0000
Subject: [PATCH 26/26] Import request and connector classes in Saloon guide
examples
The SDK connector, resource and HasConnector examples used GetUser,
GetRepository and GitHubConnector without importing them. Copied as
shown, the classes resolve in the wrong namespace: the connector method
fails with a missing class, and GitHubConnector::class silently names a
class that does not exist until the request sends.
---
src/docs/saloon.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 7844fef415..3e15a64411 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -255,6 +255,7 @@ public function boot(PendingRequest $pendingRequest): void
A connector may serve as an SDK's entry point by offering methods that send its requests:
```php
+use App\Http\Integrations\GitHub\Requests\GetUser;
use Hypervel\Saloon\Http\Response;
public function user(string $username): Response
@@ -266,6 +267,8 @@ public function user(string $username): Response
When an integration contains many endpoints, you may group related requests into resource classes. Extend `BaseResource` and use the `@extends` annotation to specify your connector type:
```php
+use App\Http\Integrations\GitHub\GitHubConnector;
+use App\Http\Integrations\GitHub\Requests\GetRepository;
use Hypervel\Saloon\Http\BaseResource;
use Hypervel\Saloon\Http\Response;
@@ -440,6 +443,7 @@ Standalone requests define their headers, query parameters, options, authenticat
A request that always uses the same connector may send itself with the `HasConnector` trait. Define the connector class in a `$connector` property:
```php
+use App\Http\Integrations\GitHub\GitHubConnector;
use Hypervel\Saloon\Traits\Request\HasConnector;
class GetUser extends Request