From 9f6012cc7f50792a6fdef8e2b5304b2a25396458 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 11:00:23 +0000
Subject: [PATCH 1/8] Raise the Algolia PHP client minimum to 4.49.0
Require ^4.49.0 in the components development dependencies and update the Scout and Foundation Composer suggestions to match.
Algolia 4.49.0 supports guzzlehttp/psr7 3 and includes Guzzle 8 adapter compatibility, removing the Algolia dependency blocker for the upcoming Guzzle upgrade without changing Hypervel HTTP behavior.
Validated all three Composer manifests and checked the diff. Composer upgraded only Algolia in the local worktree; the untracked lockfile is not included.
---
composer.json | 2 +-
src/foundation/composer.json | 2 +-
src/scout/composer.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/composer.json b/composer.json
index 23f481511..957ebb83c 100644
--- a/composer.json
+++ b/composer.json
@@ -303,7 +303,7 @@
},
"require-dev": {
"ably/ably-php": "^1.0",
- "algolia/algoliasearch-client-php": "^4.0",
+ "algolia/algoliasearch-client-php": "^4.49.0",
"brianium/paratest": "^7.24",
"composer/composer": "^2.10.3",
"composer/semver": "^3.4",
diff --git a/src/foundation/composer.json b/src/foundation/composer.json
index c0989bf40..728767c84 100644
--- a/src/foundation/composer.json
+++ b/src/foundation/composer.json
@@ -81,7 +81,7 @@
"hypervel/websocket-server": "^0.4"
},
"suggest": {
- "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.0).",
+ "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).",
"composer/semver": "Required to use PackageManifest::satisfies() for version constraint checking (^3.0).",
"fakerphp/faker": "Required to use the fake() helper and WithFaker trait (^1.24).",
"meilisearch/meilisearch-php": "Required to use the InteractsWithMeilisearch trait (^1.16).",
diff --git a/src/scout/composer.json b/src/scout/composer.json
index a82f3566c..cc235a836 100644
--- a/src/scout/composer.json
+++ b/src/scout/composer.json
@@ -55,7 +55,7 @@
"symfony/console": "^8.1"
},
"suggest": {
- "algolia/algoliasearch-client-php": "Required for Algolia driver (^4.0)",
+ "algolia/algoliasearch-client-php": "Required for Algolia driver (^4.49.0)",
"meilisearch/meilisearch-php": "Required for Meilisearch driver (^1.16)",
"typesense/typesense-php": "Required for Typesense driver (^5.2)"
},
From 8b328931185ff89bdda51242730270f2811c2902 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:58:43 +0000
Subject: [PATCH 2/8] Add incremental HTTP response line readers
Add lines() and jsonLines() for consuming response bodies from their current position without buffering the complete response. Preserve blank lines and partial final records, handle split LF and CRLF records, and use the normal JSON decoding flags and exceptions.
Reject real streaming requests when the default handler cannot provide streaming because allow_url_fopen is disabled. Keep fakes, caller-owned stacks, and custom handlers or clients under their existing contracts, without adding middleware on enabled hosts.
Cover lazy reads, decoding failures, custom transports, and coroutine progress with focused stream and loopback-server tests. Keep complete regressions for buffered-read delays and idle read timeout failures, linked to swoole/swoole-src#6235 and #6236. Skip those regressions on affected Swoole versions unless explicitly enabled for a patched build; do not add production workarounds.
Document incremental response consumption, stream ownership, and the default transport requirements.
---
.env.example | 3 +
src/docs/http-client.md | 29 +++
src/http/src/Client/PendingRequest.php | 15 +-
src/http/src/Client/Response.php | 52 +++++
tests/Http/Fixtures/streaming-handler.php | 40 ++++
tests/Http/Fixtures/streaming-server.php | 45 +++++
tests/Http/HttpClientResponseStreamTest.php | 136 +++++++++++++
tests/Http/HttpClientStreamingTest.php | 210 ++++++++++++++++++++
8 files changed, 529 insertions(+), 1 deletion(-)
create mode 100644 tests/Http/Fixtures/streaming-handler.php
create mode 100644 tests/Http/Fixtures/streaming-server.php
create mode 100644 tests/Http/HttpClientResponseStreamTest.php
create mode 100644 tests/Http/HttpClientStreamingTest.php
diff --git a/.env.example b/.env.example
index 231c30030..317364932 100644
--- a/.env.example
+++ b/.env.example
@@ -71,6 +71,9 @@
# Uncomment TEST_SERVER_HOST to opt into server integration tests.
# TEST_SERVER_HOST=127.0.0.1
+# Run streaming regressions on a Swoole build with the upstream fixes applied.
+# HYPERVEL_TEST_SWOOLE_STREAM_FIXES=1
+
# Algolia Integration Tests
# ALGOLIA_APP_ID=your-app-id
# ALGOLIA_SECRET=your-admin-api-key
diff --git a/src/docs/http-client.md b/src/docs/http-client.md
index 2b638ea74..d0648a0d7 100644
--- a/src/docs/http-client.md
+++ b/src/docs/http-client.md
@@ -2,6 +2,7 @@
- [Introduction](#introduction)
- [Making Requests](#making-requests)
+ - [Streaming Responses](#streaming-responses)
- [Request Data](#request-data)
- [QUERY Requests](#query-requests)
- [Headers](#headers)
@@ -147,6 +148,34 @@ If you would like to dump the outgoing request instance before it is sent and te
return Http::dd()->get('http://example.com');
```
+
+### Streaming Responses
+
+To process a response as it arrives, you may enable the `stream` option. The `jsonLines` method allows you to iterate over newline-delimited JSON without loading the entire response into memory:
+
+```php
+$response = Http::withOptions(['stream' => true, 'read_timeout' => 30])
+ ->get('https://example.com/events');
+
+try {
+ foreach ($response->jsonLines() as $event) {
+ // Process the event...
+ }
+} finally {
+ $response->close();
+}
+```
+
+The `jsonLines` method skips blank lines and throws a `JsonException` if a record contains invalid JSON. Like `json`, it uses `Response::$defaultJsonDecodingFlags` unless you pass `flags`. For example, `$response->jsonLines(flags: JSON_BIGINT_AS_STRING)` preserves large integers as strings. The `decodeUsing` callback applies to the whole body, not individual lines.
+
+For plain text or a custom JSON decoder, you may use the `lines` method instead. It removes LF and CRLF line endings, preserves empty lines, and includes the final line even when it has no newline. For binary data and other formats, you may read the underlying PSR-7 response body directly.
+
+Both methods continue reading from the body's current position. They do not rewind it. If you call `body` or `json` first, you must rewind the stream before reading its lines. Memory usage grows with the longest line, not the total response size.
+
+The default streaming handler requires PHP's `allow_url_fopen` setting. If this setting is disabled, real streaming requests throw a `RuntimeException`; faked requests are unaffected. Custom handlers and clients are responsible for providing their own streaming support.
+
+Unlike buffered requests, the default streaming handler does not use shared cURL connections or multiplexing. Streaming requests fail if their connection options require either feature.
+
### Request Data
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 177b6bb66..4cb501c58 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -1594,7 +1594,20 @@ public function buildHandlerStack(): HandlerStack
$handler = $this->factory->getConnectionHandler($this->connection);
}
- return $this->pushHandlers(HandlerStack::create($handler));
+ $stack = $this->pushHandlers(HandlerStack::create($handler));
+
+ if ($this->handler === null && ! ini_get('allow_url_fopen')) {
+ // Faked responses return before reaching this transport-only guard.
+ $stack->push(static fn (callable $handler): Closure => static function (RequestInterface $request, array $options) use ($handler): PromiseInterface {
+ if ($options['stream'] ?? false) {
+ throw new RuntimeException('Streaming responses require allow_url_fopen when using the default HTTP handler.');
+ }
+
+ return $handler($request, $options);
+ });
+ }
+
+ return $stack;
}
/**
diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php
index 826eebc49..067b84e9e 100644
--- a/src/http/src/Client/Response.php
+++ b/src/http/src/Client/Response.php
@@ -6,12 +6,14 @@
use ArrayAccess;
use Closure;
+use Generator;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Psr7\StreamWrapper;
use GuzzleHttp\TransferStats;
use Hypervel\Http\Client\Concerns\DeterminesStatusCode;
use Hypervel\Support\Collection;
use Hypervel\Support\Fluent;
+use Hypervel\Support\Json;
use Hypervel\Support\Traits\Macroable;
use Hypervel\Support\Traits\Tappable;
use InvalidArgumentException;
@@ -91,6 +93,56 @@ public function body(): string
return (string) $this->response->getBody();
}
+ /**
+ * Read lines from the current response body position without rewinding.
+ *
+ * @return Generator
+ */
+ public function lines(): Generator
+ {
+ $stream = $this->response->getBody();
+ $buffer = '';
+
+ while (! $stream->eof()) {
+ $chunk = $stream->read(8192);
+ $start = 0;
+
+ while (($end = strpos($chunk, "\n", $start)) !== false) {
+ $line = $buffer . substr($chunk, $start, $end - $start);
+ $buffer = '';
+
+ yield str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
+
+ // Release the untrimmed CRLF line before accumulating the next record.
+ unset($line);
+ $start = $end + 1;
+ }
+
+ $buffer .= substr($chunk, $start);
+ }
+
+ if ($buffer !== '') {
+ yield $buffer;
+ }
+ }
+
+ /**
+ * Decode non-empty JSON lines from the current response body position.
+ *
+ * @param null|int-mask $flags
+ * @return Generator
+ */
+ public function jsonLines(?int $flags = null): Generator
+ {
+ $flags ??= self::$defaultJsonDecodingFlags;
+
+ foreach ($this->lines() as $line) {
+ if (trim($line, " \t\r\n") !== '') {
+ yield Json::decode($line, flags: $flags);
+ }
+ }
+ }
+
/**
* Get the JSON decoded body of the response as an array or scalar value.
*
diff --git a/tests/Http/Fixtures/streaming-handler.php b/tests/Http/Fixtures/streaming-handler.php
new file mode 100644
index 000000000..dd4fa20c6
--- /dev/null
+++ b/tests/Http/Fixtures/streaming-handler.php
@@ -0,0 +1,40 @@
+withOptions(['stream' => true]);
+
+switch ($argv[1]) {
+ case 'handler':
+ $request->setHandler(new MockHandler([new Response(body: 'custom handler')]));
+ break;
+ case 'client':
+ $request->setClient(new Client(['handler' => HandlerStack::create(new MockHandler([new Response(body: 'custom client')]))]));
+ break;
+ case 'stack':
+ $stack = $request->pushHandlers(HandlerStack::create(new MockHandler([new Response(body: 'custom stack')])));
+ $request->setClient(new Client(['handler' => $stack]));
+ break;
+ case 'fake':
+ $request = $factory->fake(['*' => Factory::response('fake stream')])->withOptions(['stream' => true]);
+ break;
+ case 'buffered':
+ $request = $factory->fake(['*' => Factory::response('buffered')])->withOptions(['stream' => false]);
+ break;
+}
+
+try {
+ echo $request->get('http://example.test')->body();
+} catch (RuntimeException $exception) {
+ fwrite(STDERR, $exception->getMessage());
+ exit(1);
+}
diff --git a/tests/Http/Fixtures/streaming-server.php b/tests/Http/Fixtures/streaming-server.php
new file mode 100644
index 000000000..0a9b77333
--- /dev/null
+++ b/tests/Http/Fixtures/streaming-server.php
@@ -0,0 +1,45 @@
+assertSame($expected, iterator_to_array($response->lines()));
+ $this->assertTrue($stream->eof());
+ }
+
+ /**
+ * Provide line endings and bodies split across stream reads.
+ */
+ public static function lineBodies(): array
+ {
+ return [
+ 'empty body' => ['', []],
+ 'blank lines' => ["\n\n", ['', '']],
+ 'mixed endings' => ["a\r\nb\n\r\nlast", ['a', 'b', '', 'last']],
+ 'carriage return is content without LF' => ["a\rb\r", ["a\rb\r"]],
+ 'only one CR belongs to CRLF' => ["a\r\r\n", ["a\r"]],
+ 'long partial line' => [str_repeat('x', 20000) . "\nend", [str_repeat('x', 20000), 'end']],
+ ];
+ }
+
+ public function testLinesAreLazyAndReadFromTheCurrentPosition(): void
+ {
+ $stream = new HttpResponseChunkedReadStream(Utils::streamFor("skip\nfirst\nsecond\n"), 2);
+ $stream->seek(5);
+ $response = new Response(new PsrResponse(body: $stream));
+ $lines = $response->lines();
+
+ $this->assertSame(0, $stream->reads);
+ $this->assertSame('first', $lines->current());
+ $this->assertSame(3, $stream->reads);
+ $lines->next();
+ $this->assertSame('second', $lines->current());
+ $lines->next();
+ $this->assertFalse($lines->valid());
+ $this->assertSame([], iterator_to_array($response->lines()));
+ }
+
+ public function testBodyConsumptionLeavesLinesAtEofUntilExplicitRewind(): void
+ {
+ $response = new Response(new PsrResponse(body: "one\ntwo\n"));
+
+ $this->assertSame("one\ntwo\n", $response->body());
+ $this->assertSame([], iterator_to_array($response->lines()));
+
+ $response->toPsrResponse()->getBody()->rewind();
+
+ $this->assertSame(['one', 'two'], iterator_to_array($response->lines()));
+ }
+
+ public function testJsonLinesDecodeObjectsAndScalarsAndSkipWhitespace(): void
+ {
+ $response = new Response(new PsrResponse(body: " \t\r\n{\"id\":1}\n\n[2]\nnull\nfalse\n0\n\"text\""));
+
+ $this->assertSame([['id' => 1], [2], null, false, 0, 'text'], iterator_to_array($response->jsonLines()));
+ }
+
+ public function testJsonLinesFailWhenTheMalformedRecordIsConsumed(): void
+ {
+ $response = new Response(new PsrResponse(body: "{\"id\":1}\ninvalid\n{\"id\":2}\n"));
+ $lines = $response->jsonLines();
+
+ $this->assertSame(['id' => 1], $lines->current());
+ $this->expectException(JsonException::class);
+
+ $lines->next();
+ }
+
+ public function testJsonLinesUseDefaultFlagsUnlessExplicitlyOverridden(): void
+ {
+ Response::$defaultJsonDecodingFlags = JSON_BIGINT_AS_STRING;
+ $body = "{\"id\":9223372036854775808}\n";
+
+ $this->assertSame([['id' => '9223372036854775808']], iterator_to_array((new Response(new PsrResponse(body: $body)))->jsonLines()));
+ $this->assertSame([['id' => 9223372036854775808.0]], iterator_to_array((new Response(new PsrResponse(body: $body)))->jsonLines(flags: 0)));
+
+ Response::$defaultJsonDecodingFlags = 0;
+
+ $this->assertSame([['id' => '9223372036854775808']], iterator_to_array((new Response(new PsrResponse(body: $body)))->jsonLines(flags: JSON_BIGINT_AS_STRING)));
+ }
+
+ public function testNullBytesAreNotSkippedAsWhitespace(): void
+ {
+ $response = new Response(new PsrResponse(body: "\0\n"));
+
+ $this->expectException(JsonException::class);
+
+ iterator_to_array($response->jsonLines());
+ }
+}
+
+class HttpResponseChunkedReadStream implements StreamInterface
+{
+ use StreamDecoratorTrait;
+
+ public int $reads = 0;
+
+ /**
+ * Create a stream that splits reads into small chunks.
+ */
+ public function __construct(private StreamInterface $stream, private int $chunkSize)
+ {
+ }
+
+ /**
+ * Read at most one configured chunk.
+ */
+ public function read(int $length): string
+ {
+ ++$this->reads;
+
+ return $this->stream->read(min($length, $this->chunkSize));
+ }
+}
diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php
new file mode 100644
index 000000000..b1e40d9d0
--- /dev/null
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -0,0 +1,210 @@
+setTimeout(5);
+ $process->run();
+
+ $this->assertSame($exitCode, $process->getExitCode(), $process->getErrorOutput());
+ $this->assertSame($output, $exitCode === 0 ? $process->getOutput() : $process->getErrorOutput());
+ }
+
+ /**
+ * Provide default and caller-supplied transport configurations.
+ */
+ public static function handlerModes(): array
+ {
+ return [
+ 'default handler unavailable' => ['default', 1, 'Streaming responses require allow_url_fopen when using the default HTTP handler.'],
+ 'caller owns the handler' => ['handler', 0, 'custom handler'],
+ 'caller owns the client' => ['client', 0, 'custom client'],
+ 'caller owns the stack' => ['stack', 0, 'custom stack'],
+ 'fake streaming response' => ['fake', 0, 'fake stream'],
+ 'buffered requests remain supported' => ['buffered', 0, 'buffered'],
+ ];
+ }
+
+ public function testStreamingReadsAllowOtherCoroutinesToProgress(): void
+ {
+ $this->withStreamingServer('delayed', function (string $address): void {
+ $progress = false;
+ $results = parallel([
+ 'reader' => function () use ($address, &$progress): array {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])->get('http://' . $address);
+ try {
+ $items = iterator_to_array($response->jsonLines());
+
+ return [$items, $progress];
+ } finally {
+ $response->close();
+ }
+ },
+ 'release' => function () use ($address, &$progress): void {
+ usleep(10000);
+ $progress = true;
+ $this->releaseServer($address);
+ },
+ ]);
+
+ $this->assertSame([[['id' => 2]], true], $results['reader']);
+ });
+ }
+
+ public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
+ {
+ // https://github.com/swoole/swoole-src/pull/6235
+ $this->requireSwooleStreamFixes('Hooked reads wait for more data after PHP has already supplied buffered bytes.');
+
+ $this->withStreamingServer('buffered', function (string $address): void {
+ $received = new Channel(1);
+ try {
+ $results = parallel([
+ 'reader' => function () use ($address, $received): array {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])->get('http://' . $address);
+ try {
+ $lines = $response->jsonLines();
+ $first = $lines->current();
+ $received->push($first);
+ $lines->next();
+ $last = $lines->current();
+ $lines->next();
+
+ return [$first, $last, $lines->valid()];
+ } finally {
+ $response->close();
+ }
+ },
+ 'release' => function () use ($address, $received): mixed {
+ $first = $received->pop(1);
+ $this->releaseServer($address);
+
+ return $first;
+ },
+ ]);
+
+ $this->assertSame(['id' => 1], $results['release'], 'The first record was withheld until the server was released.');
+ $this->assertSame([['id' => 1], ['id' => 2], false], $results['reader']);
+ } finally {
+ $received->close();
+ }
+ });
+ }
+
+ public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(): void
+ {
+ // https://github.com/swoole/swoole-src/pull/6236
+ $this->requireSwooleStreamFixes('Hooked read timeouts return an empty string instead of a failed read.');
+
+ $this->withStreamingServer('delayed', function (string $address): void {
+ $finished = new Channel(1);
+ try {
+ $results = parallel([
+ 'reader' => function () use ($address, $finished): ?RuntimeException {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 1])->get('http://' . $address);
+ try {
+ $response->lines()->current();
+
+ return null;
+ } catch (RuntimeException $exception) {
+ return $exception;
+ } finally {
+ $finished->push(true);
+ $response->close();
+ }
+ },
+ 'release' => function () use ($address, $finished): void {
+ $finished->pop(3);
+ $this->releaseServer($address);
+ },
+ ]);
+
+ $this->assertInstanceOf(RuntimeException::class, $results['reader']);
+ $this->assertSame('Unable to read from stream', $results['reader']->getMessage());
+ } finally {
+ $finished->close();
+ }
+ });
+ }
+
+ /**
+ * Run the upstream regressions on newer or explicitly patched Swoole builds.
+ */
+ protected function requireSwooleStreamFixes(string $reason): void
+ {
+ // Swoole 6.2.2 and earlier have these socket_read() defects.
+ // https://github.com/swoole/swoole-src/blob/v6.2.2/ext-src/swoole_runtime.cc#L530-L568
+ if (version_compare(swoole_version(), '6.2.2', '<=') && getenv('HYPERVEL_TEST_SWOOLE_STREAM_FIXES') !== '1') {
+ $this->markTestSkipped($reason . ' Affects Swoole <= 6.2.2; set HYPERVEL_TEST_SWOOLE_STREAM_FIXES=1 to test a patched build.');
+ }
+ }
+
+ /**
+ * Run a hooked client against an independently controlled loopback server.
+ */
+ protected function withStreamingServer(string $mode, Closure $callback): void
+ {
+ $process = new Process([PHP_BINARY, __DIR__ . '/Fixtures/streaming-server.php', $mode]);
+ $process->setTimeout(10);
+ $process->start();
+
+ try {
+ $ready = $process->waitUntil(fn () => str_contains($process->getOutput(), "\n"));
+ $this->assertTrue($ready, $process->getErrorOutput());
+ $address = trim($process->getOutput());
+
+ $failure = null;
+ run(function () use ($callback, $address, &$failure): void {
+ try {
+ $callback($address);
+ } catch (Throwable $exception) {
+ $failure = $exception;
+ }
+ }, SWOOLE_HOOK_ALL);
+
+ if ($failure !== null) {
+ throw $failure;
+ }
+
+ $this->assertSame(0, $process->wait(), $process->getErrorOutput());
+ } finally {
+ $process->stop(0);
+ }
+ }
+
+ /**
+ * Allow the server to send its final record.
+ */
+ protected function releaseServer(string $address): void
+ {
+ $connection = stream_socket_client('tcp://' . $address, $error, $message, 2);
+ if ($connection === false) {
+ throw new RuntimeException($message, $error);
+ }
+
+ fclose($connection);
+ }
+}
From d7dcb529a379395c53d9c89b00a3fce980be34b6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:59:04 +0000
Subject: [PATCH 3/8] Improve Saloon resources, authentication, and pagination
Add a generic BaseResource that preserves concrete connector types, a CookieAuthenticator with inferred or explicit domains and sensitive credentials, and raw query-string replacement on requests and pending requests. Preserve repeated encoded names while retaining explicit parameter and authentication overrides across finalization and retries.
Carry item types through paginator contracts, iterators, and collections, and make the standard query parameter names configurable. Map each final response once after middleware completes, retain only the current sequential page, and release pooled first-page items before metadata and callbacks. Keep totals accurate across mapping and callback failures, and propagate owner cancellation without scheduling remaining pages.
Add LinkHeaderPaginator for sequential next-link traversal and independently addressable numbered pooling. Resolve relative links, preserve continuation queries, reject conflicting or malformed pagination targets, and validate last-page numbers only when pooling requires them.
Make Saloon body buffering leave the replacement stream at EOF, consistent with seekable bodies and the inherited line readers. Update the public documentation, behavioral regressions, and maximum-level type fixtures for these APIs.
---
src/docs/saloon.md | 89 ++-
.../src/Http/Auth/CookieAuthenticator.php | 38 ++
src/saloon/src/Http/BaseResource.php | 18 +
src/saloon/src/Http/PendingRequest.php | 31 +-
src/saloon/src/Http/Response.php | 4 +-
.../Pagination/Contracts/HasPagination.php | 4 +
.../Contracts/HasRequestPagination.php | 4 +
.../Contracts/MapPaginatedResponseItems.php | 4 +-
src/saloon/src/Pagination/CursorPaginator.php | 27 +-
.../src/Pagination/LinkHeaderPaginator.php | 242 +++++++
src/saloon/src/Pagination/OffsetPaginator.php | 21 +-
src/saloon/src/Pagination/PagedPaginator.php | 21 +-
src/saloon/src/Pagination/Paginator.php | 78 ++-
.../src/Traits/RequestProperties/HasQuery.php | 31 +
tests/Saloon/Http/AuthenticationTest.php | 127 ++++
tests/Saloon/Http/PendingRequestTest.php | 77 +++
tests/Saloon/Http/RequestTest.php | 25 +
tests/Saloon/Http/ResponseTest.php | 23 +
tests/Saloon/Pagination/PaginatorTest.php | 590 ++++++++++++++++++
tests/Saloon/SensitiveParameterTest.php | 6 +-
types/Saloon/Pagination.php | 169 +++++
types/Saloon/Saloon.php | 30 +
22 files changed, 1604 insertions(+), 55 deletions(-)
create mode 100644 src/saloon/src/Http/Auth/CookieAuthenticator.php
create mode 100644 src/saloon/src/Http/BaseResource.php
create mode 100644 src/saloon/src/Pagination/LinkHeaderPaginator.php
create mode 100644 tests/Saloon/Http/AuthenticationTest.php
create mode 100644 types/Saloon/Pagination.php
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 2f31417de..923da2c28 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -54,6 +54,7 @@
- [API Pagination](#api-pagination)
- [Page Pagination](#page-pagination)
- [Offset and Cursor Pagination](#offset-and-cursor-pagination)
+ - [Link Header Pagination](#link-header-pagination)
- [Pooled Pagination](#pooled-pagination)
- [Rate Limiting](#rate-limiting)
- [Defining Policies](#defining-policies)
@@ -245,18 +246,15 @@ public function boot(PendingRequest $pendingRequest): void
### Organizing SDKs
-When an integration contains many endpoints, you may group related requests behind resource classes. Type the concrete connector in each resource so its integration-specific methods and response types remain visible:
+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 Hypervel\Saloon\Http\BaseResource;
use Hypervel\Saloon\Http\Response;
-class RepositoryResource
+/** @extends BaseResource */
+class RepositoryResource extends BaseResource
{
- public function __construct(
- private readonly GitHubConnector $connector,
- ) {
- }
-
public function get(string $owner, string $repository): Response
{
return $this->connector->send(new GetRepository($owner, $repository));
@@ -279,7 +277,7 @@ You may then call the resource from your application:
$response = $github->repositories()->get('hypervel', 'components');
```
-Unlike Saloon's `BaseResource`, a normal resource class keeps the concrete connector type instead of narrowing it to the abstract connector.
+Your resource may access the connector through its protected, readonly `$connector` property. The annotation allows your editor and static analysis tools to recognize the connector's methods and response types.
### HTTP Connections
@@ -482,6 +480,16 @@ $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.
+Use `withQueryString` when an API supplies an already-encoded query, including repeated parameter names:
+
+```php
+$request->withQueryString('tag=php&tag=hypervel&cursor=a%2Fb');
+```
+
+The query should not include a leading `?`. It replaces the query in the base URL and request endpoint. Passing an empty string clears that query. Parameters defined on the connector or added using `withQueryParameters`, including authentication parameters, are still applied and take precedence when names match.
+
+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.
+
### Authentication
@@ -520,7 +528,17 @@ Apply a custom authenticator using `authenticate`, or return it from a connector
$request->authenticate(new ApiKeyAuthenticator($key));
```
-Saloon also includes header, query, token, basic, digest, NTLM, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`.
+Saloon also includes header, query, cookie, token, basic, digest, NTLM, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`.
+
+For APIs that authenticate using a cookie, use `CookieAuthenticator`:
+
+```php
+use Hypervel\Saloon\Http\Auth\CookieAuthenticator;
+
+$request->authenticate(new CookieAuthenticator('session', $token));
+```
+
+By default, the cookie is sent to the request's host. You may pass a domain such as `.example.com` as the third argument; an empty domain is not allowed. If you replace the authenticator using the same cookie name and domain, the new value is used when sending the request.
Add the `RequiresAuth` trait to a request that must never be sent without an authenticator:
@@ -912,6 +930,10 @@ $response->dataUrl();
The `dataUrl` method returns the response body as a base64 data URL using its `Content-Type` header.
+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.
+
+These methods continue from the body's current position. If you have already called `body`, call `$response->stream()->rewind()` before reading its lines.
+
Saloon also provides access to the integration objects and final request:
```php
@@ -1483,6 +1505,7 @@ class ListUsers extends Request implements Paginatable
// Define the request method and endpoint...
}
+/** @extends PagedPaginator> */
class GitHubPaginator extends PagedPaginator
{
protected function isLastPage(Response $response): bool
@@ -1532,26 +1555,21 @@ $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.
-The `collect(false)` method returns a lazy collection of page responses instead of items. You may also inspect `totalResults`, `request`, and the zero-based iterator position returned by `currentPage`. Use `startPage` to configure the first remote page number.
+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.
Calling `count($paginator)` counts remote pages by requesting each page. It is not a metadata-only operation.
-`PagedPaginator`, `OffsetPaginator`, and `CursorPaginator` use the conventional `page`, `per_page`, `limit`, `offset`, and `cursor` query names. Override `applyPagination` when an API uses different parameters:
+Override the protected query-name properties when an API uses different names:
```php
-protected function applyPagination(Request $request): Request
-{
- $parameters = ['currentPage' => $this->pageNumber];
-
- if ($this->perPageLimit !== null) {
- $parameters['pageSize'] = $this->perPageLimit;
- }
+protected string $pageName = 'currentPage';
- return $request->withQueryParameters($parameters);
-}
+protected string $perPageName = 'pageSize';
```
-If a request implements `MapPaginatedResponseItems`, its `mapPaginatedResponseItems` method takes precedence over the paginator's item mapping.
+`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.
+
+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.
### Offset and Cursor Pagination
@@ -1560,6 +1578,32 @@ Extend `OffsetPaginator` for APIs that use `limit` and `offset`. A per-page limi
Cursor pagination is always sequential because a later request depends on the previous response. Rewinding a paginator clears its iterator state and begins again at the configured start page.
+
+### Link Header Pagination
+
+Extend `LinkHeaderPaginator` for APIs that return pagination links in the HTTP `Link` header:
+
+```php
+use Hypervel\Saloon\Http\Request;
+use Hypervel\Saloon\Http\Response;
+use Hypervel\Saloon\Pagination\LinkHeaderPaginator;
+
+/** @extends LinkHeaderPaginator> */
+class RepositoryPaginator extends LinkHeaderPaginator
+{
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json();
+ }
+}
+```
+
+The first request uses your configured page and per-page parameters. For each later request, the paginator follows the `next` link and uses its query string, including any page size or cursor supplied by the API. Repeated parameter names are preserved. Parameters configured separately on the request or connector, including authentication, still take precedence.
+
+Pagination links must use the same scheme, host, port, and path as the current request.
+
+Iteration ends when the response has no `next` link. If the API also supplies a `last` link containing a page number, you may use `pool` to request the remaining pages concurrently. Pooled requests use your configured page names and `perPageLimit`. Cursor-only links must be followed sequentially. Malformed links throw a `PaginationException`; invalid or contradictory last-page numbers are rejected when pooling.
+
### Pooled Pagination
@@ -1576,6 +1620,8 @@ $responses = $paginator->pool(
The first page is sent before the remaining range is scheduled. Response keys and callback positions use the paginator's zero-based iterator position. `maxPages` is honored, and pool failures retain the first response and all other completed work.
+A first-page mapping error propagates before scheduling any remaining requests. For later pages, mapping errors appear in `PoolException::callbackFailures`; those pages are not counted and their response handlers are not called. A response-handler error also appears there, but its successfully mapped page remains counted.
+
## Rate Limiting
@@ -2020,7 +2066,6 @@ 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.
-- Saloon's `BaseResource` is not included. Use a normal resource class typed to the concrete connector so integration-specific methods and DTO types remain available.
- The optional `xmlReader` response extension is not included. Use the built-in `xml` or `dom` methods instead.
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/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php
new file mode 100644
index 000000000..5be8bf8c7
--- /dev/null
+++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php
@@ -0,0 +1,38 @@
+withCookies(
+ [$this->name => $this->value],
+ $this->domain ?? $pendingRequest->uri()->getHost(),
+ );
+ }
+}
diff --git a/src/saloon/src/Http/BaseResource.php b/src/saloon/src/Http/BaseResource.php
new file mode 100644
index 000000000..4a3ca96f2
--- /dev/null
+++ b/src/saloon/src/Http/BaseResource.php
@@ -0,0 +1,18 @@
+ */
+class BaseResource
+{
+ /**
+ * Create a resource for the given connector.
+ *
+ * @param TConnector $connector
+ */
+ public function __construct(protected readonly Connector $connector)
+ {
+ }
+}
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 079283b0a..923b88843 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -47,6 +47,7 @@ class PendingRequest
use HasDebugging;
use HasRequestProperties {
withQueryParameters as protected addQueryParameters;
+ withQueryString as protected replaceQueryString;
}
use Macroable;
@@ -120,6 +121,7 @@ public function __construct(
$connector->queryParameters(),
$request->queryParameters(),
));
+ $this->queryString = $request->queryString();
$this->optionRepository = new ArrayRepository(array_replace_recursive(
$connector->options(),
$request->options(),
@@ -178,12 +180,19 @@ public function method(): Method
*/
public function uri(): UriInterface
{
- return $this->uri ?? UrlResolver::withQuery(
- UrlResolver::resolve(
- $this->connector->resolveBaseUrl(),
- $this->request->resolveEndpoint(),
- $this->request->allowsBaseUrlOverride() ?? $this->connector->allowsBaseUrlOverride(),
- ),
+ if ($this->uri !== null) {
+ return $this->uri;
+ }
+
+ $uri = UrlResolver::resolve(
+ $this->connector->resolveBaseUrl(),
+ $this->request->resolveEndpoint(),
+ $this->request->allowsBaseUrlOverride() ?? $this->connector->allowsBaseUrlOverride(),
+ );
+ $query = $this->queryString();
+
+ return UrlResolver::withQuery(
+ $query === null ? $uri : $uri->withQuery($query),
$this->queryParameters(),
);
}
@@ -212,6 +221,16 @@ public function withQueryParameters(array $parameters): static
return $this;
}
+ /**
+ * Replace the raw query string and invalidate the finalized URI.
+ */
+ public function withQueryString(string $query): static
+ {
+ $this->uri = null;
+
+ return $this->replaceQueryString($query);
+ }
+
/**
* Authenticate the pending request immediately.
*
diff --git a/src/saloon/src/Http/Response.php b/src/saloon/src/Http/Response.php
index 64385374b..3dfec921c 100644
--- a/src/saloon/src/Http/Response.php
+++ b/src/saloon/src/Http/Response.php
@@ -84,7 +84,9 @@ public function body(): string
}
$body = $stream->getContents();
- $this->response = $this->response->withBody(Utils::streamFor($body));
+ $buffer = Utils::streamFor($body);
+ $buffer->seek(0, SEEK_END);
+ $this->response = $this->response->withBody($buffer);
$this->decoded = null;
$this->hasDecoded = false;
$this->decodingFlags = 0;
diff --git a/src/saloon/src/Pagination/Contracts/HasPagination.php b/src/saloon/src/Pagination/Contracts/HasPagination.php
index 8c52a779c..e86e711c6 100644
--- a/src/saloon/src/Pagination/Contracts/HasPagination.php
+++ b/src/saloon/src/Pagination/Contracts/HasPagination.php
@@ -7,10 +7,14 @@
use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Pagination\Paginator;
+/** @template TItem */
interface HasPagination
{
/**
* Paginate a request.
+ *
+ * @param Request $request
+ * @return Paginator
*/
public function paginate(Request $request): Paginator;
}
diff --git a/src/saloon/src/Pagination/Contracts/HasRequestPagination.php b/src/saloon/src/Pagination/Contracts/HasRequestPagination.php
index 5bf050b65..3a7a2ad30 100644
--- a/src/saloon/src/Pagination/Contracts/HasRequestPagination.php
+++ b/src/saloon/src/Pagination/Contracts/HasRequestPagination.php
@@ -7,10 +7,14 @@
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Pagination\Paginator;
+/** @template TItem */
interface HasRequestPagination
{
/**
* Paginate through a connector.
+ *
+ * @param Connector $connector
+ * @return Paginator
*/
public function paginate(Connector $connector): Paginator;
}
diff --git a/src/saloon/src/Pagination/Contracts/MapPaginatedResponseItems.php b/src/saloon/src/Pagination/Contracts/MapPaginatedResponseItems.php
index 789eb8f91..efad8284b 100644
--- a/src/saloon/src/Pagination/Contracts/MapPaginatedResponseItems.php
+++ b/src/saloon/src/Pagination/Contracts/MapPaginatedResponseItems.php
@@ -6,12 +6,14 @@
use Hypervel\Saloon\Http\Response;
+/** @template TItem */
interface MapPaginatedResponseItems
{
/**
* Map the items from a paginated response.
*
- * @return array
+ * @param Response $response
+ * @return array
*/
public function mapPaginatedResponseItems(Response $response): array;
}
diff --git a/src/saloon/src/Pagination/CursorPaginator.php b/src/saloon/src/Pagination/CursorPaginator.php
index 94aebeb97..b827a8191 100644
--- a/src/saloon/src/Pagination/CursorPaginator.php
+++ b/src/saloon/src/Pagination/CursorPaginator.php
@@ -9,19 +9,36 @@
use LogicException;
use Throwable;
+/**
+ * @template TItem
+ * @extends Paginator
+ */
abstract class CursorPaginator extends Paginator
{
+ /**
+ * The cursor query parameter.
+ */
+ protected string $cursorName = 'cursor';
+
+ /**
+ * The per-page limit query parameter.
+ */
+ protected string $perPageName = 'per_page';
+
/**
* Apply cursor pagination to the request.
+ *
+ * @param Request $request
+ * @return Request
*/
protected function applyPagination(Request $request): Request
{
if ($this->currentResponse instanceof Response) {
- $request->withQueryParameters(['cursor' => $this->getNextCursor($this->currentResponse)]);
+ $request->withQueryParameters([$this->cursorName => $this->getNextCursor($this->currentResponse)]);
}
if ($this->perPageLimit !== null) {
- $request->withQueryParameters(['per_page' => $this->perPageLimit]);
+ $request->withQueryParameters([$this->perPageName => $this->perPageLimit]);
}
return $request;
@@ -29,15 +46,17 @@ protected function applyPagination(Request $request): Request
/**
* Get the next cursor.
+ *
+ * @param Response $response
*/
abstract protected function getNextCursor(Response $response): int|string;
/**
* Reject pooled cursor pagination because later cursors depend on earlier responses.
*
- * @param null|callable(Response, int): void $responseHandler
+ * @param null|callable(Response, int): void $responseHandler
* @param null|callable(Throwable, int): void $exceptionHandler
- * @return array
+ * @return array>
*/
public function pool(
int $concurrency = 5,
diff --git a/src/saloon/src/Pagination/LinkHeaderPaginator.php b/src/saloon/src/Pagination/LinkHeaderPaginator.php
new file mode 100644
index 000000000..ddd959509
--- /dev/null
+++ b/src/saloon/src/Pagination/LinkHeaderPaginator.php
@@ -0,0 +1,242 @@
+
+ */
+abstract class LinkHeaderPaginator extends PagedPaginator
+{
+ /**
+ * The next page's complete query string.
+ */
+ protected ?string $nextQuery = null;
+
+ /**
+ * The last independently addressable page for pooled requests.
+ */
+ protected ?int $lastPage = null;
+
+ /**
+ * Get the current response and resolve its pagination links.
+ *
+ * @return Response
+ */
+ public function current(): Response
+ {
+ $response = parent::current();
+ $uri = $response->toPsrRequest()->getUri();
+ $links = $this->parseLinks($response->toPsrResponse()->getHeader('Link'), $uri);
+ $next = $links['next'] ?? null;
+ $lastPage = $this->pooling && isset($links['last']) ? $this->pageFromUri($links['last']) : null;
+
+ if ($lastPage !== null) {
+ $currentPage = $this->pageFromUri($uri) ?? $this->pageNumber;
+ if (($next !== null && $lastPage <= $currentPage)
+ || ($next === null && $lastPage > $currentPage)) {
+ throw new PaginationException('The last Link page contradicts the current page or next relation.');
+ }
+ }
+
+ $this->nextQuery = $next?->getQuery();
+ $this->lastPage = $lastPage;
+
+ return $response;
+ }
+
+ /**
+ * Apply numbered pagination or the provider's complete continuation query.
+ *
+ * @param Request $request
+ * @return Request
+ */
+ protected function applyPagination(Request $request): Request
+ {
+ if ($this->currentResponse === null || $this->pooling) {
+ return parent::applyPagination($request);
+ }
+
+ return $request->withQueryString(
+ $this->nextQuery ?? throw new PaginationException('The response has no next Link.'),
+ );
+ }
+
+ /**
+ * Determine whether the response has no continuation link.
+ *
+ * @param Response $response
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return $this->nextQuery === null;
+ }
+
+ /**
+ * Resolve the last independently addressable page.
+ *
+ * @param Response $response
+ */
+ protected function getTotalPages(Response $response): int
+ {
+ return $this->nextQuery === null
+ ? $this->startPage
+ : ($this->lastPage ?? throw new PaginationException('Pooled Link pagination requires a numbered last Link.'));
+ }
+
+ /**
+ * Clear continuation state when iteration restarts.
+ */
+ protected function onRewind(): void
+ {
+ $this->nextQuery = null;
+ $this->lastPage = null;
+ }
+
+ /**
+ * Read an optional page number without flattening repeated names.
+ */
+ protected function pageFromUri(UriInterface $uri): ?int
+ {
+ $query = Query::parse($uri->getQuery());
+
+ if (! array_key_exists($this->pageName, $query)) {
+ return null;
+ }
+
+ $value = $query[$this->pageName];
+ $page = is_string($value) ? filter_var($value, FILTER_VALIDATE_INT) : false;
+
+ if ($page === false) {
+ throw new PaginationException("The Link [{$this->pageName}] parameter must be an integer.");
+ }
+
+ return $page;
+ }
+
+ /**
+ * Parse pagination relations without splitting quoted values or URI commas.
+ *
+ * @param list $headers
+ * @return array
+ */
+ protected function parseLinks(array $headers, UriInterface $currentUri): array
+ {
+ $links = [];
+
+ foreach ($headers as $header) {
+ $position = 0;
+ $length = strlen($header);
+
+ while ($position < $length) {
+ $position += strspn($header, " \t,", $position);
+
+ if ($position === $length) {
+ break;
+ }
+
+ if ($header[$position] !== '<' || ($end = strpos($header, '>', $position + 1)) === false) {
+ throw new PaginationException('A Link target must be enclosed in angle brackets.');
+ }
+
+ $target = substr($header, $position + 1, $end - $position - 1);
+ $position = $end + 1;
+ $relations = null;
+ $anchored = false;
+
+ while ($position < $length) {
+ $position += strspn($header, " \t", $position);
+
+ if (($header[$position] ?? null) !== ';') {
+ break;
+ }
+
+ ++$position;
+ $position += strspn($header, " \t", $position);
+ $size = strcspn($header, " \t=;,", $position);
+ $name = strtolower(substr($header, $position, $size));
+ $position += $size;
+ $position += strspn($header, " \t", $position);
+ $value = '';
+
+ if (($header[$position] ?? null) === '=') {
+ ++$position;
+ $position += strspn($header, " \t", $position);
+
+ if (($header[$position] ?? null) === '"') {
+ ++$position;
+
+ while ($position < $length && $header[$position] !== '"') {
+ if ($header[$position] === '\\') {
+ ++$position;
+ }
+
+ if ($position < $length) {
+ $value .= $header[$position++];
+ }
+ }
+
+ if ($position === $length) {
+ throw new PaginationException('A quoted Link parameter is unterminated.');
+ }
+
+ ++$position;
+ } else {
+ $size = strcspn($header, " \t;,", $position);
+ $value = substr($header, $position, $size);
+ $position += $size;
+ }
+ }
+
+ if ($name === 'rel' && $relations === null) {
+ $relations = $value;
+ } elseif ($name === 'anchor') {
+ $anchored = true;
+ }
+ }
+
+ if ($position < $length && $header[$position] !== ',') {
+ throw new PaginationException('Link values must be separated by commas.');
+ }
+
+ // RFC 8288 section 3.2 permits ignoring an anchored link, not its context alone.
+ if ($anchored || $relations === null || trim($relations) === '') {
+ continue;
+ }
+
+ foreach (preg_split('/[ \t]+/', strtolower(trim($relations))) as $relation) {
+ if ($relation !== 'next' && $relation !== 'last') {
+ continue;
+ }
+
+ $uri = UriResolver::resolve($currentUri, new Uri($target));
+
+ if ($uri->getScheme() !== $currentUri->getScheme()
+ || $uri->getHost() !== $currentUri->getHost()
+ || $uri->getPort() !== $currentUri->getPort()
+ || $uri->getPath() !== $currentUri->getPath()) {
+ throw new PaginationException('Pagination Links must target the same scheme, host, port, and path as the request.');
+ }
+
+ if (isset($links[$relation]) && (string) $links[$relation] !== (string) $uri) {
+ throw new PaginationException("Conflicting [{$relation}] pagination Links were returned.");
+ }
+
+ $links[$relation] = $uri;
+ }
+ }
+ }
+
+ return $links;
+ }
+}
diff --git a/src/saloon/src/Pagination/OffsetPaginator.php b/src/saloon/src/Pagination/OffsetPaginator.php
index 0cb2564f7..212ff767b 100644
--- a/src/saloon/src/Pagination/OffsetPaginator.php
+++ b/src/saloon/src/Pagination/OffsetPaginator.php
@@ -7,10 +7,27 @@
use Hypervel\Saloon\Http\Request;
use LogicException;
+/**
+ * @template TItem
+ * @extends Paginator
+ */
abstract class OffsetPaginator extends Paginator
{
+ /**
+ * The result-limit query parameter.
+ */
+ protected string $limitName = 'limit';
+
+ /**
+ * The result-offset query parameter.
+ */
+ protected string $offsetName = 'offset';
+
/**
* Apply offset pagination to the request.
+ *
+ * @param Request $request
+ * @return Request
*/
protected function applyPagination(Request $request): Request
{
@@ -19,8 +36,8 @@ protected function applyPagination(Request $request): Request
}
return $request->withQueryParameters([
- 'limit' => $this->perPageLimit,
- 'offset' => $this->getOffset(),
+ $this->limitName => $this->perPageLimit,
+ $this->offsetName => $this->getOffset(),
]);
}
diff --git a/src/saloon/src/Pagination/PagedPaginator.php b/src/saloon/src/Pagination/PagedPaginator.php
index b7138081f..46339acbc 100644
--- a/src/saloon/src/Pagination/PagedPaginator.php
+++ b/src/saloon/src/Pagination/PagedPaginator.php
@@ -6,17 +6,34 @@
use Hypervel\Saloon\Http\Request;
+/**
+ * @template TItem
+ * @extends Paginator
+ */
abstract class PagedPaginator extends Paginator
{
+ /**
+ * The page-number query parameter.
+ */
+ protected string $pageName = 'page';
+
+ /**
+ * The per-page limit query parameter.
+ */
+ protected string $perPageName = 'per_page';
+
/**
* Apply page-number pagination to the request.
+ *
+ * @param Request $request
+ * @return Request
*/
protected function applyPagination(Request $request): Request
{
- $request->withQueryParameters(['page' => $this->pageNumber]);
+ $request->withQueryParameters([$this->pageName => $this->pageNumber]);
if ($this->perPageLimit !== null) {
- $request->withQueryParameters(['per_page' => $this->perPageLimit]);
+ $request->withQueryParameters([$this->perPageName => $this->perPageLimit]);
}
return $request;
diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php
index 0362a0848..fde674078 100644
--- a/src/saloon/src/Pagination/Paginator.php
+++ b/src/saloon/src/Pagination/Paginator.php
@@ -16,8 +16,13 @@
use InvalidArgumentException;
use Iterator;
use LogicException;
+use Swoole\Coroutine\CanceledException;
use Throwable;
+/**
+ * @template TItem
+ * @implements Iterator>
+ */
abstract class Paginator implements Countable, Iterator
{
/**
@@ -47,9 +52,18 @@ abstract class Paginator implements Countable, Iterator
/**
* The current response.
+ *
+ * @var null|Response
*/
protected ?Response $currentResponse = null;
+ /**
+ * The items mapped from the final current response.
+ *
+ * @var array
+ */
+ protected array $currentPageItems = [];
+
/**
* The total number of mapped results processed.
*/
@@ -74,6 +88,9 @@ abstract class Paginator implements Countable, Iterator
/**
* Create a paginator.
+ *
+ * @param Connector $connector
+ * @param Request $request
*/
public function __construct(
protected Connector $connector,
@@ -89,9 +106,6 @@ public function __construct(
$this->request = clone $request;
$this->request->middleware()
->onResponse(static fn (Response $response): Response => $response->throw())
- ->onResponse(function (Response $response): void {
- $this->totalResults += count($this->pageItems($response));
- })
->onResponse(function (Response $response): void {
if (! $this->detectInfiniteLoop || $this->pooling) {
return;
@@ -115,12 +129,18 @@ public function __construct(
/**
* Get the response for the current page.
+ *
+ * @return Response
*/
public function current(): Response
{
$request = $this->applyPagination(clone $this->request);
- return $this->currentResponse = $this->connector->send($request);
+ $this->currentResponse = $this->connector->send($request);
+ $this->currentPageItems = $this->pageItems($this->currentResponse);
+ $this->totalResults += count($this->currentPageItems);
+
+ return $this->currentResponse;
}
/**
@@ -128,6 +148,7 @@ public function current(): Response
*/
public function next(): void
{
+ $this->currentPageItems = [];
++$this->pageNumber;
++$this->currentPage;
}
@@ -160,6 +181,7 @@ public function rewind(): void
$this->pageNumber = $this->startPage;
$this->currentPage = 0;
$this->currentResponse = null;
+ $this->currentPageItems = [];
$this->totalResults = 0;
$this->lastFiveBodyChecksums = [];
$this->onRewind();
@@ -175,12 +197,12 @@ protected function onRewind(): void
/**
* Iterate over every response item.
*
- * @return iterable
+ * @return iterable
*/
public function items(): iterable
{
foreach ($this as $response) {
- foreach ($this->pageItems($response) as $item) {
+ foreach ($this->currentPageItems as $item) {
yield $item;
}
}
@@ -188,6 +210,8 @@ public function items(): iterable
/**
* Create a lazy collection from page responses or response items.
+ *
+ * @return ($throughItems is true ? LazyCollection : LazyCollection>)
*/
public function collect(bool $throughItems = true): LazyCollection
{
@@ -199,9 +223,9 @@ public function collect(bool $throughItems = true): LazyCollection
/**
* Send every page through a bounded coroutine pool.
*
- * @param null|callable(Response, int): void $responseHandler
+ * @param null|callable(Response, int): void $responseHandler
* @param null|callable(Throwable, int): void $exceptionHandler
- * @return array
+ * @return array>
*/
public function pool(
int $concurrency = 5,
@@ -219,6 +243,7 @@ public function pool(
try {
$firstKey = $this->key();
$firstResponse = $this->current();
+ $this->currentPageItems = [];
$totalPages = $this->getTotalPages($firstResponse);
$lastPage = $this->maxPages === null
? $totalPages
@@ -228,6 +253,8 @@ public function pool(
if ($responseHandler !== null) {
try {
$responseHandler($firstResponse, $firstKey);
+ } catch (CanceledException $exception) {
+ throw $exception;
} catch (Throwable $exception) {
$initialCallbackFailure = $exception;
}
@@ -236,7 +263,13 @@ public function pool(
$remainingPool = $this->connector->pool(
$this->remainingRequests($lastPage),
$concurrency,
- $responseHandler,
+ function (Response $response, int $key) use ($responseHandler): void {
+ $this->totalResults += count($this->pageItems($response));
+
+ if ($responseHandler !== null) {
+ $responseHandler($response, $key);
+ }
+ },
$exceptionHandler,
);
@@ -302,6 +335,8 @@ public function perPageLimit(?int $perPageLimit): static
/**
* Get the cloned request used by this paginator.
+ *
+ * @return Request
*/
public function request(): Request
{
@@ -347,21 +382,25 @@ public function count(): int
/**
* Resolve response items using the request override when present.
*
- * @return array
+ * @param Response $response
+ * @return array
*/
protected function pageItems(Response $response): array
{
$request = $response->request();
- return $request instanceof MapPaginatedResponseItems
- ? $request->mapPaginatedResponseItems($response)
- : $this->getPageItems($response, $request);
+ if ($request instanceof MapPaginatedResponseItems) {
+ /** @var MapPaginatedResponseItems&Request $request */
+ return $request->mapPaginatedResponseItems($response);
+ }
+
+ return $this->getPageItems($response, $request);
}
/**
* Yield independently addressable page requests after the first page.
*
- * @return iterable
+ * @return iterable>
*/
protected function remainingRequests(int $lastPage): iterable
{
@@ -375,6 +414,8 @@ protected function remainingRequests(int $lastPage): iterable
/**
* Get the total number of independently addressable pages.
+ *
+ * @param Response $response
*/
protected function getTotalPages(Response $response): int
{
@@ -383,18 +424,25 @@ protected function getTotalPages(Response $response): int
/**
* Apply pagination to a cloned request.
+ *
+ * @param Request $request
+ * @return Request
*/
abstract protected function applyPagination(Request $request): Request;
/**
* Determine if the response is the last page.
+ *
+ * @param Response $response
*/
abstract protected function isLastPage(Response $response): bool;
/**
* Get the items from one page.
*
- * @return array
+ * @param Response $response
+ * @param Request $request
+ * @return array
*/
abstract protected function getPageItems(Response $response, Request $request): array;
}
diff --git a/src/saloon/src/Traits/RequestProperties/HasQuery.php b/src/saloon/src/Traits/RequestProperties/HasQuery.php
index 4e13a1954..09ede6c2b 100644
--- a/src/saloon/src/Traits/RequestProperties/HasQuery.php
+++ b/src/saloon/src/Traits/RequestProperties/HasQuery.php
@@ -13,6 +13,29 @@ trait HasQuery
*/
protected ?ArrayRepository $queryRepository = null;
+ /**
+ * The already-encoded query string override.
+ */
+ protected ?string $queryString = null;
+
+ /**
+ * Get the raw query string override, without a leading question mark.
+ */
+ public function queryString(): ?string
+ {
+ return $this->queryString ?? $this->defaultQueryString();
+ }
+
+ /**
+ * Replace the base URL and endpoint query string.
+ */
+ public function withQueryString(string $query): static
+ {
+ $this->queryString = $query;
+
+ return $this;
+ }
+
/**
* Get the request query parameters.
*
@@ -46,6 +69,14 @@ protected function defaultQuery(): array
return [];
}
+ /**
+ * Resolve the default raw query string override.
+ */
+ protected function defaultQueryString(): ?string
+ {
+ return null;
+ }
+
/**
* Get the request query repository.
*/
diff --git a/tests/Saloon/Http/AuthenticationTest.php b/tests/Saloon/Http/AuthenticationTest.php
new file mode 100644
index 000000000..715288f09
--- /dev/null
+++ b/tests/Saloon/Http/AuthenticationTest.php
@@ -0,0 +1,127 @@
+authenticate(new CookieAuthenticator('session', 'secret', $domain)),
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+
+ $pendingRequest->applyAuthentication();
+
+ $this->assertSame([
+ ['cookies' => ['session' => 'secret'], 'domain' => $expected],
+ ], $pendingRequest->cookies());
+ }
+
+ /**
+ * Provide inferred and explicitly scoped cookie domains.
+ */
+ public static function cookieDomains(): array
+ {
+ return [[null, 'api.example.com'], ['.example.com', '.example.com']];
+ }
+
+ public function testAnEmptyExplicitCookieDomainIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('The cookie domain cannot be empty.');
+
+ new CookieAuthenticator('session', 'secret', '');
+ }
+
+ 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);
+ $groups = [];
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$groups): void {
+ $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement'));
+ $groups[] = $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->assertSame($groups[0], $groups[1]);
+ $this->assertCount(2, $groups[0]);
+ $this->assertSame([], $request->cookies());
+ }
+}
+
+class CookieAuthConnectorStub extends Connector
+{
+ /**
+ * Resolve the API base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com';
+ }
+}
+
+class CookieAuthRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+}
diff --git a/tests/Saloon/Http/PendingRequestTest.php b/tests/Saloon/Http/PendingRequestTest.php
index 56c4f2ce8..f656a157f 100644
--- a/tests/Saloon/Http/PendingRequestTest.php
+++ b/tests/Saloon/Http/PendingRequestTest.php
@@ -12,6 +12,7 @@
use Hypervel\Saloon\Exceptions\PendingRequestException;
use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator;
use Hypervel\Saloon\Http\Auth\HeaderAuthenticator;
+use Hypervel\Saloon\Http\Auth\QueryAuthenticator;
use Hypervel\Saloon\Http\Auth\TokenAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\PendingRequest;
@@ -73,6 +74,50 @@ public function testUriAndBodyAreFinalizedAfterRequestMiddleware(): void
);
}
+ public function testRawQuerySnapshotsDefaultsAndReplacesBothUrlQueries(): void
+ {
+ $request = new PendingRawQueryRequestStub;
+ $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, $request);
+ $request->withQueryString('changed=after-snapshot');
+
+ $pendingRequest->finalizeUri();
+
+ $this->assertSame('tag=a&tag=b&cursor=a%2fb', $pendingRequest->queryString());
+ $this->assertSame('https://api.example.com/users?tag=a&tag=b&cursor=a%2fb', (string) $pendingRequest->uri());
+ $this->assertSame([], $pendingRequest->queryParameters());
+
+ $pendingRequest->withQueryString('')->finalizeUri();
+
+ $this->assertSame('https://api.example.com/users', (string) $pendingRequest->uri());
+ $this->assertSame('changed=after-snapshot', $request->queryString());
+ }
+
+ public function testRawQueryReplacementRetainsArrayAndAuthenticationOverlays(): void
+ {
+ $request = (new PendingRawQueryRequestStub)
+ ->withQueryString('tag=a&tag=b&token=old&token=older')
+ ->withQueryParameters(['limit' => 10]);
+ $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request);
+
+ $pendingRequest->authenticate(new QueryAuthenticator('token', 'secret'))->finalizeUri();
+
+ $this->assertSame('tag=a&tag=b&version=1&limit=10&token=secret', $pendingRequest->uri()->getQuery());
+
+ $pendingRequest->withQueryString('cursor=next&token=stale')->finalizeUri();
+ $pendingRequest->withQueryParameters(['tag' => 'replacement'])->finalizeUri();
+
+ $this->assertSame('cursor=next&version=1&limit=10&token=secret&tag=replacement', $pendingRequest->uri()->getQuery());
+ $this->assertSame(['version' => 1, 'limit' => 10, 'token' => 'secret', 'tag' => 'replacement'], $pendingRequest->queryParameters());
+ }
+
+ public function testNullRawQueryKeepsTheOriginalUrlQuery(): void
+ {
+ $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, new PendingRequestRequestStub);
+
+ $this->assertNull($pendingRequest->queryString());
+ $this->assertSame('base=old', $pendingRequest->uri()->getQuery());
+ }
+
public function testConnectorAndRequestBodyTypesMustMatch(): void
{
$this->expectException(PendingRequestException::class);
@@ -193,6 +238,38 @@ protected function defaultBody(): array
}
}
+class PendingRawQueryConnectorStub extends Connector
+{
+ /**
+ * Resolve the integration base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://api.example.com?base=old';
+ }
+}
+
+class PendingRawQueryRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users?endpoint=old';
+ }
+
+ /**
+ * Resolve the default raw query string override.
+ */
+ protected function defaultQueryString(): ?string
+ {
+ return 'tag=a&tag=b&cursor=a%2fb';
+ }
+}
+
class PendingRequestRequestStub extends Request
{
use HasJsonBody;
diff --git a/tests/Saloon/Http/RequestTest.php b/tests/Saloon/Http/RequestTest.php
index 7810626da..a309878c3 100644
--- a/tests/Saloon/Http/RequestTest.php
+++ b/tests/Saloon/Http/RequestTest.php
@@ -139,6 +139,20 @@ public function testCloneOwnsIndependentInitializedRequestState(): void
$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
@@ -153,6 +167,17 @@ public function resolveEndpoint(): string
}
}
+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;
diff --git a/tests/Saloon/Http/ResponseTest.php b/tests/Saloon/Http/ResponseTest.php
index 4e550ded6..afca8dbca 100644
--- a/tests/Saloon/Http/ResponseTest.php
+++ b/tests/Saloon/Http/ResponseTest.php
@@ -144,6 +144,29 @@ public function testNonSeekableBodyIsBufferedOnce(): void
$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');
diff --git a/tests/Saloon/Pagination/PaginatorTest.php b/tests/Saloon/Pagination/PaginatorTest.php
index 2189653b2..110084470 100644
--- a/tests/Saloon/Pagination/PaginatorTest.php
+++ b/tests/Saloon/Pagination/PaginatorTest.php
@@ -4,12 +4,16 @@
namespace Hypervel\Tests\Saloon\Pagination;
+use Closure;
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
+use Hypervel\Engine\Coroutine as EngineCoroutine;
use Hypervel\Events\Dispatcher;
use Hypervel\Http\Client\Factory;
use Hypervel\RateLimiter\RateLimiter;
use Hypervel\Saloon\Enums\Method;
+use Hypervel\Saloon\Exceptions\PoolException;
+use Hypervel\Saloon\Http\Auth\QueryAuthenticator;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Faking\MockClient;
use Hypervel\Saloon\Http\Faking\MockResponse;
@@ -21,6 +25,7 @@
use Hypervel\Saloon\Pagination\Contracts\Paginatable;
use Hypervel\Saloon\Pagination\CursorPaginator;
use Hypervel\Saloon\Pagination\Exceptions\PaginationException;
+use Hypervel\Saloon\Pagination\LinkHeaderPaginator;
use Hypervel\Saloon\Pagination\OffsetPaginator;
use Hypervel\Saloon\Pagination\PagedPaginator;
use Hypervel\Saloon\SaloonManager;
@@ -29,6 +34,11 @@
use LogicException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Swoole\Coroutine\Channel;
+use Throwable;
+use WeakReference;
class PaginatorTest extends TestCase
{
@@ -228,6 +238,500 @@ public function testRequestCanMapPaginatedItems(): void
);
$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
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make([
+ 'data' => ['original'], 'page' => 1, 'pages' => 1,
+ ])]);
+ $manager->middleware()->onResponse(static function (Response $response) use ($replace): Response {
+ if ($replace) {
+ $response = Response::fromResponse($response, $response->pendingRequest(), $response->toPsrRequest());
+ }
+
+ return $response->decodeUsing(static fn (): array => [
+ 'data' => ['first', 'second'], 'page' => 1, 'pages' => 1,
+ ]);
+ });
+ $paginator = new MappingPagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->assertSame(['first', 'second'], $paginator->collect()->all());
+ $this->assertSame([1], $paginator->mappedPages);
+ $this->assertSame(2, $paginator->totalResults());
+ }
+
+ /**
+ * Provide supported response middleware changes.
+ */
+ public static function responseChanges(): array
+ {
+ return ['in place' => [false], 'replacement' => [true]];
+ }
+
+ public function testMappedItemsAreReleasedOnAdvanceAndRewind(): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make([
+ 'data' => [1], 'page' => 1, 'pages' => 1,
+ ])]);
+ $paginator = new MappingPagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $references = [];
+ $paginator->mapItems = static function () use (&$references): array {
+ $item = (object) ['id' => 1];
+ $references[] = WeakReference::create($item);
+
+ return [$item];
+ };
+
+ $paginator->current();
+ $this->assertNotNull($references[0]->get());
+ $paginator->next();
+ $this->assertNull($references[0]->get());
+ $paginator->current();
+ $this->assertNotNull($references[1]->get());
+ $paginator->rewind();
+ $this->assertNull($references[1]->get());
+ $this->assertSame(0, $paginator->totalResults());
+ }
+
+ public function testPooledMappingReleasesItemsBeforePageCountsAndCallbacks(): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest): MockResponse {
+ $page = $pendingRequest->queryParameters()['page'];
+
+ return MockResponse::make(['data' => [$page], 'page' => $page, 'pages' => 3]);
+ }]);
+ $paginator = new MappingPagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $references = [];
+ $paginator->mapItems = static function (Response $response) use (&$references): array {
+ usleep(1000);
+ $item = (object) ['id' => $response->json('page')];
+ $references[] = WeakReference::create($item);
+
+ return [$item];
+ };
+ $paginator->resolvePages = function () use (&$references): int {
+ $this->assertNull($references[0]->get());
+
+ return 3;
+ };
+ $handled = [];
+
+ $paginator->pool(responseHandler: function (Response $response, int $key) use (&$references, &$handled): void {
+ foreach ($references as $reference) {
+ $this->assertNull($reference->get());
+ }
+ $handled[] = $key;
+ });
+
+ sort($handled);
+ $this->assertSame([0, 1, 2], $handled);
+ $this->assertSame([1, 2, 3], $paginator->mappedPages);
+ $this->assertSame(3, $paginator->totalResults());
+ }
+
+ public function testFirstPageMappingFailureStopsBeforeSchedulingThePool(): void
+ {
+ $manager = $this->manager();
+ $requested = 0;
+ $manager->fake([PagedRequestStub::class => static function () use (&$requested): MockResponse {
+ ++$requested;
+
+ return MockResponse::make(['data' => [1], 'page' => 1, 'pages' => 3]);
+ }]);
+ $paginator = new MappingPagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $failure = new RuntimeException('Cannot map the first page.');
+ $paginator->mapItems = static fn (): never => throw $failure;
+ $handled = [];
+ $caught = null;
+
+ try {
+ $paginator->pool(responseHandler: static function (Response $response, int $key) use (&$handled): void {
+ $handled[] = $key;
+ });
+ } catch (RuntimeException $exception) {
+ $caught = $exception;
+ }
+
+ $this->assertSame($failure, $caught);
+ $this->assertSame(1, $requested);
+ $this->assertSame([], $handled);
+ $this->assertSame(0, $paginator->totalResults());
+ }
+
+ public function testFirstPageHandlerCancellationStopsBeforeSchedulingRemainingPages(): void
+ {
+ $manager = $this->manager();
+ $requested = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$requested): MockResponse {
+ $page = $pendingRequest->queryParameters()['page'];
+ $requested[] = $page;
+
+ return MockResponse::make(['data' => [$page], 'page' => $page, 'pages' => 3]);
+ }]);
+ $paginator = new PagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $started = new Channel(1);
+ $blocker = new Channel(1);
+ $nativeCancellation = null;
+ $outcome = null;
+ $runner = EngineCoroutine::create(static function () use ($paginator, $started, $blocker, &$nativeCancellation, &$outcome): void {
+ try {
+ $paginator->pool(responseHandler: static function () use ($started, $blocker, &$nativeCancellation): void {
+ $started->push(true);
+
+ try {
+ $blocker->pop(5);
+ } catch (CanceledException $exception) {
+ $nativeCancellation = $exception;
+ throw $exception;
+ }
+ });
+ } catch (Throwable $exception) {
+ $outcome = $exception;
+ }
+ });
+
+ try {
+ $this->assertTrue($started->pop(5));
+ $this->assertTrue(EngineCoroutine::cancelById($runner->getId(), throwException: true));
+ EngineCoroutine::join([$runner->getId()], 5);
+ $this->assertFalse(EngineCoroutine::exists($runner->getId()));
+ $this->assertInstanceOf(CanceledException::class, $nativeCancellation);
+ $this->assertSame($nativeCancellation, $outcome);
+ $this->assertSame([1], $requested);
+ } finally {
+ if (EngineCoroutine::exists($runner->getId())) {
+ EngineCoroutine::cancelById($runner->getId(), throwException: true);
+ EngineCoroutine::join([$runner->getId()], 5);
+ }
+
+ $started->close();
+ $blocker->close();
+ }
+ }
+
+ #[DataProvider('poolCallbackFailures')]
+ public function testPoolCountsMappedPagesEvenWhenCallerHandlersFail(bool $mapperFails, int $failedKey): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest): MockResponse {
+ $page = $pendingRequest->queryParameters()['page'];
+
+ return MockResponse::make(['data' => [$page], 'page' => $page, 'pages' => 3]);
+ }]);
+ $paginator = new MappingPagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+ $failure = new RuntimeException('Page callback failed.');
+ $paginator->mapItems = static function (Response $response) use ($mapperFails, $failedKey, $failure): array {
+ if ($mapperFails && $response->json('page') === $failedKey + 1) {
+ throw $failure;
+ }
+
+ return $response->json('data');
+ };
+ $handled = [];
+ $caught = null;
+
+ try {
+ $paginator->pool(responseHandler: static function (Response $response, int $key) use (&$handled, $mapperFails, $failedKey, $failure): void {
+ $handled[] = $key;
+ if (! $mapperFails && $key === $failedKey) {
+ throw $failure;
+ }
+ });
+ } catch (PoolException $exception) {
+ $caught = $exception;
+ }
+
+ $this->assertInstanceOf(PoolException::class, $caught);
+ $this->assertSame([$failedKey => $failure], $caught->callbackFailures());
+ $this->assertSame([], $caught->failures());
+ $this->assertCount(3, $caught->responses());
+ $this->assertSame($mapperFails ? 2 : 3, $paginator->totalResults());
+ sort($handled);
+ $this->assertSame($mapperFails ? [0, 2] : [0, 1, 2], $handled);
+ $this->assertSame([1, 2, 3], $paginator->mappedPages);
+ }
+
+ /**
+ * Provide mapper and caller-handler failure positions.
+ */
+ public static function poolCallbackFailures(): array
+ {
+ return ['mapper' => [true, 1], 'first handler' => [false, 0], 'remaining handler' => [false, 1]];
+ }
+
+ #[DataProvider('renamedParameters')]
+ public function testPaginationQueryNamesCanBeConfigured(string $class, array $expected): void
+ {
+ $manager = $this->manager();
+ $queries = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$queries): MockResponse {
+ $queries[] = $pendingRequest->queryParameters();
+
+ return MockResponse::make(['data' => [count($queries)], 'next' => 'next-token']);
+ }]);
+ $paginator = (new $class(new PaginationConnectorStub($manager), new PagedRequestStub))
+ ->perPageLimit(2)->maxPages(2);
+
+ iterator_to_array($paginator);
+
+ $this->assertSame($expected, $queries);
+ }
+
+ /**
+ * Provide each configurable query parameter pair.
+ */
+ public static function renamedParameters(): array
+ {
+ return [
+ [RenamedPagedPaginatorStub::class, [['number' => 1, 'size' => 2], ['number' => 2, 'size' => 2]]],
+ [RenamedOffsetPaginatorStub::class, [['take' => 2, 'skip' => 0], ['take' => 2, 'skip' => 2]]],
+ [RenamedCursorPaginatorStub::class, [['size' => 2], ['after' => 'next-token', 'size' => 2]]],
+ ];
+ }
+
+ public function testLinkContinuationReplacesRawQueryAndPageSizeAndResetsOnRewind(): void
+ {
+ $manager = $this->manager();
+ $queries = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$queries): MockResponse {
+ $query = $pendingRequest->uri()->getQuery();
+ $queries[] = $query;
+ $first = ! str_contains($query, 'cursor=');
+
+ return MockResponse::make(['data' => [$first ? 1 : 2]], headers: $first ? [
+ 'Link' => '; rel=next',
+ ] : []);
+ }]);
+ $request = (new PagedRequestStub)->withQueryString('old=value')
+ ->withQueryParameters(['filter' => 'active'])
+ ->authenticate(new QueryAuthenticator('key', 'secret'));
+ $paginator = (new RenamedLinkPaginatorStub(new PaginationConnectorStub($manager), $request))->perPageLimit(2);
+
+ $this->assertSame([1, 2], $paginator->collect()->all());
+ $this->assertSame([1, 2], $paginator->collect()->all());
+ $this->assertSame([
+ 'old=value&filter=active&number=1&size=2&key=secret',
+ 'cursor=a%2Fb&tag=one&tag=two&size=7&filter=active&key=secret',
+ 'old=value&filter=active&number=1&size=2&key=secret',
+ 'cursor=a%2Fb&tag=one&tag=two&size=7&filter=active&key=secret',
+ ], $queries);
+ $this->assertSame('old=value', $request->queryString());
+ }
+
+ #[DataProvider('validLinkHeaders')]
+ public function testLinkHeadersNavigateOnlyEffectivePaginationRelations(array $headers, ?string $nextQuery): void
+ {
+ $manager = $this->manager();
+ $queries = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$queries, $headers): MockResponse {
+ $queries[] = $pendingRequest->uri()->getQuery();
+
+ return MockResponse::make(['data' => [count($queries)]], headers: count($queries) === 1 ? ['Link' => $headers] : []);
+ }]);
+ $paginator = new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->assertSame($nextQuery === null ? [1] : [1, 2], $paginator->collect()->all());
+ $this->assertSame($nextQuery === null ? ['page=1'] : ['page=1', $nextQuery], $queries);
+ }
+
+ /**
+ * Provide valid HTTP list syntax and effective relation combinations.
+ */
+ public static function validLinkHeaders(): array
+ {
+ return [
+ 'no header' => [[], null],
+ 'last only' => [['; rel=last'], null],
+ 'relative path' => [['; rel=next'], 'page=2'],
+ 'root relative' => [['; rel=next'], 'page=2'],
+ 'case and default port' => [['; REL=NEXT'], 'page=2'],
+ 'several fields' => [['; rel=next', '; rel=last'], 'page=2'],
+ 'first rel wins' => [['; rel=next; rel=last'], 'page=2'],
+ 'unquoted media type' => [['; rel=next; type=application/json'], 'page=2'],
+ 'multiple relations' => [['; rel="next last"'], 'page=2'],
+ 'equivalent duplicate targets' => [['; rel=next, ; rel=next'], 'page=2'],
+ 'anchored and ordinary links' => [['; rel=next; anchor="/another", ; rel=next'], 'page=2'],
+ 'unknown relations and absent values' => [[', ; rel=, ; rel="", ; rel=prev, ; rel=prev, ; rel=next'], 'page=2'],
+ 'commas quotes escapes and whitespace' => [[' , ; title="quoted \"text\"; with, commas"; unused; ; rel = "NeXt"; , , '], 'cursor=a,b'],
+ 'empty list elements' => [[' , , '], null],
+ ];
+ }
+
+ #[DataProvider('invalidLinkHeaders')]
+ public function testMalformedOrContradictoryPaginationLinksFail(string $header): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]], headers: ['Link' => $header])]);
+ $paginator = new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->expectException(PaginationException::class);
+
+ $paginator->current();
+ }
+
+ /**
+ * Provide structural errors and unusable pagination targets.
+ */
+ public static function invalidLinkHeaders(): array
+ {
+ return [
+ 'missing bracket' => ['?page=2; rel=next'],
+ 'unclosed target' => [' ['; rel="next'],
+ 'missing comma' => ['; rel=next ; rel=last'],
+ 'unexpected text' => [' trailing'],
+ 'conflicting next' => ['; rel=next, ; rel=next'],
+ 'conflicting last' => ['; rel=last, ; rel=last'],
+ 'different scheme' => ['; rel=next'],
+ 'different host' => ['; rel=next'],
+ 'different port' => ['; rel=next'],
+ 'different path' => ['; rel=next'],
+ 'last different path' => ['; rel=last'],
+ ];
+ }
+
+ public function testSequentialLinkPaginationDoesNotInterpretLastPageNumbers(): void
+ {
+ $manager = $this->manager();
+ $queries = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$queries): MockResponse {
+ $queries[] = $pendingRequest->uri()->getQuery();
+
+ return MockResponse::make(['data' => [count($queries)]], headers: ['Link' => count($queries) === 1
+ ? '; rel=next, ; rel=last'
+ : '; rel=last']);
+ }]);
+ $paginator = new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->assertSame([1, 2], $paginator->collect()->all());
+ $this->assertSame(['page=1', 'page=opaque-cursor'], $queries);
+ }
+
+ #[DataProvider('invalidPooledLastLinks')]
+ public function testLinkPoolRejectsInvalidOrContradictoryLastPageNumbers(string $header): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]], headers: ['Link' => $header])]);
+ $paginator = new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->expectException(PaginationException::class);
+
+ $paginator->pool();
+ }
+
+ /**
+ * Provide unusable or contradictory last pages for a pooled range.
+ */
+ public static function invalidPooledLastLinks(): array
+ {
+ return [
+ 'fractional last' => ['; rel=last'],
+ 'oversized last' => ['; rel=last'],
+ 'repeated page number' => ['; rel=last'],
+ 'next at last page' => ['; rel=next, ; rel=last'],
+ 'next beyond last page' => ['; rel=next, ; rel=last'],
+ 'last after terminal page' => ['; rel=last'],
+ ];
+ }
+
+ #[DataProvider('completedLastLinks')]
+ public function testLinkPoolAcceptsACompletedPageAtOrBeyondTheLastPage(int $startPage, int $lastPage, array $items): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => $items], headers: [
+ 'Link' => "; rel=last",
+ ])]);
+ $paginator = (new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub))->startPage($startPage);
+
+ $this->assertSame($items, $paginator->collect()->all());
+ $this->assertCount(1, $paginator->pool());
+ $this->assertSame(count($items), $paginator->totalResults());
+ }
+
+ /**
+ * Provide zero-based, empty and shrinking completed collections.
+ */
+ public static function completedLastLinks(): array
+ {
+ return [
+ 'zero-based single page' => [0, 0, [1]],
+ 'empty collection' => [0, -1, []],
+ 'shrinking collection' => [5, 2, []],
+ ];
+ }
+
+ public function testLinkPoolUsesConfiguredNumberedRequestsAndTheDeclaredLastPage(): void
+ {
+ $manager = $this->manager();
+ $queries = [];
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest) use (&$queries): MockResponse {
+ $query = $pendingRequest->queryParameters();
+ $queries[] = $query;
+
+ return MockResponse::make(['data' => [$query['number']]], headers: [
+ 'Link' => '; rel=next, ; rel=last',
+ ]);
+ }]);
+ $paginator = (new RenamedLinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub))
+ ->startPage(2)->perPageLimit(5);
+
+ $responses = $paginator->pool();
+
+ $this->assertSame([0, 1, 2], array_keys($responses));
+ $this->assertSame([['number' => 2, 'size' => 5], ['number' => 3, 'size' => 5], ['number' => 4, 'size' => 5]], $queries);
+ $this->assertSame(3, $paginator->totalResults());
+ }
+
+ #[DataProvider('unnumberedLastLinks')]
+ public function testLinkPoolRejectsAnUnknownPageCount(string $header): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]], headers: ['Link' => $header])]);
+ $paginator = new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub);
+
+ $this->expectException(PaginationException::class);
+ $this->expectExceptionMessage('Pooled Link pagination requires a numbered last Link.');
+
+ $paginator->pool();
+ }
+
+ /**
+ * Provide next links without an independently addressable last page.
+ */
+ public static function unnumberedLastLinks(): array
+ {
+ return [['; rel=next'], ['; rel=next, ; rel=last']];
+ }
+
+ public function testLinkPoolWithoutLinksFetchesOnlyTheStartingPage(): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]])]);
+ $paginator = (new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub))->startPage(5);
+
+ $this->assertCount(1, $paginator->pool());
+ $this->assertSame(1, $paginator->totalResults());
+ }
+
+ public function testLinkPaginationMayStartAtPageZero(): void
+ {
+ $manager = $this->manager();
+ $manager->fake([PagedRequestStub::class => static function (PendingRequest $pendingRequest): MockResponse {
+ $page = (int) $pendingRequest->queryParameters()['page'];
+
+ return MockResponse::make(['data' => [$page]], headers: $page === 0 ? [
+ 'Link' => '; rel="next last"',
+ ] : []);
+ }]);
+ $paginator = (new LinkPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub))->startPage(0);
+
+ $this->assertCount(2, $paginator->pool());
}
public function testRepeatedBodiesStopASequentialPaginationLoop(): void
@@ -352,8 +856,20 @@ public function resolveEndpoint(): string
class MappedPagedRequestStub extends PagedRequestStub implements MapPaginatedResponseItems
{
+ public object $mapping;
+
+ /**
+ * Share mapping observations across request clones.
+ */
+ public function __construct()
+ {
+ $this->mapping = (object) ['calls' => 0];
+ }
+
public function mapPaginatedResponseItems(Response $response): array
{
+ ++$this->mapping->calls;
+
return array_column($response->json('data'), 'name');
}
}
@@ -419,6 +935,40 @@ protected function getPageItems(Response $response, Request $request): array
}
}
+class MappingPagedPaginatorStub extends PagedPaginatorStub
+{
+ public array $mappedPages = [];
+
+ public ?Closure $mapItems = null;
+
+ public ?Closure $resolvePages = null;
+
+ /**
+ * Track mapping before returning or rejecting the page items.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ $this->mappedPages[] = $response->json('page');
+
+ return $this->mapItems !== null ? ($this->mapItems)($response) : parent::getPageItems($response, $request);
+ }
+
+ /**
+ * Observe item lifetime when the pool resolves its page count.
+ */
+ protected function getTotalPages(Response $response): int
+ {
+ return $this->resolvePages !== null ? ($this->resolvePages)() : parent::getTotalPages($response);
+ }
+}
+
+class RenamedPagedPaginatorStub extends NeverEndingPagedPaginatorStub
+{
+ protected string $pageName = 'number';
+
+ protected string $perPageName = 'size';
+}
+
class OffsetPaginatorStub extends OffsetPaginator
{
protected function isLastPage(Response $response): bool
@@ -454,3 +1004,43 @@ protected function getPageItems(Response $response, Request $request): array
return $response->json('data');
}
}
+
+class RenamedOffsetPaginatorStub extends OffsetPaginatorStub
+{
+ protected string $limitName = 'take';
+
+ protected string $offsetName = 'skip';
+
+ /**
+ * Keep requesting pages until the configured test limit.
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return false;
+ }
+}
+
+class RenamedCursorPaginatorStub extends CursorPaginatorStub
+{
+ protected string $cursorName = 'after';
+
+ protected string $perPageName = 'size';
+}
+
+class LinkPaginatorStub extends LinkHeaderPaginator
+{
+ /**
+ * Map the items carried by the test API.
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return $response->json('data');
+ }
+}
+
+class RenamedLinkPaginatorStub extends LinkPaginatorStub
+{
+ protected string $pageName = 'number';
+
+ protected string $perPageName = 'size';
+}
diff --git a/tests/Saloon/SensitiveParameterTest.php b/tests/Saloon/SensitiveParameterTest.php
index beb3ea66a..c47ee6876 100644
--- a/tests/Saloon/SensitiveParameterTest.php
+++ b/tests/Saloon/SensitiveParameterTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Saloon;
+use Hypervel\Saloon\Http\Auth\CookieAuthenticator;
use Hypervel\Saloon\Traits\OAuth2\AuthorizationCodeGrant;
use Hypervel\Saloon\Traits\OAuth2\CreatesOAuthAuthenticator;
use Hypervel\Tests\TestCase;
@@ -14,7 +15,7 @@
class SensitiveParameterTest extends TestCase
{
#[DataProvider('sensitiveParameters')]
- public function testOAuthTokenAndStateParametersAreSensitive(
+ public function testSecretParametersAreSensitive(
string $class,
string $method,
string $parameterName,
@@ -33,13 +34,14 @@ public function testOAuthTokenAndStateParametersAreSensitive(
}
/**
- * Get secret-bearing OAuth parameters from internal call boundaries.
+ * Get secret-bearing authentication parameters.
*
* @return list
*/
public static function sensitiveParameters(): array
{
return [
+ [CookieAuthenticator::class, '__construct', 'value'],
[CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'response'],
[CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'fallbackRefreshToken'],
[CreatesOAuthAuthenticator::class, 'createOAuthAuthenticator', 'accessToken'],
diff --git a/types/Saloon/Pagination.php b/types/Saloon/Pagination.php
new file mode 100644
index 000000000..00a904d34
--- /dev/null
+++ b/types/Saloon/Pagination.php
@@ -0,0 +1,169 @@
+
+ * @implements MapPaginatedResponseItems
+ * @implements HasRequestPagination
+ */
+class SaloonPaginationTypeRequest extends Request implements Paginatable, MapPaginatedResponseItems, HasRequestPagination
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the item-list endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/items';
+ }
+
+ /**
+ * Map a page to its declared item type.
+ *
+ * @param Response $response
+ * @return list
+ */
+ public function mapPaginatedResponseItems(Response $response): array
+ {
+ return [new SaloonPaginationTypeItem];
+ }
+
+ /**
+ * Create the request's typed paginator.
+ *
+ * @param Connector $connector
+ * @return Paginator
+ */
+ public function paginate(Connector $connector): Paginator
+ {
+ return new SaloonPaginationTypePaged($connector, $this);
+ }
+}
+
+/**
+ * @extends Connector
+ * @implements HasPagination
+ */
+class SaloonPaginationTypeConnector extends Connector implements HasPagination
+{
+ /**
+ * Resolve the API URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return 'https://example.com';
+ }
+
+ /**
+ * Create the connector's typed paginator.
+ *
+ * @param Request $request
+ * @return Paginator
+ */
+ public function paginate(Request $request): Paginator
+ {
+ return new SaloonPaginationTypePaged($this, $request);
+ }
+}
+
+/** @extends PagedPaginator */
+class SaloonPaginationTypePaged extends PagedPaginator
+{
+ /**
+ * Stop after the fixture page.
+ *
+ * @param Response $response
+ */
+ protected function isLastPage(Response $response): bool
+ {
+ return true;
+ }
+
+ /**
+ * Return the declared item type.
+ *
+ * @param Response $response
+ * @param Request $request
+ * @return list
+ */
+ protected function getPageItems(Response $response, Request $request): array
+ {
+ return [new SaloonPaginationTypeItem];
+ }
+}
+
+/** @extends OffsetPaginator */
+abstract class SaloonPaginationTypeOffset extends OffsetPaginator
+{
+}
+
+/** @extends CursorPaginator */
+abstract class SaloonPaginationTypeCursor extends CursorPaginator
+{
+}
+
+/** @extends LinkHeaderPaginator */
+abstract class SaloonPaginationTypeLink extends LinkHeaderPaginator
+{
+}
+
+/**
+ * Verify pagination item types across subclasses and public contracts.
+ *
+ * @param HasPagination $connector
+ * @param HasRequestPagination $request
+ */
+function SaloonPaginationTypeAssertions(
+ SaloonPaginationTypePaged $paged,
+ SaloonPaginationTypeOffset $offset,
+ SaloonPaginationTypeCursor $cursor,
+ SaloonPaginationTypeLink $link,
+ HasPagination $connector,
+ HasRequestPagination $request,
+ bool $throughItems,
+): void {
+ assertType('iterable', $paged->items());
+ assertType('Hypervel\Support\LazyCollection', $paged->collect());
+ assertType('Hypervel\Support\LazyCollection>', $paged->collect(false));
+ assertType('Hypervel\Support\LazyCollection>|Hypervel\Support\LazyCollection', $paged->collect($throughItems));
+ assertType('Hypervel\Saloon\Http\Response', $paged->current());
+ assertType('array>', $paged->pool());
+ assertType('iterable', $offset->items());
+ assertType('iterable', $cursor->items());
+ assertType('iterable', $link->items());
+ assertType('Hypervel\Saloon\Http\Response', $link->current());
+ assertType('Hypervel\Saloon\Pagination\Paginator', $connector->paginate(new SaloonPaginationTypeRequest));
+ assertType('Hypervel\Saloon\Pagination\Paginator', $request->paginate(new SaloonPaginationTypeConnector));
+
+ foreach ($paged as $key => $response) {
+ assertType('int', $key);
+ assertType('Hypervel\Saloon\Http\Response', $response);
+ }
+
+ foreach ($paged->items() as $key => $item) {
+ assertType('int', $key);
+ assertType(SaloonPaginationTypeItem::class, $item);
+ }
+}
diff --git a/types/Saloon/Saloon.php b/types/Saloon/Saloon.php
index dcd89d23c..1da62051d 100644
--- a/types/Saloon/Saloon.php
+++ b/types/Saloon/Saloon.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use Hypervel\Saloon\Enums\Method;
+use Hypervel\Saloon\Http\BaseResource;
use Hypervel\Saloon\Http\Connector;
use Hypervel\Saloon\Http\Request;
use Hypervel\Saloon\Http\Response;
@@ -33,12 +34,41 @@ public function createDtoFromResponse(Response $response): SaloonTypeUserData
/** @extends Connector */
class SaloonTypeConnector extends Connector
{
+ /**
+ * Get an integration-specific value for resource type assertions.
+ */
+ public function integrationName(): string
+ {
+ return 'users';
+ }
+
public function resolveBaseUrl(): string
{
return 'https://example.com';
}
}
+/** @extends BaseResource */
+class SaloonTypeUserResource extends BaseResource
+{
+ /**
+ * Send a typed request through the resource connector.
+ *
+ * @return Response
+ */
+ public function get(): Response
+ {
+ assertType(SaloonTypeConnector::class, $this->connector);
+ assertType('string', $this->connector->integrationName());
+
+ return $this->connector->send(new SaloonTypeGetUserRequest);
+ }
+}
+
+$resource = new SaloonTypeUserResource(new SaloonTypeConnector);
+assertType('Hypervel\Saloon\Http\Response', $resource->get());
+assertType(SaloonTypeUserData::class, $resource->get()->dto());
+
$response = (new SaloonTypeConnector)->send(new SaloonTypeGetUserRequest);
assertType('Hypervel\Saloon\Http\Response', $response);
From c8957f9b87d21117fc43b1b26e122334e2327ead Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 15:17:41 +0000
Subject: [PATCH 4/8] Remove manual streaming regression override
Use only the Swoole version boundary for the buffered-read and read-timeout regressions. Each test links to the upstream correction and runs automatically after 6.2.2, so a later affected release cannot silently bypass coverage.
Remove the environment setting and its helper instead of maintaining a second way to enable the tests. Keep all regression assertions and leave the production streaming transport unchanged.
---
.env.example | 3 ---
tests/Http/HttpClientStreamingTest.php | 22 ++++++----------------
2 files changed, 6 insertions(+), 19 deletions(-)
diff --git a/.env.example b/.env.example
index 317364932..231c30030 100644
--- a/.env.example
+++ b/.env.example
@@ -71,9 +71,6 @@
# Uncomment TEST_SERVER_HOST to opt into server integration tests.
# TEST_SERVER_HOST=127.0.0.1
-# Run streaming regressions on a Swoole build with the upstream fixes applied.
-# HYPERVEL_TEST_SWOOLE_STREAM_FIXES=1
-
# Algolia Integration Tests
# ALGOLIA_APP_ID=your-app-id
# ALGOLIA_SECRET=your-admin-api-key
diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php
index b1e40d9d0..2bf6ab8b3 100644
--- a/tests/Http/HttpClientStreamingTest.php
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -76,8 +76,9 @@ public function testStreamingReadsAllowOtherCoroutinesToProgress(): void
public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
{
- // https://github.com/swoole/swoole-src/pull/6235
- $this->requireSwooleStreamFixes('Hooked reads wait for more data after PHP has already supplied buffered bytes.');
+ if (SWOOLE_VERSION_ID <= 60202) {
+ $this->markTestSkipped('Buffered stream reads require https://github.com/swoole/swoole-src/pull/6235.');
+ }
$this->withStreamingServer('buffered', function (string $address): void {
$received = new Channel(1);
@@ -116,8 +117,9 @@ public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(): void
{
- // https://github.com/swoole/swoole-src/pull/6236
- $this->requireSwooleStreamFixes('Hooked read timeouts return an empty string instead of a failed read.');
+ if (SWOOLE_VERSION_ID <= 60202) {
+ $this->markTestSkipped('Stream read timeout errors require https://github.com/swoole/swoole-src/pull/6236.');
+ }
$this->withStreamingServer('delayed', function (string $address): void {
$finished = new Channel(1);
@@ -150,18 +152,6 @@ public function testIdleStreamingReadTimeoutRaisesTheStreamReadError(): void
});
}
- /**
- * Run the upstream regressions on newer or explicitly patched Swoole builds.
- */
- protected function requireSwooleStreamFixes(string $reason): void
- {
- // Swoole 6.2.2 and earlier have these socket_read() defects.
- // https://github.com/swoole/swoole-src/blob/v6.2.2/ext-src/swoole_runtime.cc#L530-L568
- if (version_compare(swoole_version(), '6.2.2', '<=') && getenv('HYPERVEL_TEST_SWOOLE_STREAM_FIXES') !== '1') {
- $this->markTestSkipped($reason . ' Affects Swoole <= 6.2.2; set HYPERVEL_TEST_SWOOLE_STREAM_FIXES=1 to test a patched build.');
- }
- }
-
/**
* Run a hooked client against an independently controlled loopback server.
*/
From df3376da9e2c99b99bc9f3ff635bb6b577805794 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 15:18:02 +0000
Subject: [PATCH 5/8] Preserve cookie attributes and reuse loaded pagination
responses
Add withCookie(SetCookie) to HTTP and Saloon requests without changing withCookies. Snapshot caller-owned cookie state, retain attributes through Saloon transport and cache identity, and reject missing domains before either real or faked requests. Authentication cookies inferred from a request are host-only; HTTPS credentials are Secure while explicit HTTP requests remain supported.
Make repeated paginator current() calls reuse the loaded response without another request, mapping pass, or count. Publish the response only after item mapping succeeds so cursor and Link requests retry the same page after a mapper failure. Clear loaded state on advance and rewind, and compare pooled observations without assuming completion order.
Cover cookie ownership, domain validation, authentication attributes and retries, attribute-sensitive cache keys, and repeated pagination reads across page, cursor, and Link strategies. Update HTTP facade and ApiClient method annotations, cookie examples, pagination type guidance, and Link error wording. Formatting, full static analysis and the affected HTTP, Saloon, and ApiClient suites pass.
---
src/api-client/src/PendingRequest.php | 1 +
src/docs/http-client.md | 17 +++-
src/docs/saloon.md | 22 ++++-
src/http/src/Client/PendingRequest.php | 15 ++++
src/http/src/Client/ReservedOptions.php | 2 +-
.../src/Http/Auth/CookieAuthenticator.php | 15 +++-
src/saloon/src/Http/PendingRequest.php | 2 +-
src/saloon/src/Http/Sender.php | 5 +-
src/saloon/src/Pagination/Paginator.php | 18 +++-
.../Traits/RequestProperties/HasCookies.php | 36 ++++++--
src/support/src/Facades/Http.php | 1 +
tests/Http/HttpClientTest.php | 25 ++++++
tests/Saloon/Cache/CacheTest.php | 10 +++
tests/Saloon/CoroutineIsolationTest.php | 4 +-
tests/Saloon/Http/AuthenticationTest.php | 44 +++++++---
tests/Saloon/Http/RequestTest.php | 15 +++-
tests/Saloon/Pagination/PaginatorTest.php | 88 +++++++++++++++++++
17 files changed, 282 insertions(+), 38 deletions(-)
diff --git a/src/api-client/src/PendingRequest.php b/src/api-client/src/PendingRequest.php
index 7e891e52c..dc5567a76 100644
--- a/src/api-client/src/PendingRequest.php
+++ b/src/api-client/src/PendingRequest.php
@@ -47,6 +47,7 @@
* @method static withToken(string $token, string $type = 'Bearer')
* @method static withUserAgent(bool|string $userAgent)
* @method static withUrlParameters(array $parameters = [])
+ * @method static withCookie(\GuzzleHttp\Cookie\SetCookie $cookie)
* @method static withCookies(array $cookies, string $domain)
* @method static maxRedirects(int $max)
* @method static withoutRedirecting()
diff --git a/src/docs/http-client.md b/src/docs/http-client.md
index d0648a0d7..4d024bb8b 100644
--- a/src/docs/http-client.md
+++ b/src/docs/http-client.md
@@ -354,6 +354,21 @@ $response = Http::withCookies([
], 'example.com')->get(/* ... */);
```
+To specify a cookie's path or other attributes, pass a Guzzle `SetCookie` instance to `withCookie`. The cookie must include a domain:
+
+```php
+use GuzzleHttp\Cookie\SetCookie;
+
+$response = Http::withCookie(new SetCookie([
+ 'Name' => 'session',
+ 'Value' => 'abc123',
+ 'Domain' => 'api.example.com',
+ 'Path' => '/api',
+ 'Secure' => true,
+ 'HostOnly' => true,
+]))->get('https://api.example.com/api/users');
+```
+
By default, redirects will be followed. You may configure the maximum number of redirects using the `maxRedirects` method, or disable redirects entirely using the `withoutRedirecting` method:
```php
@@ -935,7 +950,7 @@ public function boot(): void
The second argument is a request-option preset. It accepts normal Guzzle request options except for options whose ownership belongs to a dedicated Hypervel API:
-- `cookies` is rejected. Every pending request owns an isolated cookie jar; seed it with `withCookies()`.
+- `cookies` is rejected. Every pending request owns an isolated cookie jar; add cookies using `withCookie()` or `withCookies()`.
- `handler` is rejected. Use `setHandler()` for a request-specific handler.
- `pool` is rejected. HTTP clients are not object-pooled.
- `max_host_connections` and `max_total_connections` are rejected. Use bounded coroutine fan-out or the rate limiter instead.
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index 923da2c28..c0fd9bed6 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -538,7 +538,7 @@ use Hypervel\Saloon\Http\Auth\CookieAuthenticator;
$request->authenticate(new CookieAuthenticator('session', $token));
```
-By default, the cookie is sent to the request's host. You may pass a domain such as `.example.com` as the third argument; an empty domain is not allowed. If you replace the authenticator using the same cookie name and domain, the new value is used when sending the request.
+By default, the cookie is sent only to the request's host, not its subdomains. For HTTPS requests, it is also marked `Secure` so it cannot be sent over HTTP. You may pass a domain such as `.example.com` as the third argument to include subdomains; an empty domain is not allowed. If you replace the authenticator using the same cookie name and domain argument, the new value is used when sending the request.
Add the `RequiresAuth` trait to a request that must never be sent without an authenticator:
@@ -740,6 +740,20 @@ $request
The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds.
+To specify a cookie's path or other attributes, use `withCookie` with a Guzzle `SetCookie` instance. The cookie must include a domain:
+
+```php
+use GuzzleHttp\Cookie\SetCookie;
+
+$request->withCookie(new SetCookie([
+ 'Name' => 'locale',
+ 'Value' => 'en',
+ 'Domain' => 'api.example.com',
+ 'Path' => '/reports',
+ 'Secure' => true,
+]));
+```
+
Request-shaping options such as `headers`, `query`, `cookies`, `body`, `json`, `form_params`, `multipart`, `auth`, `delay`, and `http_errors` must be configured through Saloon's dedicated methods. Transport sharing belongs to a fixed Hypervel HTTP connection, while request handlers, object pools, and connection caps are not accepted through `withOptions`.
@@ -1524,10 +1538,12 @@ class GitHubPaginator extends PagedPaginator
}
}
+/** @implements HasPagination> */
class GitHubConnector extends Connector implements HasPagination
{
// Define the connector base URL and defaults...
+ /** @return Paginator> */
public function paginate(Request $request): Paginator
{
if ($request instanceof HasRequestPagination) {
@@ -1553,7 +1569,7 @@ foreach ($paginator->items() as $user) {
$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.
+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.
@@ -1602,7 +1618,7 @@ The first request uses your configured page and per-page parameters. For each la
Pagination links must use the same scheme, host, port, and path as the current request.
-Iteration ends when the response has no `next` link. If the API also supplies a `last` link containing a page number, you may use `pool` to request the remaining pages concurrently. Pooled requests use your configured page names and `perPageLimit`. Cursor-only links must be followed sequentially. Malformed links throw a `PaginationException`; invalid or contradictory last-page numbers are rejected when pooling.
+Iteration ends when the response has no `next` link. If the API also supplies a `last` link containing a page number, you may use `pool` to request the remaining pages concurrently. Pooled requests use your configured page names and `perPageLimit`. Cursor-only links must be followed sequentially. Malformed header syntax or conflicting pagination links throw a `PaginationException`; invalid or contradictory last-page numbers are rejected when pooling.
### Pooled Pagination
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 4cb501c58..874fe2a42 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -9,6 +9,7 @@
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Cookie\CookieJar;
+use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\TransferException;
@@ -499,6 +500,20 @@ public function withUrlParameters(array $parameters = []): static
});
}
+ /**
+ * Specify a cookie and its attributes for the request.
+ */
+ public function withCookie(SetCookie $cookie): static
+ {
+ if ($cookie->getDomain() === null) {
+ throw new InvalidArgumentException('An outgoing cookie must have a domain.');
+ }
+
+ $this->cookies->setCookie(clone $cookie);
+
+ return $this;
+ }
+
/**
* Specify the cookies that should be included with the request.
*/
diff --git a/src/http/src/Client/ReservedOptions.php b/src/http/src/Client/ReservedOptions.php
index eacb0e6f4..e0ab2ab4e 100644
--- a/src/http/src/Client/ReservedOptions.php
+++ b/src/http/src/Client/ReservedOptions.php
@@ -23,7 +23,7 @@ public static function reject(array $options, bool $allowTransportSharing, strin
$messages = [
'pool' => 'HTTP clients are not object-pooled; named connections share their low-level transport handler automatically.',
'handler' => 'Use PendingRequest::setHandler() to provide a request-specific handler.',
- 'cookies' => 'Use PendingRequest::withCookies() to seed the request-owned cookie jar.',
+ 'cookies' => 'Use PendingRequest::withCookie() or withCookies() to seed the request-owned cookie jar.',
'max_host_connections' => 'Guzzle applies connection caps through a shared multi-handler, which cannot be driven safely by concurrent coroutines. Use bounded coroutine fan-out or rate limiting instead.',
'max_total_connections' => 'Guzzle applies connection caps through a shared multi-handler, which cannot be driven safely by concurrent coroutines. Use bounded coroutine fan-out or rate limiting instead.',
];
diff --git a/src/saloon/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php
index 5be8bf8c7..2476c418c 100644
--- a/src/saloon/src/Http/Auth/CookieAuthenticator.php
+++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php
@@ -4,6 +4,7 @@
namespace Hypervel\Saloon\Http\Auth;
+use GuzzleHttp\Cookie\SetCookie;
use Hypervel\Saloon\Contracts\Authenticator;
use Hypervel\Saloon\Http\PendingRequest;
use InvalidArgumentException;
@@ -30,9 +31,15 @@ public function __construct(
*/
public function set(PendingRequest $pendingRequest): void
{
- $pendingRequest->withCookies(
- [$this->name => $this->value],
- $this->domain ?? $pendingRequest->uri()->getHost(),
- );
+ $uri = $pendingRequest->uri();
+
+ $pendingRequest->withCookie(new SetCookie([
+ 'Name' => $this->name,
+ 'Value' => $this->value,
+ 'Domain' => $this->domain ?? $uri->getHost(),
+ 'HostOnly' => $this->domain === null,
+ 'Secure' => $uri->getScheme() === 'https',
+ 'Discard' => true,
+ ]));
}
}
diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php
index 923b88843..0a8162587 100644
--- a/src/saloon/src/Http/PendingRequest.php
+++ b/src/saloon/src/Http/PendingRequest.php
@@ -144,7 +144,7 @@ public function __construct(
$this->bodyRepository = $connectorBody->merge($requestBody->all());
}
- $this->cookieGroups = $request->cookies();
+ $this->cookies = $request->cookies();
$this->retryPolicy = $request->retryPolicy();
$this->authenticator = $request->authenticator() ?? $connector->authenticator();
}
diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php
index 8fd295da5..1c9945498 100644
--- a/src/saloon/src/Http/Sender.php
+++ b/src/saloon/src/Http/Sender.php
@@ -4,6 +4,7 @@
namespace Hypervel\Saloon\Http;
+use GuzzleHttp\Cookie\SetCookie;
use Hypervel\Contracts\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Telescope\TelescopeTag;
use Hypervel\Http\Client\Factory;
@@ -59,8 +60,8 @@ public function send(PendingRequest $pendingRequest, array $transport): Response
$httpRequest->withBody($body, null);
}
- foreach ($pendingRequest->cookies() as $cookieGroup) {
- $httpRequest->withCookies($cookieGroup['cookies'], $cookieGroup['domain']);
+ foreach ($pendingRequest->cookies() as $cookie) {
+ $httpRequest->withCookie(new SetCookie($cookie));
}
$httpRequest
diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php
index fde674078..5761fbe2c 100644
--- a/src/saloon/src/Pagination/Paginator.php
+++ b/src/saloon/src/Pagination/Paginator.php
@@ -57,6 +57,11 @@ abstract class Paginator implements Countable, Iterator
*/
protected ?Response $currentResponse = null;
+ /**
+ * Whether the current page has been fetched and mapped.
+ */
+ protected bool $currentPageLoaded = false;
+
/**
* The items mapped from the final current response.
*
@@ -134,11 +139,18 @@ public function __construct(
*/
public function current(): Response
{
+ if ($this->currentPageLoaded && $this->currentResponse !== null) {
+ return $this->currentResponse;
+ }
+
$request = $this->applyPagination(clone $this->request);
- $this->currentResponse = $this->connector->send($request);
- $this->currentPageItems = $this->pageItems($this->currentResponse);
+ $response = $this->connector->send($request);
+ $this->currentPageItems = $this->pageItems($response);
+ // Keep the preceding response until mapping succeeds so a failed load retries the same page.
+ $this->currentResponse = $response;
$this->totalResults += count($this->currentPageItems);
+ $this->currentPageLoaded = true;
return $this->currentResponse;
}
@@ -148,6 +160,7 @@ public function current(): Response
*/
public function next(): void
{
+ $this->currentPageLoaded = false;
$this->currentPageItems = [];
++$this->pageNumber;
++$this->currentPage;
@@ -181,6 +194,7 @@ public function rewind(): void
$this->pageNumber = $this->startPage;
$this->currentPage = 0;
$this->currentResponse = null;
+ $this->currentPageLoaded = false;
$this->currentPageItems = [];
$this->totalResults = 0;
$this->lastFiveBodyChecksums = [];
diff --git a/src/saloon/src/Traits/RequestProperties/HasCookies.php b/src/saloon/src/Traits/RequestProperties/HasCookies.php
index f42a6fb8d..8760e22af 100644
--- a/src/saloon/src/Traits/RequestProperties/HasCookies.php
+++ b/src/saloon/src/Traits/RequestProperties/HasCookies.php
@@ -4,14 +4,34 @@
namespace Hypervel\Saloon\Traits\RequestProperties;
+use GuzzleHttp\Cookie\CookieJar;
+use GuzzleHttp\Cookie\SetCookie;
+use InvalidArgumentException;
+
trait HasCookies
{
/**
- * The request cookie groups.
+ * The request cookies and their attributes.
+ *
+ * @var list>
+ */
+ protected array $cookies = [];
+
+ /**
+ * Specify a cookie and its attributes for the request.
*
- * @var list, domain: string}>
+ * @return $this
*/
- protected array $cookieGroups = [];
+ public function withCookie(SetCookie $cookie): static
+ {
+ if ($cookie->getDomain() === null) {
+ throw new InvalidArgumentException('An outgoing cookie must have a domain.');
+ }
+
+ $this->cookies[] = $cookie->toArray();
+
+ return $this;
+ }
/**
* Specify cookies that should be included with the request.
@@ -21,18 +41,20 @@ trait HasCookies
*/
public function withCookies(array $cookies, string $domain): static
{
- $this->cookieGroups[] = ['cookies' => $cookies, 'domain' => $domain];
+ foreach (CookieJar::fromArray($cookies, $domain) as $cookie) {
+ $this->withCookie($cookie);
+ }
return $this;
}
/**
- * Get the request cookie groups.
+ * Get the request cookies and their attributes.
*
- * @return list, domain: string}>
+ * @return list>
*/
public function cookies(): array
{
- return $this->cookieGroups;
+ return $this->cookies;
}
}
diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php
index dab43262d..5da151696 100644
--- a/src/support/src/Facades/Http.php
+++ b/src/support/src/Facades/Http.php
@@ -104,6 +104,7 @@
* @method static \Hypervel\Http\Client\PendingRequest withAttributes(array $attributes)
* @method static \Hypervel\Http\Client\PendingRequest withBasicAuth(string $username, string $password)
* @method static \Hypervel\Http\Client\PendingRequest withBody(null|resource|\Psr\Http\Message\StreamInterface|string|\Hypervel\Support\Stringable $content, string|null $contentType = 'application/json')
+ * @method static \Hypervel\Http\Client\PendingRequest withCookie(\GuzzleHttp\Cookie\SetCookie $cookie)
* @method static \Hypervel\Http\Client\PendingRequest withCookies(array $cookies, string $domain)
* @method static \Hypervel\Http\Client\PendingRequest withDigestAuth(string $username, string $password)
* @method static \Hypervel\Http\Client\PendingRequest withHeader(string $name, mixed $value)
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index 113b49792..42d970c6b 100644
--- a/tests/Http/HttpClientTest.php
+++ b/tests/Http/HttpClientTest.php
@@ -10,6 +10,7 @@
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Cookie\CookieJar;
+use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException as GuzzleRequestException;
use GuzzleHttp\Exception\TooManyRedirectsException;
@@ -2212,6 +2213,30 @@ public function testWithCookies(): void
$this->assertSame('https://laravel.com', $responseCookie['Domain']);
}
+ public function testWithCookiePreservesAttributesAndSnapshotsTheInput(): void
+ {
+ $this->factory->fake();
+ $cookie = new SetCookie([
+ 'Name' => 'session', 'Value' => 'first', 'Domain' => 'api.example.com',
+ 'Path' => '/api', 'Secure' => true, 'HttpOnly' => true, 'HostOnly' => true,
+ ]);
+ $expected = $cookie->toArray();
+ $request = $this->factory->withCookie($cookie);
+ $cookie->setValue('second');
+
+ $response = $request->get('https://api.example.com/api/users');
+
+ $this->assertSame([$expected], $response->cookies()->toArray());
+ }
+
+ public function testWithCookieRejectsADomainlessCookie(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('An outgoing cookie must have a domain.');
+
+ $this->factory->withCookie(new SetCookie(['Name' => 'session', 'Value' => 'secret']));
+ }
+
public function testWithQueryParameters(): void
{
$this->factory->fake();
diff --git a/tests/Saloon/Cache/CacheTest.php b/tests/Saloon/Cache/CacheTest.php
index 0f2a8d147..d90466e34 100644
--- a/tests/Saloon/Cache/CacheTest.php
+++ b/tests/Saloon/Cache/CacheTest.php
@@ -6,6 +6,7 @@
use DateInterval;
use DateTimeInterface;
+use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Psr7\Utils;
use Hypervel\Cache\ArrayStore;
use Hypervel\Cache\Repository;
@@ -284,6 +285,15 @@ public function testDefaultKeyCanonicalizesMapsAndSeparatesResponseIdentity(): v
$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
diff --git a/tests/Saloon/CoroutineIsolationTest.php b/tests/Saloon/CoroutineIsolationTest.php
index 6e8c13956..ca821fb65 100644
--- a/tests/Saloon/CoroutineIsolationTest.php
+++ b/tests/Saloon/CoroutineIsolationTest.php
@@ -74,14 +74,14 @@ protected function sendIsolatedOperation(
): array {
$mockClient = new MockClient([
static function (PendingRequest $pendingRequest) use ($tenant): MockResponse {
- $cookieGroup = $pendingRequest->cookies()[0];
+ $cookie = $pendingRequest->cookies()[0];
return MockResponse::make([
'mock' => $tenant,
'header' => $pendingRequest->headers()['X-Tenant'],
'authorization' => $pendingRequest->headers()['Authorization'],
'middleware' => $pendingRequest->headers()['X-Middleware'],
- 'cookie' => $cookieGroup['cookies']['session'],
+ 'cookie' => $cookie['Value'],
]);
},
]);
diff --git a/tests/Saloon/Http/AuthenticationTest.php b/tests/Saloon/Http/AuthenticationTest.php
index 715288f09..f2d383ec6 100644
--- a/tests/Saloon/Http/AuthenticationTest.php
+++ b/tests/Saloon/Http/AuthenticationTest.php
@@ -25,10 +25,10 @@
class AuthenticationTest extends TestCase
{
#[DataProvider('cookieDomains')]
- public function testCookieDomainIsInferredOrExplicit(?string $domain, string $expected): void
+ public function testCookieDomainIsInferredOrExplicit(?string $domain, string $expected, string $scheme): void
{
$pendingRequest = new PendingRequest(
- new CookieAuthConnectorStub,
+ new CookieAuthConnectorStub($scheme . '://api.example.com'),
(new CookieAuthRequestStub)->authenticate(new CookieAuthenticator('session', 'secret', $domain)),
m::mock(CacheFactory::class),
m::mock(RateLimiter::class),
@@ -36,9 +36,14 @@ public function testCookieDomainIsInferredOrExplicit(?string $domain, string $ex
$pendingRequest->applyAuthentication();
- $this->assertSame([
- ['cookies' => ['session' => 'secret'], 'domain' => $expected],
- ], $pendingRequest->cookies());
+ $this->assertCount(1, $pendingRequest->cookies());
+ $cookie = $pendingRequest->cookies()[0];
+ $this->assertSame('session', $cookie['Name']);
+ $this->assertSame('secret', $cookie['Value']);
+ $this->assertSame($expected, $cookie['Domain']);
+ $this->assertSame($domain === null, $cookie['HostOnly'] ?? false);
+ $this->assertSame($scheme === 'https', $cookie['Secure']);
+ $this->assertTrue($cookie['Discard']);
}
/**
@@ -46,7 +51,12 @@ public function testCookieDomainIsInferredOrExplicit(?string $domain, string $ex
*/
public static function cookieDomains(): array
{
- return [[null, 'api.example.com'], ['.example.com', '.example.com']];
+ 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 testAnEmptyExplicitCookieDomainIsRejected(): void
@@ -80,10 +90,10 @@ public function testReplacementAuthenticationReachesTheTransportWithoutAccumulat
->authenticate(new CookieAuthenticator('session', 'original'))
->authenticate(new CookieAuthenticator('session', 'request'))
->retry(2);
- $groups = [];
- $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$groups): void {
+ $pendingCookies = [];
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void {
$pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement'));
- $groups[] = $pendingRequest->cookies();
+ $pendingCookies[] = $pendingRequest->cookies();
});
$response = $manager->send(new CookieAuthConnectorStub, $request);
@@ -95,21 +105,31 @@ public function testReplacementAuthenticationReachesTheTransportWithoutAccumulat
$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($groups[0], $groups[1]);
- $this->assertCount(2, $groups[0]);
+ $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 'https://api.example.com';
+ return $this->baseUrl;
}
}
diff --git a/tests/Saloon/Http/RequestTest.php b/tests/Saloon/Http/RequestTest.php
index a309878c3..2f335ffaf 100644
--- a/tests/Saloon/Http/RequestTest.php
+++ b/tests/Saloon/Http/RequestTest.php
@@ -5,11 +5,14 @@
namespace Hypervel\Tests\Saloon\Http;
use ArgumentCountError;
+use GuzzleHttp\Cookie\CookieJar;
+use GuzzleHttp\Cookie\SetCookie;
use Hypervel\Container\Container;
use Hypervel\Saloon\Cache\Traits\HasCaching;
use Hypervel\Saloon\Enums\Method;
use Hypervel\Saloon\Http\Request;
use Hypervel\Tests\TestCase;
+use InvalidArgumentException;
class RequestTest extends TestCase
{
@@ -93,6 +96,14 @@ public function testAcceptReplacesTheExistingAcceptHeader(): void
$this->assertSame('application/json', $request->headers()['Accept']);
}
+ public function testWithCookieRejectsADomainlessCookie(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('An outgoing cookie must have a domain.');
+
+ (new ContainerRequestStub)->withCookie(new SetCookie(['Name' => 'locale', 'Value' => 'en']));
+ }
+
public function testCloneOwnsIndependentInitializedRequestState(): void
{
$request = (new ContainerRequestStub)
@@ -126,9 +137,7 @@ public function testCloneOwnsIndependentInitializedRequestState(): void
$this->assertSame(10, $request->delayMilliseconds());
$this->assertCount(1, $request->middleware()->requestPipeline()->pipes());
$this->assertSame(['original' => true], $request->body());
- $this->assertSame([
- ['cookies' => ['original' => 'yes'], 'domain' => '.example.test'],
- ], $request->cookies());
+ $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());
diff --git a/tests/Saloon/Pagination/PaginatorTest.php b/tests/Saloon/Pagination/PaginatorTest.php
index 110084470..6ba7e96a3 100644
--- a/tests/Saloon/Pagination/PaginatorTest.php
+++ b/tests/Saloon/Pagination/PaginatorTest.php
@@ -42,6 +42,91 @@
class PaginatorTest extends TestCase
{
+ #[DataProvider('currentPageLoads')]
+ public function testCurrentLoadsEachPageOnceAndRetriesFailedMapping(string $class, array $queries, bool $failMapping): void
+ {
+ $mappingCalls = 0;
+ $failure = new RuntimeException('Cannot map the first page.');
+ $request = new class(static function (Response $response) use (&$mappingCalls, $failMapping, $failure): array {
+ if (++$mappingCalls === 1 && $failMapping) {
+ throw $failure;
+ }
+
+ return $response->json('data');
+ }) extends PagedRequestStub implements MapPaginatedResponseItems {
+ /**
+ * Share mapping observations across request clones.
+ */
+ public function __construct(public Closure $mapper)
+ {
+ }
+
+ /**
+ * Map the page through the test callback.
+ */
+ public function mapPaginatedResponseItems(Response $response): array
+ {
+ return ($this->mapper)($response);
+ }
+ };
+ $requestedQueries = [];
+ $manager = $this->manager();
+ $manager->fake([$request::class => static function (PendingRequest $pendingRequest) use (&$requestedQueries, $queries): MockResponse {
+ $query = $pendingRequest->uri()->getQuery();
+ $requestedQueries[] = $query;
+ $page = $query === $queries[0] ? 1 : 2;
+
+ return MockResponse::make([
+ 'data' => [$page], 'page' => $page, 'pages' => 2, 'next' => $page === 1 ? '2' : null,
+ ], headers: $page === 1 ? ['Link' => '; rel=next'] : []);
+ }]);
+ $paginator = new $class(new PaginationConnectorStub($manager), $request);
+
+ if ($failMapping) {
+ $caught = null;
+ try {
+ $paginator->current();
+ } catch (RuntimeException $exception) {
+ $caught = $exception;
+ }
+ $this->assertSame($failure, $caught);
+ $this->assertSame(0, $paginator->totalResults());
+ }
+
+ $first = $paginator->current();
+ $this->assertSame($first, $paginator->current());
+ $this->assertSame(0, $paginator->key());
+ $this->assertSame(1, $paginator->totalResults());
+ $this->assertSame($failMapping ? 2 : 1, $mappingCalls);
+ $this->assertSame($failMapping ? [$queries[0], $queries[0]] : [$queries[0]], $requestedQueries);
+
+ $paginator->next();
+ $second = $paginator->current();
+ $this->assertSame($second, $paginator->current());
+ $this->assertSame([2], $second->json('data'));
+ $this->assertSame(1, $paginator->key());
+ $this->assertSame(2, $paginator->totalResults());
+ $this->assertSame([1, 2], iterator_to_array($paginator->items(), false));
+ $this->assertSame(2, $paginator->totalResults());
+ $this->assertSame($failMapping ? 5 : 4, $mappingCalls);
+ $this->assertSame($failMapping ? [$queries[0], ...$queries, ...$queries] : [...$queries, ...$queries], $requestedQueries);
+ }
+
+ /**
+ * Provide paginator strategies with successful and failed first-page mapping.
+ */
+ public static function currentPageLoads(): iterable
+ {
+ foreach ([
+ 'paged' => [PagedPaginatorStub::class, ['page=1', 'page=2']],
+ 'cursor' => [CursorPaginatorStub::class, ['', 'cursor=2']],
+ 'link' => [LinkPaginatorStub::class, ['page=1', 'page=2']],
+ ] as $name => [$class, $queries]) {
+ yield $name => [$class, $queries, false];
+ yield $name . ' mapping retry' => [$class, $queries, true];
+ }
+ }
+
public function testPagedPaginatorIteratesItemsAndResetsEveryStateOnRewind(): void
{
$manager = $this->manager();
@@ -331,6 +416,7 @@ public function testPooledMappingReleasesItemsBeforePageCountsAndCallbacks(): vo
sort($handled);
$this->assertSame([0, 1, 2], $handled);
+ sort($paginator->mappedPages);
$this->assertSame([1, 2, 3], $paginator->mappedPages);
$this->assertSame(3, $paginator->totalResults());
}
@@ -454,6 +540,7 @@ public function testPoolCountsMappedPagesEvenWhenCallerHandlersFail(bool $mapper
$this->assertSame($mapperFails ? 2 : 3, $paginator->totalResults());
sort($handled);
$this->assertSame($mapperFails ? [0, 2] : [0, 1, 2], $handled);
+ sort($paginator->mappedPages);
$this->assertSame([1, 2, 3], $paginator->mappedPages);
}
@@ -684,6 +771,7 @@ public function testLinkPoolUsesConfiguredNumberedRequestsAndTheDeclaredLastPage
$responses = $paginator->pool();
$this->assertSame([0, 1, 2], array_keys($responses));
+ usort($queries, static fn (array $first, array $second): int => $first['number'] <=> $second['number']);
$this->assertSame([['number' => 2, 'size' => 5], ['number' => 3, 'size' => 5], ['number' => 4, 'size' => 5]], $queries);
$this->assertSame(3, $paginator->totalResults());
}
From ccfc3e80fba29f32d479988b5c1911663b73d05e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 16:13:23 +0000
Subject: [PATCH 6/8] Validate outgoing cookies before selecting a response
path
Use SetCookie validation in the HTTP and Saloon withCookie methods, alongside the required-domain check. Invalid domains, names, and null values now fail explicitly instead of being silently discarded by the transport while remaining visible to Saloon fakes or cache keys.
Keep the existing withCookies behavior and native cookie insertion rules. Remove the duplicate CookieAuthenticator constructor check so authentication uses the same validation as other outgoing cookies.
Extend the existing setter tests with missing-domain, empty-domain, and null-value cases. Remove the obsolete constructor test and an incorrect cookie-array annotation, and document the connection configuration getter.
Verified formatting, source and type-fixture analysis, and the affected HTTP and Saloon tests.
---
src/http/src/Client/PendingRequest.php | 7 +++++
.../src/Http/Auth/CookieAuthenticator.php | 4 ---
.../Traits/RequestProperties/HasCookies.php | 4 +++
tests/Http/HttpClientTest.php | 29 ++++++++++++++++---
tests/Saloon/Http/AuthenticationTest.php | 9 ------
tests/Saloon/Http/RequestTest.php | 29 +++++++++++++++++--
6 files changed, 62 insertions(+), 20 deletions(-)
diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 874fe2a42..bb287b7fc 100644
--- a/src/http/src/Client/PendingRequest.php
+++ b/src/http/src/Client/PendingRequest.php
@@ -509,6 +509,10 @@ public function withCookie(SetCookie $cookie): static
throw new InvalidArgumentException('An outgoing cookie must have a domain.');
}
+ if (($error = $cookie->validate()) !== true) {
+ throw new InvalidArgumentException('Invalid cookie: ' . $error);
+ }
+
$this->cookies->setCookie(clone $cookie);
return $this;
@@ -2152,6 +2156,9 @@ public function getConnection(): ?string
return $this->connection;
}
+ /**
+ * Get the pending request connection configuration.
+ */
public function getConnectionConfig(): ?array
{
return $this->connectionConfig;
diff --git a/src/saloon/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php
index 2476c418c..4ce608833 100644
--- a/src/saloon/src/Http/Auth/CookieAuthenticator.php
+++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php
@@ -7,7 +7,6 @@
use GuzzleHttp\Cookie\SetCookie;
use Hypervel\Saloon\Contracts\Authenticator;
use Hypervel\Saloon\Http\PendingRequest;
-use InvalidArgumentException;
use SensitiveParameter;
readonly class CookieAuthenticator implements Authenticator
@@ -21,9 +20,6 @@ public function __construct(
public string $value,
public ?string $domain = null,
) {
- if ($domain === '') {
- throw new InvalidArgumentException('The cookie domain cannot be empty.');
- }
}
/**
diff --git a/src/saloon/src/Traits/RequestProperties/HasCookies.php b/src/saloon/src/Traits/RequestProperties/HasCookies.php
index 8760e22af..5cc61820a 100644
--- a/src/saloon/src/Traits/RequestProperties/HasCookies.php
+++ b/src/saloon/src/Traits/RequestProperties/HasCookies.php
@@ -28,6 +28,10 @@ public function withCookie(SetCookie $cookie): static
throw new InvalidArgumentException('An outgoing cookie must have a domain.');
}
+ if (($error = $cookie->validate()) !== true) {
+ throw new InvalidArgumentException('Invalid cookie: ' . $error);
+ }
+
$this->cookies[] = $cookie->toArray();
return $this;
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index 42d970c6b..2310a8988 100644
--- a/tests/Http/HttpClientTest.php
+++ b/tests/Http/HttpClientTest.php
@@ -2205,7 +2205,6 @@ public function testWithCookies(): void
$this->assertCount(1, $response->cookies()->toArray());
- /** @var \GuzzleHttp\Cookie\CookieJarInterface $responseCookies */
$responseCookie = $response->cookies()->toArray()[0];
$this->assertSame('foo', $responseCookie['Name']);
@@ -2229,12 +2228,34 @@ public function testWithCookiePreservesAttributesAndSnapshotsTheInput(): void
$this->assertSame([$expected], $response->cookies()->toArray());
}
- public function testWithCookieRejectsADomainlessCookie(): void
+ #[DataProvider('invalidCookies')]
+ public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('An outgoing cookie must have a domain.');
+ $this->expectExceptionMessage($message);
- $this->factory->withCookie(new SetCookie(['Name' => 'session', 'Value' => 'secret']));
+ $this->factory->withCookie(new SetCookie($cookie));
+ }
+
+ /**
+ * Provide cookies that cannot be sent with a request.
+ */
+ public static function invalidCookies(): array
+ {
+ return [
+ 'null domain' => [
+ ['Name' => 'session', 'Value' => 'secret'],
+ 'An outgoing cookie must have a domain.',
+ ],
+ 'empty domain' => [
+ ['Name' => 'session', 'Value' => 'secret', 'Domain' => ''],
+ 'Invalid cookie: The cookie domain must not be empty',
+ ],
+ 'null value' => [
+ ['Name' => 'session', 'Domain' => 'api.example.com'],
+ 'Invalid cookie: The cookie value must not be empty',
+ ],
+ ];
}
public function testWithQueryParameters(): void
diff --git a/tests/Saloon/Http/AuthenticationTest.php b/tests/Saloon/Http/AuthenticationTest.php
index f2d383ec6..372e8394c 100644
--- a/tests/Saloon/Http/AuthenticationTest.php
+++ b/tests/Saloon/Http/AuthenticationTest.php
@@ -18,7 +18,6 @@
use Hypervel\Saloon\Http\Sender;
use Hypervel\Saloon\SaloonManager;
use Hypervel\Tests\TestCase;
-use InvalidArgumentException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -59,14 +58,6 @@ public static function cookieDomains(): array
];
}
- public function testAnEmptyExplicitCookieDomainIsRejected(): void
- {
- $this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('The cookie domain cannot be empty.');
-
- new CookieAuthenticator('session', 'secret', '');
- }
-
public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void
{
$http = new Factory;
diff --git a/tests/Saloon/Http/RequestTest.php b/tests/Saloon/Http/RequestTest.php
index 2f335ffaf..04de2e137 100644
--- a/tests/Saloon/Http/RequestTest.php
+++ b/tests/Saloon/Http/RequestTest.php
@@ -13,6 +13,7 @@
use Hypervel\Saloon\Http\Request;
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
+use PHPUnit\Framework\Attributes\DataProvider;
class RequestTest extends TestCase
{
@@ -96,12 +97,34 @@ public function testAcceptReplacesTheExistingAcceptHeader(): void
$this->assertSame('application/json', $request->headers()['Accept']);
}
- public function testWithCookieRejectsADomainlessCookie(): void
+ #[DataProvider('invalidCookies')]
+ public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
{
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('An outgoing cookie must have a domain.');
+ $this->expectExceptionMessage($message);
- (new ContainerRequestStub)->withCookie(new SetCookie(['Name' => 'locale', 'Value' => 'en']));
+ (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
From 5dad47e80319d1f79049ad89cb494dba915be158 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 16:13:42 +0000
Subject: [PATCH 7/8] Count distinct pages when detecting pagination loops
Key response checksums by iterator position so retrying a failed item mapper replaces the current page observation rather than consuming another slot in the loop detector. Preserve position keys when removing the oldest checksum, and retain the existing middleware ordering and pooled-request bypass.
This allows a mapper to succeed after repeated attempts at the same page without being blocked by the identical-response heuristic. Sequential requests across distinct pages still stop when the last five page bodies are identical.
Strengthen the existing page, cursor, and Link retry cases to cover four mapping failures followed by success. Keep the distinct-page loop regression and document the existing detectInfiniteLoop override beside the other paginator settings.
Verified formatting, source and type-fixture analysis, and the affected Saloon tests.
---
src/docs/saloon.md | 2 ++
src/saloon/src/Pagination/Paginator.php | 9 +++++----
tests/Saloon/Pagination/PaginatorTest.php | 20 ++++++++++----------
3 files changed, 17 insertions(+), 14 deletions(-)
diff --git a/src/docs/saloon.md b/src/docs/saloon.md
index c0fd9bed6..30e395497 100644
--- a/src/docs/saloon.md
+++ b/src/docs/saloon.md
@@ -1585,6 +1585,8 @@ 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.
+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.
+
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/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php
index 5761fbe2c..357347070 100644
--- a/src/saloon/src/Pagination/Paginator.php
+++ b/src/saloon/src/Pagination/Paginator.php
@@ -87,7 +87,7 @@ abstract class Paginator implements Countable, Iterator
/**
* The latest response body checksums.
*
- * @var list
+ * @var array
*/
protected array $lastFiveBodyChecksums = [];
@@ -116,7 +116,8 @@ public function __construct(
return;
}
- $this->lastFiveBodyChecksums[] = hash('xxh128', $response->body());
+ // 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;
@@ -124,11 +125,11 @@ public function __construct(
if (count(array_unique($this->lastFiveBodyChecksums)) === 1) {
throw new PaginationException(
- 'Potential infinite loop detected because the last five responses had the same body.',
+ 'Potential infinite loop detected because the last five pages had the same body.',
);
}
- array_shift($this->lastFiveBodyChecksums);
+ unset($this->lastFiveBodyChecksums[array_key_first($this->lastFiveBodyChecksums)]);
});
}
diff --git a/tests/Saloon/Pagination/PaginatorTest.php b/tests/Saloon/Pagination/PaginatorTest.php
index 6ba7e96a3..7b62c6290 100644
--- a/tests/Saloon/Pagination/PaginatorTest.php
+++ b/tests/Saloon/Pagination/PaginatorTest.php
@@ -43,12 +43,12 @@
class PaginatorTest extends TestCase
{
#[DataProvider('currentPageLoads')]
- public function testCurrentLoadsEachPageOnceAndRetriesFailedMapping(string $class, array $queries, bool $failMapping): void
+ public function testCurrentLoadsEachPageOnceAndRetriesFailedMapping(string $class, array $queries, int $mappingFailures): void
{
$mappingCalls = 0;
$failure = new RuntimeException('Cannot map the first page.');
- $request = new class(static function (Response $response) use (&$mappingCalls, $failMapping, $failure): array {
- if (++$mappingCalls === 1 && $failMapping) {
+ $request = new class(static function (Response $response) use (&$mappingCalls, $mappingFailures, $failure): array {
+ if (++$mappingCalls <= $mappingFailures) {
throw $failure;
}
@@ -82,7 +82,7 @@ public function mapPaginatedResponseItems(Response $response): array
}]);
$paginator = new $class(new PaginationConnectorStub($manager), $request);
- if ($failMapping) {
+ for ($attempt = 0; $attempt < $mappingFailures; ++$attempt) {
$caught = null;
try {
$paginator->current();
@@ -97,8 +97,8 @@ public function mapPaginatedResponseItems(Response $response): array
$this->assertSame($first, $paginator->current());
$this->assertSame(0, $paginator->key());
$this->assertSame(1, $paginator->totalResults());
- $this->assertSame($failMapping ? 2 : 1, $mappingCalls);
- $this->assertSame($failMapping ? [$queries[0], $queries[0]] : [$queries[0]], $requestedQueries);
+ $this->assertSame($mappingFailures + 1, $mappingCalls);
+ $this->assertSame(array_fill(0, $mappingFailures + 1, $queries[0]), $requestedQueries);
$paginator->next();
$second = $paginator->current();
@@ -108,8 +108,8 @@ public function mapPaginatedResponseItems(Response $response): array
$this->assertSame(2, $paginator->totalResults());
$this->assertSame([1, 2], iterator_to_array($paginator->items(), false));
$this->assertSame(2, $paginator->totalResults());
- $this->assertSame($failMapping ? 5 : 4, $mappingCalls);
- $this->assertSame($failMapping ? [$queries[0], ...$queries, ...$queries] : [...$queries, ...$queries], $requestedQueries);
+ $this->assertSame($mappingFailures + 4, $mappingCalls);
+ $this->assertSame([...array_fill(0, $mappingFailures, $queries[0]), ...$queries, ...$queries], $requestedQueries);
}
/**
@@ -122,8 +122,8 @@ public static function currentPageLoads(): iterable
'cursor' => [CursorPaginatorStub::class, ['', 'cursor=2']],
'link' => [LinkPaginatorStub::class, ['page=1', 'page=2']],
] as $name => [$class, $queries]) {
- yield $name => [$class, $queries, false];
- yield $name . ' mapping retry' => [$class, $queries, true];
+ yield $name => [$class, $queries, 0];
+ yield $name . ' mapping retry' => [$class, $queries, 4];
}
}
From 14d60848828e16c24be97f200c4daf5739813a38 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 12 Sep 2026 16:13:43 +0000
Subject: [PATCH 8/8] Wait for response headers before releasing the streaming
fixture
Coordinate the coroutine-progress test with a bounded readiness channel after the reader receives response headers. The fixture now has an established reader connection before the release coroutine connects, without relying on a fixed sleep to choose connection order.
Retain the short delay before release so the reader can wait for body data. Assert the final record directly: the fixture sends it only after the other coroutine runs, making the separate progress flag and reference captures unnecessary.
Keep response, channel, and process cleanup intact. Production streaming behavior and the existing upstream-version regression skips are unchanged.
Verified the edited test file, formatting, source and type-fixture analysis, and the affected HTTP suite.
---
tests/Http/HttpClientStreamingTest.php | 44 ++++++++++++++------------
1 file changed, 24 insertions(+), 20 deletions(-)
diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php
index 2bf6ab8b3..ef3cc03f0 100644
--- a/tests/Http/HttpClientStreamingTest.php
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -51,26 +51,30 @@ public static function handlerModes(): array
public function testStreamingReadsAllowOtherCoroutinesToProgress(): void
{
$this->withStreamingServer('delayed', function (string $address): void {
- $progress = false;
- $results = parallel([
- 'reader' => function () use ($address, &$progress): array {
- $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])->get('http://' . $address);
- try {
- $items = iterator_to_array($response->jsonLines());
-
- return [$items, $progress];
- } finally {
- $response->close();
- }
- },
- 'release' => function () use ($address, &$progress): void {
- usleep(10000);
- $progress = true;
- $this->releaseServer($address);
- },
- ]);
-
- $this->assertSame([[['id' => 2]], true], $results['reader']);
+ $ready = new Channel(1);
+ try {
+ $results = parallel([
+ 'reader' => function () use ($address, $ready): array {
+ $response = (new Factory)->withOptions(['stream' => true, 'read_timeout' => 3])->get('http://' . $address);
+ try {
+ $ready->push(true);
+
+ return iterator_to_array($response->jsonLines());
+ } finally {
+ $response->close();
+ }
+ },
+ 'release' => function () use ($address, $ready): void {
+ $this->assertTrue($ready->pop(1), 'The streaming response headers did not arrive.');
+ usleep(10000);
+ $this->releaseServer($address);
+ },
+ ]);
+
+ $this->assertSame([['id' => 2]], $results['reader']);
+ } finally {
+ $ready->close();
+ }
});
}