Skip to content
Merged
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/api-client/src/PendingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
46 changes: 45 additions & 1 deletion src/docs/http-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- [Introduction](#introduction)
- [Making Requests](#making-requests)
- [Streaming Responses](#streaming-responses)
- [Request Data](#request-data)
- [QUERY Requests](#query-requests)
- [Headers](#headers)
Expand Down Expand Up @@ -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');
```

<a name="streaming-responses"></a>
### 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.

<a name="request-data"></a>
### Request Data

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
109 changes: 86 additions & 23 deletions src/docs/saloon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -245,18 +246,15 @@ public function boot(PendingRequest $pendingRequest): void
<a name="organizing-sdks"></a>
### 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<GitHubConnector> */
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));
Expand All @@ -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.

<a name="http-connections"></a>
### HTTP Connections
Expand Down Expand Up @@ -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.

<a name="authentication"></a>
### Authentication

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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`.

<a name="middleware"></a>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1483,6 +1519,7 @@ class ListUsers extends Request implements Paginatable
// Define the request method and endpoint...
}

/** @extends PagedPaginator<array<string, mixed>> */
class GitHubPaginator extends PagedPaginator
{
protected function isLastPage(Response $response): bool
Expand All @@ -1501,10 +1538,12 @@ class GitHubPaginator extends PagedPaginator
}
}

/** @implements HasPagination<array<string, mixed>> */
class GitHubConnector extends Connector implements HasPagination
{
// Define the connector base URL and defaults...

/** @return Paginator<array<string, mixed>> */
public function paginate(Request $request): Paginator
{
if ($request instanceof HasRequestPagination) {
Expand All @@ -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<UserData>` and `@return Paginator<UserData>` 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<UserData>` (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<UserData>` with the same item type as its paginator. Mapping runs once per fetched page, after all response middleware, and `totalResults` counts these final items.

<a name="offset-and-cursor-pagination"></a>
### Offset and Cursor Pagination
Expand All @@ -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.

<a name="link-header-pagination"></a>
### 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<array<string, mixed>> */
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.

<a name="pooled-pagination"></a>
### Pooled Pagination

Expand All @@ -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.

<a name="rate-limiting"></a>
## Rate Limiting

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/foundation/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
Loading
Loading