diff --git a/composer.json b/composer.json
index 23f481511f..957ebb83c1 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/api-client/src/PendingRequest.php b/src/api-client/src/PendingRequest.php
index 7e891e52c1..dc5567a767 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 2b638ea746..4d024bb8b6 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
@@ -325,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
@@ -906,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 2f31417deb..30e3954974 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 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:
@@ -722,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`.
@@ -912,6 +944,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 +1519,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
@@ -1501,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) {
@@ -1530,28 +1569,25 @@ 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. 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.
+
+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.
### Offset and Cursor Pagination
@@ -1560,6 +1596,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 header syntax or conflicting pagination links throw a `PaginationException`; invalid or contradictory last-page numbers are rejected when pooling.
+
### Pooled Pagination
@@ -1576,6 +1638,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 +2084,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/foundation/composer.json b/src/foundation/composer.json
index c0989bf40f..728767c848 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/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php
index 177b6bb669..bb287b7fcc 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,24 @@ 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.');
+ }
+
+ if (($error = $cookie->validate()) !== true) {
+ throw new InvalidArgumentException('Invalid cookie: ' . $error);
+ }
+
+ $this->cookies->setCookie(clone $cookie);
+
+ return $this;
+ }
+
/**
* Specify the cookies that should be included with the request.
*/
@@ -1594,7 +1613,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;
}
/**
@@ -2124,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/http/src/Client/ReservedOptions.php b/src/http/src/Client/ReservedOptions.php
index eacb0e6f4d..e0ab2ab4e0 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/http/src/Client/Response.php b/src/http/src/Client/Response.php
index 826eebc49f..067b84e9ea 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/src/saloon/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php
new file mode 100644
index 0000000000..4ce6088338
--- /dev/null
+++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php
@@ -0,0 +1,41 @@
+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/BaseResource.php b/src/saloon/src/Http/BaseResource.php
new file mode 100644
index 0000000000..4a3ca96f21
--- /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 079283b0a5..0a8162587f 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(),
@@ -142,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();
}
@@ -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 64385374b8..3dfec921c1 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/Http/Sender.php b/src/saloon/src/Http/Sender.php
index 8fd295da5e..1c9945498e 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/Contracts/HasPagination.php b/src/saloon/src/Pagination/Contracts/HasPagination.php
index 8c52a779c6..e86e711c69 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 5bf050b65c..3a7a2ad30a 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 789eb8f919..efad8284b6 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 94aebeb97b..b827a81919 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 0000000000..ddd9595090
--- /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 0cb2564f7c..212ff767bf 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 b7138081f2..46339acbcd 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 0362a08488..3573470708 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,23 @@ abstract class Paginator implements Countable, Iterator
/**
* The current response.
+ *
+ * @var null|Response
*/
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.
+ *
+ * @var array
+ */
+ protected array $currentPageItems = [];
+
/**
* The total number of mapped results processed.
*/
@@ -68,12 +87,15 @@ abstract class Paginator implements Countable, Iterator
/**
* The latest response body checksums.
*
- * @var list
+ * @var array
*/
protected array $lastFiveBodyChecksums = [];
/**
* Create a paginator.
+ *
+ * @param Connector $connector
+ * @param Request $request
*/
public function __construct(
protected Connector $connector,
@@ -89,15 +111,13 @@ 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;
}
- $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;
@@ -105,22 +125,35 @@ 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)]);
});
}
/**
* Get the response for the current page.
+ *
+ * @return Response
*/
public function current(): Response
{
+ if ($this->currentPageLoaded && $this->currentResponse !== null) {
+ return $this->currentResponse;
+ }
+
$request = $this->applyPagination(clone $this->request);
- return $this->currentResponse = $this->connector->send($request);
+ $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;
}
/**
@@ -128,6 +161,8 @@ public function current(): Response
*/
public function next(): void
{
+ $this->currentPageLoaded = false;
+ $this->currentPageItems = [];
++$this->pageNumber;
++$this->currentPage;
}
@@ -160,6 +195,8 @@ public function rewind(): void
$this->pageNumber = $this->startPage;
$this->currentPage = 0;
$this->currentResponse = null;
+ $this->currentPageLoaded = false;
+ $this->currentPageItems = [];
$this->totalResults = 0;
$this->lastFiveBodyChecksums = [];
$this->onRewind();
@@ -175,12 +212,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 +225,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 +238,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 +258,7 @@ public function pool(
try {
$firstKey = $this->key();
$firstResponse = $this->current();
+ $this->currentPageItems = [];
$totalPages = $this->getTotalPages($firstResponse);
$lastPage = $this->maxPages === null
? $totalPages
@@ -228,6 +268,8 @@ public function pool(
if ($responseHandler !== null) {
try {
$responseHandler($firstResponse, $firstKey);
+ } catch (CanceledException $exception) {
+ throw $exception;
} catch (Throwable $exception) {
$initialCallbackFailure = $exception;
}
@@ -236,7 +278,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 +350,8 @@ public function perPageLimit(?int $perPageLimit): static
/**
* Get the cloned request used by this paginator.
+ *
+ * @return Request
*/
public function request(): Request
{
@@ -347,21 +397,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 +429,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 +439,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/HasCookies.php b/src/saloon/src/Traits/RequestProperties/HasCookies.php
index f42a6fb8d9..5cc61820ab 100644
--- a/src/saloon/src/Traits/RequestProperties/HasCookies.php
+++ b/src/saloon/src/Traits/RequestProperties/HasCookies.php
@@ -4,14 +4,38 @@
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.');
+ }
+
+ if (($error = $cookie->validate()) !== true) {
+ throw new InvalidArgumentException('Invalid cookie: ' . $error);
+ }
+
+ $this->cookies[] = $cookie->toArray();
+
+ return $this;
+ }
/**
* Specify cookies that should be included with the request.
@@ -21,18 +45,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/saloon/src/Traits/RequestProperties/HasQuery.php b/src/saloon/src/Traits/RequestProperties/HasQuery.php
index 4e13a19546..09ede6c2b0 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/src/scout/composer.json b/src/scout/composer.json
index a82f3566c2..cc235a8361 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)"
},
diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php
index dab43262dc..5da1516960 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/Fixtures/streaming-handler.php b/tests/Http/Fixtures/streaming-handler.php
new file mode 100644
index 0000000000..dd4fa20c6a
--- /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 0000000000..0a9b773334
--- /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 0000000000..ef3cc03f00
--- /dev/null
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -0,0 +1,204 @@
+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 {
+ $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();
+ }
+ });
+ }
+
+ public function testBufferedFirstRecordArrivesBeforeTheNextServerWrite(): void
+ {
+ 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);
+ 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
+ {
+ 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);
+ 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 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);
+ }
+}
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index 113b497920..2310a8988a 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;
@@ -2204,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']);
@@ -2212,6 +2212,52 @@ 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());
+ }
+
+ #[DataProvider('invalidCookies')]
+ public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ $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
{
$this->factory->fake();
diff --git a/tests/Saloon/Cache/CacheTest.php b/tests/Saloon/Cache/CacheTest.php
index 0f2a8d147f..d90466e344 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 6e8c139568..ca821fb656 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
new file mode 100644
index 0000000000..372e8394c5
--- /dev/null
+++ b/tests/Saloon/Http/AuthenticationTest.php
@@ -0,0 +1,138 @@
+authenticate(new CookieAuthenticator('session', 'secret', $domain)),
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ );
+
+ $pendingRequest->applyAuthentication();
+
+ $this->assertCount(1, $pendingRequest->cookies());
+ $cookie = $pendingRequest->cookies()[0];
+ $this->assertSame('session', $cookie['Name']);
+ $this->assertSame('secret', $cookie['Value']);
+ $this->assertSame($expected, $cookie['Domain']);
+ $this->assertSame($domain === null, $cookie['HostOnly'] ?? false);
+ $this->assertSame($scheme === 'https', $cookie['Secure']);
+ $this->assertTrue($cookie['Discard']);
+ }
+
+ /**
+ * Provide inferred and explicitly scoped cookie domains.
+ */
+ public static function cookieDomains(): array
+ {
+ return [
+ 'inferred HTTPS' => [null, 'api.example.com', 'https'],
+ 'explicit HTTPS' => ['.example.com', '.example.com', 'https'],
+ 'inferred HTTP' => [null, 'api.example.com', 'http'],
+ 'explicit HTTP' => ['.example.com', '.example.com', 'http'],
+ ];
+ }
+
+ public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void
+ {
+ $http = new Factory;
+ $http->registerConnection('saloon');
+ $cookies = [];
+ $http->fake(function (HttpRequest $request, array $options) use (&$cookies) {
+ $cookies[] = $options['cookies']->toArray();
+
+ return Factory::response('', count($cookies) === 1 ? 500 : 200);
+ });
+ $config = m::mock(ConfigRepository::class);
+ $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon');
+ $manager = new SaloonManager(
+ new Sender($http, $config),
+ m::mock(CacheFactory::class),
+ m::mock(RateLimiter::class),
+ $config,
+ new Dispatcher,
+ );
+ $request = (new CookieAuthRequestStub)
+ ->authenticate(new CookieAuthenticator('session', 'original'))
+ ->authenticate(new CookieAuthenticator('session', 'request'))
+ ->retry(2);
+ $pendingCookies = [];
+ $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void {
+ $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement'));
+ $pendingCookies[] = $pendingRequest->cookies();
+ });
+
+ $response = $manager->send(new CookieAuthConnectorStub, $request);
+
+ $this->assertSame(200, $response->status());
+ $this->assertCount(2, $cookies);
+ foreach ($cookies as $attemptCookies) {
+ $this->assertCount(1, $attemptCookies);
+ $this->assertSame('session', $attemptCookies[0]['Name']);
+ $this->assertSame('replacement', $attemptCookies[0]['Value']);
+ $this->assertSame('api.example.com', $attemptCookies[0]['Domain']);
+ $this->assertTrue($attemptCookies[0]['HostOnly']);
+ $this->assertTrue($attemptCookies[0]['Secure']);
+ $this->assertTrue($attemptCookies[0]['Discard']);
+ }
+ $this->assertSame($pendingCookies[0], $pendingCookies[1]);
+ $this->assertCount(2, $pendingCookies[0]);
+ $this->assertSame([], $request->cookies());
+ }
+}
+
+class CookieAuthConnectorStub extends Connector
+{
+ /**
+ * Set the API base URL.
+ */
+ public function __construct(protected string $baseUrl = 'https://api.example.com')
+ {
+ }
+
+ /**
+ * Resolve the API base URL.
+ */
+ public function resolveBaseUrl(): string
+ {
+ return $this->baseUrl;
+ }
+}
+
+class CookieAuthRequestStub extends Request
+{
+ protected Method $method = Method::GET;
+
+ /**
+ * Resolve the request endpoint.
+ */
+ public function resolveEndpoint(): string
+ {
+ return '/users';
+ }
+}
diff --git a/tests/Saloon/Http/PendingRequestTest.php b/tests/Saloon/Http/PendingRequestTest.php
index 56c4f2ce83..f656a157ff 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 7810626da7..04de2e137a 100644
--- a/tests/Saloon/Http/RequestTest.php
+++ b/tests/Saloon/Http/RequestTest.php
@@ -5,11 +5,15 @@
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;
+use PHPUnit\Framework\Attributes\DataProvider;
class RequestTest extends TestCase
{
@@ -93,6 +97,36 @@ public function testAcceptReplacesTheExistingAcceptHeader(): void
$this->assertSame('application/json', $request->headers()['Accept']);
}
+ #[DataProvider('invalidCookies')]
+ public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ (new ContainerRequestStub)->withCookie(new SetCookie($cookie));
+ }
+
+ /**
+ * Provide cookies that cannot be sent with a request.
+ */
+ public static function invalidCookies(): array
+ {
+ return [
+ 'null domain' => [
+ ['Name' => 'locale', 'Value' => 'en'],
+ 'An outgoing cookie must have a domain.',
+ ],
+ 'empty domain' => [
+ ['Name' => 'locale', 'Value' => 'en', 'Domain' => ''],
+ 'Invalid cookie: The cookie domain must not be empty',
+ ],
+ 'null value' => [
+ ['Name' => 'locale', 'Domain' => 'api.example.com'],
+ 'Invalid cookie: The cookie value must not be empty',
+ ],
+ ];
+ }
+
public function testCloneOwnsIndependentInitializedRequestState(): void
{
$request = (new ContainerRequestStub)
@@ -126,9 +160,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());
@@ -139,6 +171,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 +199,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 4e550ded61..afca8dbca8 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 2189653b2c..7b62c62906 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,9 +34,99 @@
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
{
+ #[DataProvider('currentPageLoads')]
+ 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, $mappingFailures, $failure): array {
+ if (++$mappingCalls <= $mappingFailures) {
+ 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);
+
+ for ($attempt = 0; $attempt < $mappingFailures; ++$attempt) {
+ $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($mappingFailures + 1, $mappingCalls);
+ $this->assertSame(array_fill(0, $mappingFailures + 1, $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($mappingFailures + 4, $mappingCalls);
+ $this->assertSame([...array_fill(0, $mappingFailures, $queries[0]), ...$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, 0];
+ yield $name . ' mapping retry' => [$class, $queries, 4];
+ }
+ }
+
public function testPagedPaginatorIteratesItemsAndResetsEveryStateOnRewind(): void
{
$manager = $this->manager();
@@ -228,6 +323,503 @@ 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);
+ sort($paginator->mappedPages);
+ $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);
+ sort($paginator->mappedPages);
+ $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));
+ 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());
+ }
+
+ #[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 +944,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 +1023,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 +1092,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 beb3ea66ab..c47ee68768 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 0000000000..00a904d341
--- /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 dcd89d23c3..1da62051db 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);