diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 511f7690dc..60fd2ca3ba 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -156,14 +156,14 @@ spatie/laravel-data: checked_through: ce296f22861dc1237ce468754cc7f46d3ac34ad5 last_reviewed_pr: null sync_date: '2026-10-03' - notes: Hypervel replaces upstream's data pipes and resolvers with a fixed engine. The pipes and DataFromSomethingResolver map to Support/Creation/DataCreator and its per-operation ConstructionState; the validation resolvers and built-in rule inferrers map to Support/Validation/DataValidationCompiler and DataValidator; TransformedDataResolver and VisibleDataFieldsResolver map to Support/Transformation/DataTransformer and PartialTree. DataCreator's direct construction from DataClass creation recipes and DataTransformer's bulk copy are deliberate fast paths, so apply upstream behavior changes to them as well as to the general paths, and compare tests/Benchmarks/Data/benchmark.php results before and after. Upstream tests for those replaced classes live in the matching upstream feature tests or in the owning engine's tests under tests/Data/Support. + notes: Hypervel replaces upstream's data pipes and resolvers with a fixed engine. The pipes and DataFromSomethingResolver map to Support/Creation/DataCreator and its per-operation ConstructionState; the validation resolvers and built-in rule inferrers map to Support/Validation/DataValidationCompiler and DataValidator; TransformedDataResolver and VisibleDataFieldsResolver map to Support/Transformation/DataTransformer and PartialTree. DataCreator's direct construction from DataClass creation recipes and DataTransformer's bulk copy are deliberate fast paths, so apply upstream behavior changes to them as well as to the general paths, and compare tests/Benchmarks/Data/benchmark.php results before and after. Upstream tests for those replaced classes live in the matching upstream feature tests or in the owning engine's tests under tests/Data/Support. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported. spatie/laravel-permission: branch: main checked_through: 6615eefac655efcd652fe394a20cbdf4f72c609f last_reviewed_pr: null sync_date: '2026-10-05' - notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. + notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported either. aimeos/laravel-nestedset: branch: master @@ -179,44 +179,45 @@ PHP-Open-Source-Saver/jwt-auth: saloonphp/saloon: branch: v4 - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 2f45e526f9d1d10a138f864492b074b046d2e52e + last_reviewed_pr: 564 + sync_date: '2026-10-07' + notes: Core of the unified hypervel/saloon package, together with the four plugin entries and shared documentation below. Upstream's connector send path and senders map to SaloonManager and Http/Sender, which use Hypervel's HTTP client; pools use coroutines. The PendingRequest constructor lifecycle maps to SaloonManager::createPendingRequest (properties merge in the PendingRequest constructor and plugins boot after it), mock clients are selected and matched in SaloonManager::send after request middleware rather than stored on the pending request, and Helpers/URLHelper maps to Http/UrlResolver. Upstream's Config maps to the manager's global middleware, the saloon.connection settings and the framework Date, Sleep and Http stray-request APIs; Helpers/RequestExceptionHelper maps to Response::newRequestException. Upstream's retry loop and DelayMiddleware map to SaloonManager::send, and its retry properties and handleRetry() methods map to Data/RetryPolicy and the defaultRetryPolicy() hooks. Helpers/OAuth2/OAuthConfig maps to the immutable Data/OAuthConfig, and token-response parsing shared by the OAuth grant traits lives in Traits/OAuth2/ParsesOAuthTokenResponses. Assess upstream changes against these framework integrations and preserve supported SDK-generator extension points. Upstream tests and fixtures map to the same paths under tests/Saloon/{Unit,Feature,Fixtures}; cases that call upstream's live test API use the engine test server under tests/Integration/Saloon instead. src/saloon/README.md lists the deliberate public differences across all five entries, with their reasons and Hypervel equivalents; check them against current source before applying an upstream change they affect, and keep the README current. saloonphp/laravel-plugin: branch: v4 - checked_through: null - last_reviewed_pr: null - sync_date: null - notes: Integrated into src/saloon. + checked_through: 47d06ef8621a5a25a2041e4a9004d96539db3f34 + last_reviewed_pr: 89 + sync_date: '2026-10-07' + notes: Part of the unified src/saloon package; reconcile alongside the core entry rather than creating a separate package. Provider, config, console, facade and event changes map to SaloonServiceProvider, config/saloon.php, Console, Facades and Events. Upstream's Http directory holds a MockClient subclass and observability middleware rather than a sender. Fakes and fixture recording map to SaloonManager and Http/Faking, SaloonManager dispatches the request events, and Telescope support comes from the framework HTTP client watcher through Http/Sender's Telescope tags. Tests map to tests/Saloon/Hypervel and use the core fixtures, which the plugin's fixtures copy. The plugin's Feature/PoolTest and Feature/RequestTest repeat core cases, so merge their new or changed cases into tests/Saloon/Feature/PoolTest and tests/Integration/Saloon/Feature/{PoolTest,RequestTest}, keeping plugin-specific setup such as Saloon::fake(), rather than adding separate copies. saloonphp/cache-plugin: branch: v3 - checked_through: null - last_reviewed_pr: null - sync_date: null - notes: Integrated into src/saloon. + checked_through: 5a036bae401dab95b41c514dcd87b25bc4600b24 + last_reviewed_pr: 17 + sync_date: '2026-10-07' + notes: Part of the unified src/saloon package. Upstream's CacheMiddleware and CacheRecorderMiddleware map to SaloonManager::send(), which reads the cache before matching the mock client and writes after the response, and clearCache() maps to SaloonManager::clearCache(); keys come from Cache/CacheKey. Drivers map to framework cache stores selected by Cacheable::cacheStore(); assess driver changes against that integration instead of duplicating them. Connectors implement Cacheable and override cacheKey() and cacheableMethods() rather than using HasCaching. SaloonServiceProvider allows CachedResponse through the cache's serializable-class policy. Tests map to tests/Saloon/Cache/{Feature,Fixtures}, with the real-request case in tests/Integration/Saloon/Cache. saloonphp/pagination-plugin: branch: v2 - checked_through: null - last_reviewed_pr: null - sync_date: null - notes: Integrated into src/saloon. + checked_through: a61822ca93c1207008941822b00df1d8740a4096 + last_reviewed_pr: 17 + sync_date: '2026-10-07' + notes: Part of the unified src/saloon package, under Pagination. Request-owned pagination, link paginators and the protected paginator state ($pageNumber, $startPage, $perPageLimit, $currentResponse, $currentPageLoaded, $pooling) are used by SDK generators; check affected consumers when adapting upstream changes. Upstream's async iteration maps to Paginator::pool(), so async test cases traverse through pool(). The infinite-loop check runs in Paginator::current(), not request middleware, because a middleware closure would keep the paginator alive through its own request. Upstream tests map to tests/Saloon/Pagination/Feature, with the public superhero API faked by Fixtures/SuperheroApi; Hypervel-only cases are in tests/Saloon/Pagination/PaginatorTest. saloonphp/rate-limit-plugin: branch: v2 - checked_through: null - last_reviewed_pr: null - sync_date: null - notes: Integrated into src/saloon. + checked_through: aed5ff488e4dc2b7c1fc19ba40e16f139d658126 + last_reviewed_pr: 31 + sync_date: '2026-10-07' + notes: Part of the unified src/saloon package. Upstream rate-limit behavior maps to RateLimit and SaloonManager using Hypervel's rate-limiter policies and stores; assess changes against those framework services. Upstream's limiter middleware, too-many-attempts limit and store records become admission before transport, a cooldown recorded from the transport response, and Saloon::inspectRateLimit(). Feature/HasRateLimitsTest, Unit/{Destruct,Helper}Test and Laravel/Feature/JobMiddlewareTest map to tests/Saloon/RateLimit/{Feature,Unit,Hypervel/Feature}; ported cases fake the transport with Http::fake(), because Saloon fakes consume no capacity, and assert through inspectRateLimit() instead of store records. Unit/{Limit,Bucket,LimitStore}Test, Feature/Stores and Laravel/Feature/LaravelStoreTest test classes replaced by the rate limiter's Limit, CalendarWindow and LeakyBucket policies and its stores, so compare their changes with tests/RateLimiter (including its shared store contract) and tests/Integration/RateLimiter instead of porting them. RateLimitTest keeps the fake, cache and policy-group cases. Sammyjo20/saloon-docs: branch: v3 - checked_through: null - last_reviewed_pr: null - sync_date: null - notes: Shared documentation for Saloon and its plugins. + checked_through: 8ea96847141fe60e24eb9dda2eb0e27a59c6bebd + last_reviewed_pr: 92 + sync_date: '2026-10-07' + notes: Shared documentation for the unified src/saloon package and all four plugin entries above. Reconcile applicable core and plugin pages into the existing sections of the single src/docs/saloon.md rather than separate plugin guides, keeping Hypervel's additions and checking examples against current source, in Laravel-style prose matching the guide. The v3 branch includes v4 guidance. Installation commands, supported versions, upgrade guides, release notes, showcase, tutorials, the book and the third-party Lawman and SDK generator pages have no Hypervel equivalent; check them only for behavior the guide must still describe. getsentry/sentry-laravel: branch: master diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index c59ca2d954..1790156ff0 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -26,6 +26,7 @@ - [Scheduling](#scheduling) - [Maintenance Mode](#maintenance-mode) - [HTTP Client and Concurrency](#http-client-and-concurrency) + - [Saloon](#saloon) - [Broadcasting](#broadcasting) - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) @@ -514,6 +515,13 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). + +### Saloon + +Integrations built with `saloonphp/saloon`, its Laravel plugin, and its cache, pagination, and rate limit plugins use the single `hypervel/saloon` package. Replace the `Saloon\` and `Saloon\Laravel\` namespaces with `Hypervel\Saloon\`, and the plugins' `Saloon\CachePlugin\`, `Saloon\PaginationPlugin\`, and `Saloon\RateLimitPlugin\` namespaces with `Hypervel\Saloon\Cache\`, `Hypervel\Saloon\Pagination\`, and `Hypervel\Saloon\RateLimit\`. + +Connectors may be shared by concurrent requests, so they are read-only. Move code that changes a connector's headers, query parameters, options, authenticator, delay, middleware, or mock client at runtime to the request, the `send` call, or the connector's `boot` method. Requests use fluent methods such as `withHeaders` and `withQueryParameters` instead of `headers()->add()` and `query()->add()`. Replace `sendAsync` and promises with pools, retry properties with `retry` or `defaultRetryPolicy`, custom senders with HTTP connections, and the rate limit plugin's limits and stores with rate limiter policies. Request exceptions extend the HTTP client's `RequestException`, so `catch (SaloonException $e)` no longer catches failed responses. See [Differences From Saloon](/docs/{{version}}/saloon#differences-from-saloon). + ### Broadcasting diff --git a/src/docs/rate-limiting.md b/src/docs/rate-limiting.md index 51c065cf60..48476cdac2 100644 --- a/src/docs/rate-limiting.md +++ b/src/docs/rate-limiting.md @@ -8,6 +8,7 @@ - [Defining Rate Limits](#defining-rate-limits) - [Choosing a Rate Limit](#choosing-a-rate-limit) - [Fixed Windows](#fixed-windows) + - [Calendar Windows](#calendar-windows) - [Sliding Windows](#sliding-windows) - [Leaky Buckets](#leaky-buckets) - [Weighted Operations](#weighted-operations) @@ -33,6 +34,7 @@ Hypervel includes a powerful rate limiter that you may use to limit HTTP routes, The rate limiter supports: - fixed-window limits; +- calendar windows that reset at the start of each minute, hour, day, or month; - sliding-window limits; - continuously replenishing leaky buckets; - weighted operations; @@ -180,6 +182,7 @@ Hypervel provides several rate limits for different kinds of work: | Rate Limit | When to Use It | |---|---| | Fixed window | You want a simple limit that resets all capacity at once. | +| Calendar window | You want capacity to reset on the clock, such as an API quota that resets at midnight. | | Sliding window | You want to smooth the traffic spike that may occur at a fixed-window boundary. | | Leaky bucket | You want capacity to replenish continuously or need precise burst control. | | Exponential backoff | You want repeated failures to create progressively longer delays. | @@ -207,6 +210,36 @@ $limit = Limit::perMinute(120, decayMinutes: 2); A denied operation does not consume capacity or extend the active window. + +### Calendar Windows + +The `CalendarWindow` class resets capacity at the start of each minute, hour, day, or month, rather than a fixed time after the first operation. This matches APIs whose quotas reset on the clock, such as a daily quota that resets at midnight: + +```php +use Hypervel\RateLimiter\CalendarWindow; + +$perMinute = CalendarWindow::perMinute(60); +$perHour = CalendarWindow::perHour(1000); +$perDay = CalendarWindow::perDay(10_000); +$perMonth = CalendarWindow::perMonth(100_000); +``` + +A daily window may reset at another time of day, given in the 24-hour `HH:MM` or `HH:MM:SS` format: + +```php +$limit = CalendarWindow::perDay(10_000, at: '01:00'); +``` + +Calendar windows follow your application's timezone. If a provider resets its quota in another timezone, use the `timezone` method: + +```php +$limit = CalendarWindow::perDay(10_000)->timezone('America/Los_Angeles'); +``` + +Windows follow the local clock when daylight saving time begins or ends, so a daily window may last 23 or 25 hours, and an hour that happens twice is two separate hourly windows. When the clocks go back and a daily reset time happens twice, the window resets the first time. When the clocks go forward past a daily reset time, the window resets later by the same amount that day. + +Calendar windows support the same `by`, `cost`, `globally`, `after`, and `response` modifiers as fixed windows. A denied operation does not consume capacity, and `resetAfter()` returns the time until the active window resets, or zero when no window is active. + ### Sliding Windows @@ -276,7 +309,7 @@ $limit = Limit::perMinute(100) ->by('uploads:'.$user->id); ``` -The cost may not exceed the fixed-window or sliding-window capacity, or the leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. +The cost may not exceed the fixed-window, calendar-window, or sliding-window capacity, or the leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. ### Unlimited @@ -547,7 +580,7 @@ interface Store } ``` -A custom store receives validated `Limit`, `SlidingWindow`, and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores. +A custom store receives validated `Limit`, `CalendarWindow`, `SlidingWindow`, and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. A calendar window counts like a fixed window, but new state expires at the end of the window that its `window` method returns for the store's current time. Both use epoch microseconds. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores. The `consumeMany` method evaluates entries in order, including repeated keys, and returns results through the first denial. Store all accepted changes together; on denial, leave capacity unchanged and report remaining capacity without the discarded charges. The `block` method extends a cooldown without shortening an existing block. diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 541201cdce..3e15a64411 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -17,6 +17,7 @@ - [Headers](#headers) - [Request URL](#request-url) - [Query Parameters](#query-parameters) + - [API Versions](#api-versions) - [Authentication](#authentication) - [Request Bodies](#request-bodies) - [Multipart Requests](#multipart-requests) @@ -62,6 +63,9 @@ - [Defining Policies](#defining-policies) - [Tenant and Service Limits](#tenant-and-service-limits) - [Server Cooldowns](#server-cooldowns) + - [Sharing Limits](#sharing-limits) + - [Inspecting Limits](#inspecting-limits) + - [Disabling Rate Limits](#disabling-rate-limits) - [Queued Requests](#queued-requests) - [Multi-Tenant Integrations](#multi-tenant-integrations) - [Telescope](#telescope) @@ -204,7 +208,7 @@ Do not change a reused connector while handling a request. If a credential or en ### Connector Defaults -You may define default headers, query parameters, Guzzle options, authentication, delays, and request bodies using protected connector methods: +You may define default headers, query parameters, Guzzle options, authentication, delays, [retry policies](#retries), and request bodies using protected connector methods: ```php use Hypervel\Saloon\Contracts\Authenticator; @@ -248,9 +252,23 @@ public function boot(PendingRequest $pendingRequest): void ### Organizing SDKs +A connector may serve as an SDK's entry point by offering methods that send its requests: + +```php +use App\Http\Integrations\GitHub\Requests\GetUser; +use Hypervel\Saloon\Http\Response; + +public function user(string $username): Response +{ + return $this->send(new GetUser($username)); +} +``` + When an integration contains many endpoints, you may group related requests into resource classes. Extend `BaseResource` and use the `@extends` annotation to specify your connector type: ```php +use App\Http\Integrations\GitHub\GitHubConnector; +use App\Http\Integrations\GitHub\Requests\GetRepository; use Hypervel\Saloon\Http\BaseResource; use Hypervel\Saloon\Http\Response; @@ -284,9 +302,18 @@ Your resource may access the connector through its protected, readonly `$connect ### HTTP Connections -Saloon registers one named Hypervel HTTP connection, named `saloon` by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar. +Saloon registers one named Hypervel [HTTP connection](/docs/{{version}}/http-client#connections), named `saloon` by default. Synchronous requests through this connection reuse the low-level transport, allowing cURL to retain keep-alive sockets, DNS information, and TLS sessions. Each operation still receives its own pending request, client, middleware stack, and cookie jar. -You may publish the configuration file to change the default connection options. A connector may also select another connection that your application registered during worker boot: +The `saloon` connection uses a 10-second connection timeout and a 30-second request timeout. Like other HTTP client requests, Saloon requests require TLS 1.2 or later unless the connection sets another `crypto_method` option. You may [publish the configuration file](#publishing-configuration-and-stubs) to change its options. A connector may also select another connection that your application registers during worker boot: + +```php +use Hypervel\Support\Facades\Http; + +Http::registerConnection('github', [ + 'connect_timeout' => 5, + 'timeout' => 60, +]); +``` ```php public function resolveHttpConnection(): ?string @@ -295,6 +322,8 @@ public function resolveHttpConnection(): ?string } ``` +A connection used by Saloon may not contain options that shape the request, such as `headers`, `query`, `cookies`, `json`, and `auth`, because Saloon builds those from the connector and request. Define them on the connector instead. Saloon requests also receive the HTTP client's [global options](/docs/{{version}}/http-client#global-options) and [global middleware](/docs/{{version}}/http-client#global-middleware). + Connection names must come from a fixed, bounded set. Do not create a connection name from a tenant ID, hostname, or credential, since registered connections and their handlers remain in memory for the worker lifetime. One fixed connection may safely send requests to many tenant-selected hosts. Connection options do not provide an aggregate connection cap. The unsafe Guzzle `max_host_connections` and `max_total_connections` options are rejected. Bound each concurrent operation with a [Saloon pool](#concurrent-requests), and use [rate limits](#rate-limiting) to control request rates. Saloon does not use object pooling because each request needs its own client, middleware stack, and cookie jar. @@ -336,7 +365,7 @@ Requests are mutable while you prepare one operation. You may create them with ` $request = GetUser::make('hypervel'); ``` -No-argument requests may also be resolved from the service container and are created fresh on each resolution. Requests that require caller-supplied values should be constructed directly, created with `make`, or given an explicit transient container binding. +Requests may also be resolved from the service container, which creates a fresh request on each resolution and injects its constructor dependencies. Pass any other constructor values as parameters to the container's `make` method. ### Request Methods @@ -373,6 +402,17 @@ $response = $github->send( The returned `Hypervel\Saloon\Http\Response` extends Hypervel's normal HTTP response, so the same JSON, header, status, exception, and PSR-7 methods are available. +To inspect an outgoing request without sending it, pass the request to the connector's `createPendingRequest` method. The pending request has run its plugins, authenticator, boot hooks, and request middleware: + +```php +$pendingRequest = $github->createPendingRequest(GetUser::make('hypervel')); + +$pendingRequest->headers(); +$pendingRequest->uri(); +``` + +The URL and body are finalized, and fake responses are matched, only when a request is sent. Sending creates a new pending request, so make outgoing changes on the request or in its hooks and middleware rather than on an inspected pending request. + ### Standalone Requests @@ -395,7 +435,32 @@ class GetStatus extends SoloRequest $response = (new GetStatus)->send(); ``` -Normal connector requests accept relative endpoints. An absolute endpoint is rejected unless the request or connector explicitly opts into base URL replacement. This protects credentials from being sent to an unexpected host. +Standalone requests define their headers, query parameters, options, authentication, and body using the same [default methods](#request-defaults) as other requests. + +> [!WARNING] +> A standalone request sends its headers and credentials to whatever URL `resolveEndpoint` returns. Do not build the endpoint from user input. + +A request that always uses the same connector may send itself with the `HasConnector` trait. Define the connector class in a `$connector` property: + +```php +use App\Http\Integrations\GitHub\GitHubConnector; +use Hypervel\Saloon\Traits\Request\HasConnector; + +class GetUser extends Request +{ + use HasConnector; + + protected string $connector = GitHubConnector::class; + + // ... +} + +$response = GetUser::make('hypervel')->send(); +``` + +The request creates its connector once, and the `connector` method returns it. Use `setConnector` to supply a configured instance instead, or override `resolveConnector` when the connector needs constructor arguments. The `createPendingRequest` method prepares the request through that connector. + +Normal connector requests accept relative endpoints. An endpoint without a scheme is always a path on the connector's host, even when it contains a colon, such as `documents:batchGet`, or begins with `//`. An absolute endpoint is rejected unless the request or connector explicitly opts into base URL replacement. This protects credentials from being sent to an unexpected host. Override `allowsBaseUrlOverride` on a request to return `true` when that request may use an application-controlled absolute endpoint. The request method returns `null` by default, which inherits the connector's decision: @@ -455,7 +520,7 @@ Requests and pending requests provide fluent methods that mirror Hypervel's HTTP ### Headers -Use `withHeader` to add one header or `withHeaders` to merge several headers. The `replaceHeaders` method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing: +Use `withHeader` to add one header or `withHeaders` to merge several headers. Adding a header that is already present keeps both values. The `replaceHeaders` method replaces matching header names without removing unrelated headers. Header names are matched without regard to casing: ```php $request @@ -468,6 +533,17 @@ $request ->withUserAgent('Acme Application/1.0'); ``` +The `withoutHeader` and `withoutHeaders` methods remove headers, also ignoring case. Removing a header from a request only affects the request's own headers, since connector defaults are merged into the pending request when the request is sent. To remove a connector default, remove it from the pending request in a hook or middleware: + +```php +use Hypervel\Saloon\Http\PendingRequest; + +public function boot(PendingRequest $pendingRequest): void +{ + $pendingRequest->withoutHeader('X-Api-Version'); +} +``` + ### Request URL @@ -481,6 +557,30 @@ The URL must be absolute HTTP or HTTPS. It replaces both the connector base URL The `url` method returns the override, or `null` when none was supplied. Explicit query strings and query parameters are still applied as described below. +The `withUrlParameters` method fills URI template placeholders in the connector base URL, the request endpoint, or the URL override, just like Hypervel's HTTP client: + +```php +// Base URL: https://{region}.api.example.com +// Endpoint: /accounts/{account} + +$request->withUrlParameters([ + 'region' => 'eu', + 'account' => $accountId, +]); +``` + +Values in simple placeholders such as `{account}` are percent-encoded, so slashes and query characters in a value stay part of that value. Placeholders are only replaced when parameters are given. + +Pending requests also provide `withUrl` and `withMethod`, so a hook or middleware may redirect a single operation without changing the request instance: + +```php +use Hypervel\Saloon\Enums\Method; + +$pendingRequest + ->withUrl('https://uploads.example.com/files') + ->withMethod(Method::PUT); +``` + ### Query Parameters @@ -495,6 +595,14 @@ $request->withQueryParameters([ Request values replace connector values with the same key. Values added later by middleware replace earlier values. Query parameters already present in the connector base URL or request endpoint are preserved unless the request contains the same top-level key. +Query parameters are encoded using PHP's `http_build_query` function, so arrays use bracketed names such as `filter[active]=1`, booleans become `1` or `0`, and `null` values are left out. When an API expects another format, use the `withQueryString` method described below. + +The `withoutQueryParameters` method removes parameters added through `withQueryParameters` or `defaultQuery`. As with headers, removing a parameter from a request does not remove a connector default; remove it from the pending request instead. Values embedded in the base URL, endpoint, or `withQueryString` query are unchanged: + +```php +$request->withoutQueryParameters(['page', 'per_page']); +``` + Use `withQueryString` when an API supplies an already-encoded query, including repeated parameter names: ```php @@ -505,6 +613,31 @@ The query should not include a leading `?`. It replaces the query in the base UR You may define a default query string by overriding `defaultQueryString(): ?string` on your request. Returning `null` leaves the URL's query unchanged. The `queryString` method returns this string, while `queryParameters` returns the separately configured array parameters. Middleware may also call `withQueryString` on a pending request to replace the query for that attempt. + +### API Versions + +The `HasApiVersion` plugin sends an API version with each operation. Assign the version in the connector or request constructor, and choose where it is sent with the `$versionMode` property: a header (the default), a query parameter, or the URL. The `$versionKey` property names the header or query parameter and defaults to `api-version`: + +```php +use Hypervel\Saloon\Enums\VersionMode; +use Hypervel\Saloon\Traits\Plugins\HasApiVersion; + +class AnthropicConnector extends Connector +{ + use HasApiVersion; + + public function __construct() + { + $this->apiVersion = '2023-06-01'; + $this->versionKey = 'anthropic-version'; + } + + // ... +} +``` + +In URL mode, the version replaces a `{version}` placeholder in the base URL, endpoint, or URL override, such as `https://generativelanguage.googleapis.com/{version}`. The version may only contain letters, numbers, dashes, underscores, and single dots, so it can only fill that subdomain label or path segment. You may override `getApiVersion` to calculate the version. When both a connector and a request send a version, the request's version is used. + ### Authentication @@ -516,6 +649,31 @@ $request->withBasicAuth($username, $password); $request->withDigestAuth($username, $password); ``` +These methods create authenticators, which you may also use directly. Return an authenticator from a connector or request's `defaultAuth` method, or pass one to `authenticate`. Saloon includes header, query, cookie, token, basic, digest, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`: + +```php +use Hypervel\Saloon\Contracts\Authenticator; +use Hypervel\Saloon\Http\Auth\HeaderAuthenticator; + +protected function defaultAuth(): ?Authenticator +{ + return new HeaderAuthenticator($this->key, 'X-Api-Key'); +} +``` + +A request's authenticator replaces its connector's. When an API requires more than one form of authentication, such as a client certificate and a token, combine them using `MultiAuthenticator`: + +```php +use Hypervel\Saloon\Http\Auth\CertificateAuthenticator; +use Hypervel\Saloon\Http\Auth\MultiAuthenticator; +use Hypervel\Saloon\Http\Auth\QueryAuthenticator; + +$request->authenticate(new MultiAuthenticator( + new CertificateAuthenticator('/path/to/client.pem', $password), + new QueryAuthenticator('api_key', $key), +)); +``` + Built-in NTLM authentication is not provided. Integrations requiring NTLM must supply their own authenticator and transport middleware. You may create a reusable authenticator by implementing `Hypervel\Saloon\Contracts\Authenticator`: @@ -528,24 +686,26 @@ class ApiKeyAuthenticator implements Authenticator { public function __construct( private readonly string $key, + private readonly string $accountId, ) { } public function set(PendingRequest $pendingRequest): void { - $pendingRequest->withHeader('X-Api-Key', $this->key); + $pendingRequest->withHeaders([ + 'X-Api-Key' => $this->key, + 'X-Account-Id' => $this->accountId, + ]); } } ``` -Apply a custom authenticator using `authenticate`, or return it from a connector's `defaultAuth` method: +Use a custom authenticator like the built-in ones: ```php -$request->authenticate(new ApiKeyAuthenticator($key)); +$request->authenticate(new ApiKeyAuthenticator($key, $accountId)); ``` -Saloon also includes header, query, cookie, token, basic, digest, certificate, access-token, and multi-authenticator implementations under `Hypervel\Saloon\Http\Auth`. - For APIs that authenticate using a cookie, use `CookieAuthenticator`: ```php @@ -571,6 +731,28 @@ class GetPrivateReport extends Request You may also add the trait to a connector to require authentication for every request in that integration. Saloon throws `MissingAuthenticatorException` before sending an unauthenticated request. You may override `getRequiresAuthMessage(PendingRequest $pendingRequest): string` when the integration needs a more specific message. +Some APIs require you to request a token before calling other endpoints. A connector's `boot` method may send that request and authenticate the pending request with the result: + +```php +use Hypervel\Saloon\Http\Auth\TokenAuthenticator; +use Hypervel\Saloon\Http\PendingRequest; + +public function boot(PendingRequest $pendingRequest): void +{ + if ($pendingRequest->request() instanceof GetAccessToken) { + return; + } + + $token = $this->send(new GetAccessToken($this->username, $this->password)) + ->throw() + ->json('token'); + + $pendingRequest->authenticate(new TokenAuthenticator($token)); +} +``` + +The early return stops the token request from requesting a token itself. Don't combine this with `RequiresAuth`, which checks for an authenticator before `boot` runs. When the API's tokens last longer than one request, you may store the token in your application's [cache](/docs/{{version}}/cache) until it expires. + ### Request Bodies @@ -604,7 +786,7 @@ class CreateIssue extends Request } ``` -The JSON, form, XML, and multipart traits supply the appropriate content type unless you already defined one. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format; request body values take precedence over connector values. +The JSON, form, and XML traits supply the appropriate content type unless you already defined one. A multipart body always receives `multipart/form-data` with its boundary unless the content type you define declares a boundary. String and stream bodies do not assume a content type, so specify one using `contentType` or `defaultHeaders`. Body traits may also be used on connectors when every request shares the same body format. A request's JSON and form values are merged into the connector's, replacing values with the same key, and its multipart parts are added after the connector's. A request's string, XML, or stream body replaces the connector's. When both a connector and request define a body, their body repositories must be the same type. For example, a request using `HasFormBody` cannot be sent through a connector using `HasJsonBody`. Saloon throws a `PendingRequestException` when the body types do not match. @@ -617,13 +799,17 @@ $request->withBody($stream, 'application/octet-stream'); $request->withBody($stream, null); ``` -The `withData` method merges structured values into the current JSON or form body. If the request does not yet have a structured body, the method creates a JSON body: +The `withBody` method accepts a string, stream, or resource. Like the HTTP client's method of the same name, it sets an `application/json` content type unless you pass another one, or `null` to leave the content type unchanged. + +The `withData` method merges structured values into the current JSON or form body. If the request has no JSON, form, or multipart body, the method creates a JSON body: ```php $request->withData(['active' => true]); ``` -Laravel `Arrayable` objects, `JsonSerializable` objects, and stringable values are normalized recursively in JSON, form, and query data. +On a multipart body, `withData` adds each value as a field after the existing parts. Fields are appended, so repeating a name adds another field instead of replacing the first. Nested arrays are sent as bracketed field names such as `roles[0]`, `true` is sent as `1`, and `false` and `null` are sent as empty fields. + +`Arrayable` and `JsonSerializable` objects, and the fluent strings returned by `Str::of`, are converted recursively in JSON, form, and query data. JSON bodies use `JSON_THROW_ON_ERROR` by default. To use other encoding flags, return a configured `JsonBodyRepository` from `defaultBodyRepository`: @@ -638,7 +824,7 @@ protected function defaultBodyRepository(): ?BodyRepository } ``` -The request may still use `HasJsonBody` so Saloon supplies the JSON content type. Invalid JSON values throw a `BodyException` instead of being sent as an empty body. +The request may still use `HasJsonBody` so Saloon supplies the JSON content type. When the connector also defines a JSON body, the request's values are merged into the connector's repository, so the connector's flags apply. Invalid JSON values throw a `BodyException` instead of being sent as an empty body. ### Multipart Requests @@ -654,8 +840,43 @@ $request ->attach('description', 'Quarterly report'); ``` +You may also pass an array of parts, each containing the same arguments: + +```php +$request->attach([ + ['document', fopen($path, 'rb'), 'report.pdf', ['Content-Type' => 'application/pdf']], + ['description', 'Quarterly report'], +]); +``` + +When a request has a body in another format, `attach` replaces it with a multipart body. The `asMultipart` method selects a multipart body without adding a value. Neither method discards values that a multipart body already contains. + Saloon preserves multipart streams, filenames, headers, order, and the generated boundary. Caller-owned streams are not buffered automatically. +A request using the `HasMultipartBody` trait returns its default parts from `defaultBody` as `MultipartValue` instances, each containing the part's name, contents, and an optional filename and headers. String contents are sent as they are, so open files using `fopen`: + +```php +use Hypervel\Saloon\Data\MultipartValue; +use Hypervel\Saloon\Traits\Body\HasMultipartBody; + +class UploadAvatar extends Request +{ + use HasMultipartBody; + + // ... + + protected function defaultBody(): array + { + return [ + new MultipartValue('avatar', fopen($this->path, 'rb'), 'avatar.png', [ + 'Content-Type' => 'image/png', + ]), + new MultipartValue('description', 'Profile photo'), + ]; + } +} +``` + ### Custom Body Repositories @@ -719,7 +940,7 @@ class NdjsonBodyRepository implements MergeableBody } ``` -Implement `MergeableBody` when connector values should be merged with request values. A repository that implements only `BodyRepository` is replaced as a complete value instead. The `withData` method is reserved for the built-in JSON and form repositories; calling it on a custom repository selects a JSON body. +Implement `MergeableBody` when connector values should be merged with request values. A repository that implements only `BodyRepository` is replaced as a complete value instead. The `withData` method is reserved for the built-in JSON, form, and multipart repositories; calling it on a custom repository selects a JSON body. Return the repository from the request or connector's `defaultBodyRepository` method and set the appropriate content type during `boot`: @@ -754,7 +975,35 @@ $request ->withOptions(['proxy' => $proxy]); ``` -The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds. +The `timeout` and `connectTimeout` methods accept seconds, while `delay` accepts milliseconds. Saloon waits for the delay before every attempt, including attempts answered by a mock or fake response, so `Sleep::fake()` can assert it in your tests. Responses served from the cache are not delayed. + +The `withoutRedirecting` method disables redirects, and the `sink` method writes the response body to a path or resource as it is received. + +The `withoutOptions` method removes options from the request, so the connector's value or the HTTP connection's configured value applies again. To remove a connector default, call `withoutOptions` on the pending request: + +```php +$request->withoutOptions(['timeout', 'connect_timeout']); +``` + +The `HasTimeout` plugin gives a connector or request default timeouts from its `$connectTimeout` and `$requestTimeout` properties, in seconds. You may override the `getConnectTimeout` or `getRequestTimeout` methods to calculate them instead. Options set on the same connector or request take precedence over its declared timeouts, and a request's timeouts take precedence over its connector's. A timeout that is not declared leaves the existing options and the HTTP connection's configured value in effect: + +```php +use Hypervel\Saloon\Traits\Plugins\HasTimeout; + +class GitHubConnector extends Connector +{ + use HasTimeout; + + protected int $connectTimeout = 5; + + protected int $requestTimeout = 60; + + public function resolveBaseUrl(): string + { + return 'https://api.github.com'; + } +} +``` To specify a cookie's path or other attributes, use `withCookie` with a Guzzle `SetCookie` instance. The cookie must include a domain: @@ -775,7 +1024,7 @@ Request-shaping options such as `headers`, `query`, `cookies`, `body`, `json`, ` ## Middleware -Middleware may inspect or change the pending request, response, or fatal transport exception. You may register middleware from a connector or request's `boot` method, through a reusable plugin, or globally during application boot. +Middleware may inspect or change the pending request, response, or fatal transport exception. You may register middleware on a request before sending it, from a connector or request's `boot` method, through a reusable plugin, or globally during application boot. ### Request Middleware @@ -797,7 +1046,17 @@ public function boot(PendingRequest $pendingRequest): void } ``` -Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request. +Requests have the same middleware pipeline, so you may also add middleware to a request before sending it: + +```php +$request = GetUser::make('hypervel'); + +$request->middleware()->onRequest(function (PendingRequest $pendingRequest) use ($traceId): void { + $pendingRequest->withHeader('X-Trace-ID', $traceId); +}); +``` + +Request middleware may return a `MockResponse` or another implementation of `FakeResponse` to short-circuit the network request. The remaining request middleware still runs, and the last fake response returned is used. A fake response supplied by middleware bypasses the [response cache](#caching) and [mock clients](#faking-responses), and consumes no [rate limit](#rate-limiting) capacity. Other return values are ignored, so change the pending request it receives rather than returning a new one. For reusable middleware, create an invokable class that implements `RequestMiddleware`: @@ -820,7 +1079,9 @@ class AddRequestId implements RequestMiddleware Register the class in the same way as a closure: `$pendingRequest->middleware()->onRequest(new AddRequestId)`. -Middleware runs in registration order. You may pass `PipeOrder::First` or `PipeOrder::Last` using the `order` argument when a middleware must run before or after the normal group. Named middleware must have a unique name within its pipeline. +Middleware runs in registration order. Saloon registers [global middleware](#global-middleware) first, followed by plugin middleware, middleware added by the connector's `boot` method, the request's own middleware, and middleware added by the request's `boot` method. You may pass `PipeOrder::FIRST` or `PipeOrder::LAST` using the `order` argument when a middleware must run before or after the normal group. + +These sources are merged into the operation's pipelines, so a name may be used only once among its request middleware, and likewise among its response and fatal exception middleware. A duplicate name throws a `DuplicatePipeNameException`. Request middleware may register response middleware, but request middleware added while the request pipeline is running does not run for that operation. ### Response Middleware @@ -861,7 +1122,7 @@ class RecordResponse implements ResponseMiddleware ### Fatal Exception Middleware -Fatal exception middleware receives connection and transport failures after they have been wrapped in a `FatalRequestException`: +Fatal exception middleware receives connection and transport failures after they have been wrapped in a `FatalRequestException`, as well as a `FatalRequestException` thrown by a fake response to simulate one: ```php use Hypervel\Saloon\Exceptions\Request\FatalRequestException; @@ -919,7 +1180,7 @@ The connector hook runs before the request hook. Each hook runs once on the fina ### Plugins -Plugins are traits with a boot method named after the trait. They allow connector and request behavior to be shared without another service layer: +Plugins are traits with a public boot method named after the trait. They allow connector and request behavior to be shared without another service layer: ```php use Hypervel\Saloon\Http\PendingRequest; @@ -934,7 +1195,9 @@ trait AddsRequestId } ``` -Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation. +Use the trait on any connector or request. Saloon discovers plugin boot methods once per concrete class and invokes them for each operation. Change the pending request in a plugin rather than the connector or request, since a connector may be shared by concurrent operations. A plugin used by both a connector and its request boots twice. + +Connector plugins boot before request plugins, and the request is authenticated once every plugin has booted. This lets an authenticator use the URL and headers set by plugins, such as an [API version](#api-versions) in the host. A plugin may select the authenticator by calling `authenticate` on the pending request; Saloon applies it after the remaining plugins boot. The connector and request `boot` methods and request middleware run after authentication, so calling `authenticate` there applies the authenticator immediately. ## Responses @@ -958,7 +1221,7 @@ $response->toPsrResponse(); $response->dataUrl(); ``` -The `dataUrl` method returns the response body as a base64 data URL using its `Content-Type` header. +The `dataUrl` method returns the response body as a base64 data URL using its `Content-Type` header. The `isJson` and `isXml` methods check whether that header names a JSON or XML type, and `array` is an alias of `json`. You may use [`lines` and `jsonLines`](/docs/{{version}}/http-client#streaming-responses) to process a response as it arrives. Enable the `stream` request option and leave response caching and fixture recording disabled, since both read the body before returning the response. @@ -1033,7 +1296,7 @@ class GetUser extends Request } ``` -Call `dto` to create the configured value. The `dtoOrFail` method refuses to create it when the integration considers the response failed: +Call `dto` to create the configured value. The `dto` method converts every response, including failed ones, so you may also map error responses. The `dtoOrFail` method instead throws a `LogicException` when the integration considers the response [failed](#error-handling). When the response would also throw, its request exception is available as the `LogicException`'s previous exception: ```php $user = $github->send(new GetUser('hypervel'))->dtoOrFail(); @@ -1098,50 +1361,128 @@ if ($response->failed()) { $response->throw(); ``` -By default, 4xx responses throw `ClientException`, 5xx responses throw `ServerException`, and other integration-defined failures throw `RequestException`. Transport failures throw `FatalRequestException`. +Common statuses throw an exception named after them from the `Hypervel\Saloon\Exceptions\Request\Statuses` namespace: -You may define provider-specific failure behavior on a request or connector: +| Status | Exception | +|---|---| +| 400 | `BadRequestException` | +| 401 | `UnauthorizedException` | +| 402 | `PaymentRequiredException` | +| 403 | `ForbiddenException` | +| 404 | `NotFoundException` | +| 405 | `MethodNotAllowedException` | +| 408 | `RequestTimeOutException` | +| 409 | `ConflictException` | +| 422 | `UnprocessableEntityException` | +| 429 | `TooManyRequestsException` | +| 500 | `InternalServerErrorException` | +| 502 | `BadGatewayException` | +| 503 | `ServiceUnavailableException` | +| 504 | `GatewayTimeoutException` | + +These extend `ClientException` for 4xx responses and `ServerException` for 5xx responses, which other 4xx and 5xx responses throw directly. All of them extend `RequestException`, which other integration-defined failures throw. + +A request that could not complete throws `FatalRequestException`. This includes failed connections and transfers that break while the response is being received, whatever status the partial response has. + +Some APIs report errors in a successful response. A request or connector may override `hasRequestFailed` to decide whether a response failed, returning `null` to fall back to the status code: ```php +use Hypervel\Saloon\Http\Response; + public function hasRequestFailed(Response $response): ?bool { return $response->json('ok') === false ? true : null; } +``` + +In this example, a response whose body contains `"ok": false` is treated as failed whatever its status. A request's non-null decision takes precedence over its connector's. Failed responses are not [cached](#caching), and by default they throw from `throw` and trigger [retries](#retries). + +A response throws when the `shouldThrowRequestException` method of either its request or its connector returns `true`. Both return `failed()` by default. To stop a failed response from throwing while keeping it failed, return `false` from both methods. Returning `false` from `hasRequestFailed` instead makes the response successful, so it may also be cached and converted by `dtoOrFail`. + +You may return a custom Saloon request exception from `getRequestException`, and a request's exception takes precedence over its connector's. To give a custom exception its own message, override its `prepareMessage` method: + +```php +use Hypervel\Http\Client\Response; +use Hypervel\Saloon\Exceptions\Request\RequestException; -public function shouldThrowRequestException(Response $response): bool +class GitHubException extends RequestException { - return $response->status() !== 404 && $response->failed(); + protected function prepareMessage(Response $response): string + { + return 'GitHub: ' . $response->json('message'); + } } ``` -In this example, the integration treats a 404 response as an empty result instead of an exception. +Then return it from the request or connector: -A non-null request failure decision takes precedence over the connector decision, which takes precedence over the status-code fallback. You may return a custom Saloon request exception from `getRequestException`. The `AlwaysThrowOnErrors` plugin calls `throw` automatically after each response. +```php +use Hypervel\Saloon\Exceptions\Request\RequestException; +use Hypervel\Saloon\Http\Response; + +public function getRequestException(Response $response): ?RequestException +{ + return new GitHubException($response); +} +``` + +The `AlwaysThrowOnErrors` plugin calls `throw` automatically after each response. ### Retries -Use `retry` to specify the number of attempts and delay between attempts: +The `retry` method accepts the maximum number of times a request should be attempted and the number of milliseconds to wait between attempts. A request is retried when its response would [throw](#error-handling) or the request could not complete: ```php $request->retry(3, 100); ``` -You may supply an array of delays or calculate the delay using a closure: +You may calculate the delay using a closure, or pass an array of delays as the first argument, which makes one more attempt than it contains delays: ```php +$request->retry(3, fn (int $attempt) => $attempt * 100); + $request->retry([100, 250, 500]); +``` -$request->retry( - 4, - fn (int $attempt) => $attempt * 100, - when: fn ($exception, $pendingRequest) => $exception->getCode() !== 401, - throw: false, -); +When a request with more than one attempt runs out of attempts, Saloon throws the last attempt's `RequestException` or `FatalRequestException`. A request with one attempt, such as `retry(1)`, returns a failed response as usual. Pass `throw: false` to return the last response instead of throwing. A `FatalRequestException` is still thrown, because there is no response to return, as is a request exception thrown by response middleware, such as the `AlwaysThrowOnErrors` plugin's: + +```php +$request->retry(3, 100, throw: false); ``` Each attempt receives a fresh pending request, so middleware and authentication run again. Seekable request bodies are restored before another attempt. Saloon throws a `BodyException` rather than retrying a consumed non-seekable body. +The `when` callback receives the exception and the failed attempt's pending request. Changes made to the original request through `$pendingRequest->request()` apply to the next attempt, such as replacing an expired token: + +```php +use Hypervel\Saloon\Exceptions\Request\RequestException; +use Hypervel\Saloon\Http\PendingRequest; + +$request->retry(2, when: function ($exception, PendingRequest $pendingRequest) { + if (! $exception instanceof RequestException || $exception->status() !== 401) { + return false; + } + + $pendingRequest->request()->withToken($this->refreshToken()); + + return true; +}); +``` + +To retry every request sent through a connector, return a policy from the connector's `defaultRetryPolicy` method. A request may define the same method to set its own default: + +```php +use Hypervel\Saloon\Data\RetryPolicy; + +protected function defaultRetryPolicy(): ?RetryPolicy +{ + return new RetryPolicy(3, 100); +} +``` + +Saloon uses the policy passed to the request's `retry` method, then the request's default, then the connector's default. A request's policy replaces the connector's entirely, including its `when` condition, so `$request->retry(1)` turns off the connector's retries. Middleware and `boot` methods may also call `retry` on the pending request to change the policy for a single operation. + ### Debugging @@ -1153,7 +1494,26 @@ $request->debugRequest(); $request->debugResponse(); ``` -The `debugRequest` and `debugResponse` methods accept a custom callback. All three methods accept `die: true`, which terminates the current worker process and is intended only for local debugging. +To debug every request sent through a connector, call the same methods on the pending request from the connector's `boot` method. All three methods accept `die: true`, which stops the current request or command after the output and is intended only for local debugging. + +Saloon dumps the request and response using Symfony's VarDumper. The request is shown as Saloon passes it to the HTTP client, after [PSR-7 request hooks](#psr-request-hooks) have run, so headers that HTTP client middleware or Guzzle add later are not included. The response is shown before response middleware that uses the default [order](#middleware). + +The `debugRequest` and `debugResponse` methods also accept a callback, such as one that writes to your logs instead: + +```php +use Hypervel\Saloon\Http\PendingRequest; +use Hypervel\Saloon\Http\Response; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + +$request->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest): void { + logger()->debug('Saloon request', ['uri' => (string) $psrRequest->getUri()]); +}); + +$request->debugResponse(function (Response $response, ResponseInterface $psrResponse): void { + logger()->debug('Saloon response', ['status' => $response->status()]); +}); +``` > [!WARNING] > Debug output contains raw headers and bodies and may expose credentials or other sensitive values. Custom callbacks are also responsible for any stream reads they perform. @@ -1194,7 +1554,9 @@ class GitHubConnector extends Connector } ``` -The `allowBaseUrlOverride` option is required here because GitHub's trusted authorization and token endpoints use a different host from the connector's API base URL. Leave this option disabled when the OAuth endpoints are relative to the connector base URL. +The authorize, token, and user endpoints default to `authorize`, `token`, and `user`, relative to the connector's base URL. The `allowBaseUrlOverride` option is required here because GitHub's trusted authorization and token endpoints use a different host from the connector's API base URL. Leave this option disabled when the OAuth endpoints are relative to the connector base URL. + +If each tenant has its own OAuth client, pass the client credentials to the connector's constructor and use them in `defaultOAuthConfig`. The `authorizationUrl` method returns the URL together with its generated state. Store the state for the current authorization attempt before redirecting the user: @@ -1206,6 +1568,8 @@ session(['github_oauth_state' => $authorization->state]); return redirect((string) $authorization); ``` +The given scopes are added to the configuration's `defaultScopes`. The state is a random 32-character string unless you pass your own `state` argument. The `authorizationUrl` method also accepts a `scopeSeparator` and an array of `additionalQueryParameters`. Additional parameters may replace the standard ones, such as `response_type` for an OpenID Connect hybrid flow, but the URL's `state` always matches the returned state. + After the provider redirects to your application, exchange the code and validate the returned state: ```php @@ -1220,7 +1584,7 @@ $response = $github->send( ); ``` -If either state value is supplied, both values must be non-empty and equal. Keeping the state beside each authorization attempt allows one connector to support concurrent tabs and users without shared mutable state. +If either state value is supplied, both values must be non-empty and equal, or Saloon throws an `InvalidStateException`. If the provider rejects the code, `getAccessToken` throws the token response's [request exception](#error-handling). Keeping the state beside each authorization attempt allows one connector to support concurrent tabs and users without shared mutable state. You may pass a PKCE code challenge to `authorizationUrl` and the corresponding verifier to `getAccessToken`: @@ -1258,7 +1622,7 @@ class ServiceConnector extends Connector $authenticator = $connector->getAccessToken(['reports:read']); ``` -Use `ClientCredentialsBasicAuthGrant` when the provider requires the client credentials in an HTTP Basic authentication header. +The given scopes are added to the configuration's `defaultScopes` and separated by spaces unless you pass a `scopeSeparator`. Client credentials connectors need no `redirectUri`. Use `ClientCredentialsBasicAuthGrant` when the provider requires the client credentials in an HTTP Basic authentication header. OAuth token grant methods return an OAuth authenticator by default. Pass `returnResponse: true` when you need the original Saloon response instead: @@ -1280,6 +1644,18 @@ if ($authenticator->hasExpired() && $authenticator->isRefreshable()) { } ``` +To keep tokens between requests, store the authenticator's `accessToken`, `refreshToken`, and `expiresAt` properties and create a new authenticator from them when you need it. For example, you may store the tokens in [encrypted](/docs/{{version}}/eloquent-mutators#encrypted-casting) model attributes and [cast](/docs/{{version}}/eloquent-mutators#date-casting) the expiry attribute to `immutable_datetime`: + +```php +use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; + +$authenticator = new AccessTokenAuthenticator( + $user->github_access_token, + $user->github_refresh_token, + $user->github_token_expires_at, +); +``` + OAuth token expiry values use immutable dates. Saloon rejects negative or unrepresentable expiry durations instead of creating an already-expired token. Refresh requests use the configured `tokenEndpoint` by default. If your provider uses a separate endpoint, set `refreshEndpoint` on your `OAuthConfig`: @@ -1326,7 +1702,19 @@ $authenticator = $connector->getAccessToken( ); ``` -For providers that require a different request body or token response format, override the protected `resolveAccessTokenRequest`, `resolveRefreshTokenRequest`, `resolveUserRequest`, `createOAuthAuthenticatorFromResponse`, or `createOAuthAuthenticator` method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow. +For providers that require a different request body or token response format, override the protected `resolveAccessTokenRequest`, `resolveRefreshTokenRequest`, `resolveUserRequest`, `createOAuthAuthenticatorFromResponse`, or `createOAuthAuthenticator` method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow. A PKCE verifier is added to the request your `resolveAccessTokenRequest` method returns. + +For example, you may return your own authenticator class. Authorization code connectors receive the access token, refresh token, and expiry, while client credentials connectors receive the access token and expiry: + +```php +use DateTimeImmutable; +use Hypervel\Saloon\Contracts\OAuthAuthenticator; + +protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator +{ + return new SpotifyAuthenticator($accessToken, $refreshToken, $expiresAt); +} +``` ## Concurrent Requests @@ -1346,7 +1734,7 @@ $pool = $github->pool([ $responses = $pool->send(); -$responses['hypervel']->throw(); +$responses['hypervel']->json(); ``` The pool also accepts a lazy iterable or a producer callback that receives the connector: @@ -1359,7 +1747,9 @@ $responses = $github->pool(function (Connector $connector) use ($usernames) { }, concurrency: 10)->send(); ``` -The concurrency value must be a positive integer. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines. +The callback runs each time the pool is sent, so a pool whose callback returns a new iterable each time may be sent again. A generator passed directly can only be sent once. + +The concurrency defaults to 5 and must be a positive integer. Each request is sent as the connector's `send` method would send it, so its retry policy, middleware, caching, and rate limits still apply. Scheduling blocks when the bound is full, so a lazy iterable does not create an unbounded queue of child coroutines. You may also create an empty pool and configure it fluently before sending: @@ -1381,7 +1771,7 @@ If a response handler owns each result and you do not need a response array, cal $github->pool( requests: $requests, concurrency: 10, - responseHandler: function (Response $response, string $key): void { + responseHandler: function (Response $response, int|string $key): void { ProcessImportedUser::dispatch($key, $response->json()); }, )->process(); @@ -1397,13 +1787,17 @@ You may handle request failures while allowing the remaining requests to finish: ```php $responses = $github->pool( requests: $requests, - exceptionHandler: function (Throwable $exception, string $key): void { + exceptionHandler: function (Throwable $exception, int|string $key): void { report($exception); }, )->send(); ``` -A handled failure is omitted from the returned responses. Without an exception handler, or when a response or exception callback fails, Saloon waits for every started child and then throws `PoolException`. The exception provides `orchestrationFailure`, `failures`, `callbackFailures`, and `responses` methods so no completed work or cause is lost. +A failed response is a request failure: when the response would throw, such as a `4xx` or `5xx` status or a request your [failure hooks](#error-handling) mark as failed, the exception handler receives its request exception, and `$exception->response()` returns the response. A handled failure is omitted from the returned responses. + +Without an exception handler, or when a response or exception callback fails, Saloon waits for every started child and then throws `PoolException`. The exception provides `orchestrationFailure`, `failures`, `callbackFailures`, and `responses` methods so no completed work or cause is lost. Its previous exception is the failure that stopped scheduling, otherwise the first request failure, or the first callback failure if no request failed, so reported pool failures show their cause. + +To stop sending early, end the request generator. Requests that have already started, or that are waiting for a free slot, still run to completion. ## Caching @@ -1441,7 +1835,18 @@ A null store uses `saloon.cache.store`, which itself falls back to Hypervel's de An integer cache duration is measured in seconds. You may also return a `DateInterval` or `DateTimeInterface` instance. -GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. Only successful network responses are written. Saloon fakes do not read or populate the response cache. +GET, HEAD, OPTIONS, and QUERY requests are cacheable by default. To change which methods are eligible, override `cacheableMethods` on the connector, or on a request using the [`HasCaching` trait](#request-cache-controls). A request's list takes precedence over its connector's: + +```php +use Hypervel\Saloon\Enums\Method; + +protected function cacheableMethods(): array +{ + return [Method::GET, Method::POST]; +} +``` + +A response is written unless it failed, including failures defined by a request or connector's [failure check](#error-handling). Mock responses and fixtures are cached like network responses, so a cached response is returned before the mock client is consulted. See [Fixtures](#fixtures) for keeping tests out of the cache. The default cache key includes the connector and request classes, method, final URI, headers, cookies, authentication and certificate state, prepared body, and response-affecting transport options. This means a QUERY request's body is part of its identity. Responses cached from streaming bodies are buffered because the cache must retain their bytes. @@ -1471,7 +1876,17 @@ $request->disableCaching(); $request->invalidateCache(); ``` -The request or its connector must implement `Cacheable` before these controls are used. `invalidateCache` removes the matching value and refreshes it from the network. +The request or its connector must implement `Cacheable` before these controls are used. `invalidateCache` removes the matching value and caches the new response. + +To remove a cached response without sending the request, pass the connector to the request's `clearCache` method. A connector's `clearCache` method accepts any request, including one without the `HasCaching` trait: + +```php +$request->clearCache($connector); + +$connector->clearCache($request); +``` + +Clearing ignores `disableCaching` and the cacheable methods. It prepares the request as sending does, running its plugins, boot methods, and middleware, so it removes the value the request would read. `SendingSaloonRequest` listeners do not run, so any change a listener makes to the request's identity must also be made by a hook or middleware, or derived by a [custom cache key](#custom-cache-keys). To always keep a request type out of connector caching, override `cachingEnabled` on a request without the `HasCaching` trait. This is useful for live streams that must not be buffered: @@ -1498,8 +1913,6 @@ protected function cacheKey(PendingRequest $pendingRequest): ?string Saloon hashes custom keys before passing them to the cache backend, so raw credentials and tenant identifiers are not exposed in backend key names. A custom key must include every value that can change the successful response. It is required when a non-seekable request body is cached or when lower-level PSR or HTTP middleware changes response identity after Saloon finalizes the operation. -You may override `cacheableMethods` to change which methods are eligible. A request override takes precedence over the connector's list. - ### Cache Scopes @@ -1529,14 +1942,14 @@ public function boot(): void The callback runs when a cacheable operation resolves its key. It must resolve the current tenant at invocation time; do not capture one tenant while the worker boots. Returning null deliberately allows sharing for connectors where a separate scope is not needed. -Authentication state already separates tenant-owned credentials, and the final URI separates tenant-specific endpoints. A cache scope expresses an additional application policy, such as preventing tenants with shared platform credentials from sharing provider responses. It is applied to reads, writes, invalidation, and custom keys. +Authentication state already separates tenant-owned credentials, and the final URI separates tenant-specific endpoints. A cache scope expresses an additional application policy, such as preventing tenants with shared platform credentials from sharing provider responses. It is applied to reads, writes, invalidation, clearing, and custom keys. ## API Pagination Saloon pagination iterates through pages returned by a remote API. It is separate from Hypervel's [application pagination](/docs/{{version}}/pagination), which prepares local data for views and JSON responses. -A paginated request must implement the `Paginatable` marker contract. Create a paginator class for the remote API's pagination format. +A paginated request must implement the `Paginatable` marker contract. Create a paginator class for the remote API's pagination format. Saloon includes page, offset, cursor, link, and link header paginators. For another format, extend `Paginator` and implement `applyPagination`, `isLastPage`, and `getPageItems`, using the protected `$pageNumber` and `$perPageLimit` properties to build each page's request. ### Page Pagination @@ -1610,9 +2023,9 @@ $users = $paginator->collect(); The `HasPagination` contract provides the conventional connector entry point. A request that needs its own paginator may implement `HasRequestPagination` and define `paginate(Connector $connector): Paginator`; the connector can delegate to it as shown above. Declare the request's item type with `@implements HasRequestPagination` and `@return Paginator` on its `paginate` method. -The `collect(false)` method returns a lazy collection of page responses instead of items. Declare the paginator's item type with `@extends PagedPaginator` (or the matching base class) to preserve it through `items` and `collect`. You may also inspect `totalResults`, `request`, and the zero-based iterator position returned by `currentPage`. Use `startPage` to configure the first remote page number. +Iterating the paginator itself yields each page's response, and only the current page is kept in memory. A page response that would [throw](#error-handling) stops iteration with its request exception. The `collect(false)` method returns a lazy collection of page responses instead of items. To return data objects, map them in `getPageItems` and declare the paginator's item type with `@extends PagedPaginator` (or the matching base class) to preserve it through `items` and `collect`. You may also inspect `totalResults`, `request`, and the zero-based iterator position returned by `currentPage`. Use `startPage` to configure the first remote page number. -Calling `count($paginator)` counts remote pages by requesting each page. It is not a metadata-only operation. +Calling `count($paginator)` counts remote pages by requesting each page. It is not a metadata-only operation. Do not use `iterator_count`, which never loads a page and so cannot find the last one. Override the protected query-name properties when an API uses different names: @@ -1622,16 +2035,25 @@ protected string $pageName = 'currentPage'; protected string $perPageName = 'pageSize'; ``` -`PagedPaginator` defaults to `page` and `per_page`. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure. +`PagedPaginator` defaults to `page` and `per_page`, and sends a page size only when a per-page limit is set. To send one by default, declare `protected ?int $perPageLimit = 100;` on your paginator. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure. During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages return the same response body. Check that your paginator correctly identifies the last page. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator. +The check compares each page's body after all response middleware has run. If your API adds a value that changes on every request, such as a request ID, override `getBodyChecksum` to compare only the page contents: + +```php +protected function getBodyChecksum(Response $response): string +{ + return hash('xxh128', json_encode($response->json('data'))); +} +``` + If a request implements `MapPaginatedResponseItems`, its `mapPaginatedResponseItems` method takes precedence over the paginator's item mapping. Declare `@implements MapPaginatedResponseItems` with the same item type as its paginator. Mapping runs once per fetched page, after all response middleware, and `totalResults` counts these final items. ### Offset and Cursor Pagination -Extend `OffsetPaginator` for APIs that use `limit` and `offset`. A per-page limit must be configured before iteration. Extend `CursorPaginator` for APIs where each response supplies the next cursor, and implement `getNextCursor`. +Extend `OffsetPaginator` for APIs that use `limit` and `offset`. A per-page limit must be configured before iteration, and the protected `getOffset` method returns the current page's offset, such as for comparison with a total in `isLastPage`. Extend `CursorPaginator` for APIs where each response supplies the next cursor, and implement `getNextCursor`. Cursors may be strings, numbers, booleans, or null. The default query encoding omits null and sends booleans as `1` or `0`. Override `applyPagination` when your API requires another encoding, and use `isLastPage` to determine when pagination ends. @@ -1749,16 +2171,22 @@ class GitHubConnector extends Connector } ``` -By default, a denied policy throws `RateLimitReachedException` before reaching the network. Override `waitForRateLimits` to return true when the operation should sleep until capacity is available: +A policy may be a [fixed, calendar or sliding window, or a leaky bucket](/docs/{{version}}/rate-limiting#choosing-a-rate-limit). For example, a quota that resets at the end of each minute or day suits a calendar window, while an API that refills capacity continuously suits a leaky bucket. Give each user or API key its own limit with a distinct `by` key. + +By default, a denied policy throws `RateLimitReachedException` before reaching the network. The exception's `policy` and `result` methods return the denied policy and its decision, so `$exception->result()->retryAfter()` gives the number of seconds to wait. Override `waitForRateLimits` to sleep until capacity is available instead. It receives the denied policy and its decision, so you may wait out a short burst limit while still throwing for a daily quota: ```php -protected function waitForRateLimits(): bool +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; +use Hypervel\RateLimiter\Cooldown; + +protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { - return true; + return $result->retryAfter() <= 10; } ``` -You may select another configured rate-limiter store by overriding `resolveRateLimitStore`. Fakes and cache hits do not consume capacity. +Limits are stored in the rate-limiter store named by the `saloon.rate_limiter.store` configuration value, or the rate limiter's default store when it is `null`. A connector or request may return another configured [store](/docs/{{version}}/rate-limiting#available-stores) name from `resolveRateLimitStore`. Fakes and cache hits do not consume capacity. Saloon [consumes each resource's policies together](/docs/{{version}}/rate-limiting#consuming-multiple-limits), subject to the documented Redis Cluster limitation. Connector and request limits are separate: the connector is admitted first, so its charge remains if the request's limits deny the operation. After waiting for capacity, Saloon checks the resource's cooldown again before retrying its policies. @@ -1804,20 +2232,20 @@ Saloon limiter names always begin with `saloon:`. Restricting the callback preve ### Server Cooldowns -When a rate-limited connector or request receives a 429 response with a valid `Retry-After` header, Saloon records the provider's cooldown before response middleware runs. A later operation checks this cooldown before consuming its configured admission policies. +When a rate-limited connector or request receives a 429 response, Saloon records the provider's cooldown before response middleware runs. The `Retry-After` header sets its length. If the header is missing or invalid, the cooldown lasts 60 seconds, while a delay that has already passed records no cooldown. A later operation checks this cooldown before consuming its configured admission policies. -By default, the connector or request class identifies the cooldown. If a provider applies cooldowns per account or credential, override `resolveRateLimitCooldownKey` with that stable provider identity: +By default, each connector or request has its own cooldown. If a provider applies cooldowns per account or credential, override `resolveRateLimitCooldownKey` with that stable provider identity: ```php protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string { - return static::class . ':account:' . $this->providerAccountId; + return 'account:' . $this->providerAccountId; } ``` Do not infer this key from a hostname or assume every provider limits by token. Use the boundary documented by the provider. -You may override `resolveRateLimitCooldown` to support a provider-specific response or clamp a provider-defined maximum: +You may override `resolveRateLimitCooldown` to support a provider-specific response or clamp a provider-defined maximum. Returning `null` records no cooldown: ```php use Hypervel\RateLimiter\AdmissionPolicy; @@ -1847,7 +2275,79 @@ class GitHubConnector extends Connector } ``` -A 429 response is returned through normal error handling. Recording a cooldown never resends the response recursively. If the normal retry policy requests another attempt, that attempt must first pass the recorded cooldown. +A 429 response is returned through normal error handling. Recording a cooldown never resends the response recursively. If the normal retry policy requests another attempt, that attempt must first pass the recorded cooldown. To wait out the cooldown and send the request again, retry 429 responses and wait for cooldowns: + +```php +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; +use Hypervel\RateLimiter\Cooldown; +use Hypervel\Saloon\Data\RetryPolicy; +use Hypervel\Saloon\Exceptions\Request\FatalRequestException; +use Hypervel\Saloon\Exceptions\Request\RequestException; + +protected function defaultRetryPolicy(): ?RetryPolicy +{ + return new RetryPolicy( + times: 2, + when: fn (FatalRequestException|RequestException $exception): bool => $exception instanceof RequestException + && $exception->status() === 429, + ); +} + +protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool +{ + return $policy instanceof Cooldown; +} +``` + + +### Sharing Limits + +Saloon records a resource's limits and cooldowns under its class name. When several connectors or requests use the same provider quota, override `resolveRateLimiterName` on each of them to return the same name: + +```php +protected function resolveRateLimiterName(): string +{ + return 'acme'; +} +``` + +Resources with the same name share a policy's state when they declare the same policy and use the same rate-limiter store and key scope. A 429 received by one of them pauses the others too, unless their cooldown keys differ. + + +### Inspecting Limits + +The `Saloon` facade's `inspectRateLimit` method checks a resource's policy or cooldown without consuming it, using the same store and limiter name as a request: + +```php +use Hypervel\RateLimiter\Cooldown; +use Hypervel\RateLimiter\Limit; +use Hypervel\Saloon\Facades\Saloon; + +$result = Saloon::inspectRateLimit($connector, Limit::perMinute(60)->by('github')); + +if ($result->remaining() <= $result->limit() * 0.2) { + // 20% or fewer of this minute's requests remain... +} + +$cooldown = Saloon::inspectRateLimit($connector, Cooldown::for(null)); +``` + +Pass the same policy your resource declares. Putting shared policies in a method keeps them identical. + + +### Disabling Rate Limits + +Override `rateLimitingEnabled` to skip a resource's limits for an operation. A disabled operation consumes no capacity, ignores active cooldowns, and records no cooldown from its response: + +```php +protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool +{ + return ! $pendingRequest->request() instanceof ImportRequest; +} +``` + +Disabling a request's limits does not disable its connector's limits. Returning no policies still applies cooldowns. ### Queued Requests @@ -1980,12 +2480,12 @@ Macros remain registered for the worker lifetime. Hypervel automatically clears ## Testing -Saloon provides a strict mock client, response sequences, fixtures, request recording, and PHPUnit assertions. Saloon fakes operate at the integration layer, before caching, admission policies, and Hypervel HTTP transport. +Saloon provides a strict mock client, response sequences, fixtures, request recording, and PHPUnit assertions. Saloon fakes operate at the integration layer, after the response cache is checked and before admission policies and Hypervel HTTP transport. ### Faking Responses -Use the `Saloon` facade to replace responses for the current test application: +Use the `Saloon` facade to fake responses for the current test application: ```php use App\Http\Integrations\GitHub\Requests\GetUser; @@ -2000,6 +2500,26 @@ Saloon::fake([ ]); ``` +The `make` method accepts a body, status code, and headers. An array body is encoded as JSON, while a string body is returned as it is: + +```php +MockResponse::make(['message' => 'Not Found'], 404, [ + 'Content-Type' => 'application/json', +]); +``` + +To simulate a failed connection, pass an exception to the mock response's `throw` method. Saloon reports a `ConnectionException` as a `FatalRequestException`, just like a real connection failure: + +```php +use Hypervel\Http\Client\ConnectionException; + +Saloon::fake([ + GetUser::class => MockResponse::make()->throw(new ConnectionException('Connection refused')), +]); +``` + +The fake belongs to the test application, so each test starts without one. Calling `fake` again adds responses to the same mock client, so a test may extend the fakes registered in its `setUp` method. To replace the global mock client instead, pass a `MockClient` instance to `fake`. + Responses may be matched by request class, connector class, or wildcard URL. Request matches take precedence over connector matches, followed by URL matches and sequence responses: ```php @@ -2010,9 +2530,26 @@ Saloon::fake([ ]); ``` +A URL pattern may leave out the scheme and host, such as `'api.github.com/repos/*'` or `'/user'`, and matches the URL with or without its query string. A pattern that includes a query string, such as `'/users?page=2'`, matches only that encoded query. Assertions and `allowStrayRequests` use the same patterns. + Each matching value may also be a callback that receives the pending request and returns a mock response or fixture. A lower-level `Http::fake()` still prevents a network request after the Saloon lifecycle reaches Hypervel's HTTP client. The response is recorded by the HTTP client and `isMocked()` returns false. When no Saloon mock client is active, Saloon facade assertions do not include the response. -You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. +You may attach a mock client to one request using `withMockClient`, or pass it as the second argument to `Connector::send`. An explicitly supplied client takes precedence over a request client, which takes precedence over the facade's global test client. Mock responses are matched after request middleware has run and the URL is final, so URL matches see any changes middleware made. + +A local mock client is useful when testing an SDK's requests directly. It records every request sent with it, so you may make assertions on it: + +```php +use Hypervel\Saloon\Http\Faking\MockClient; +use Hypervel\Saloon\Http\Faking\MockResponse; + +$mockClient = new MockClient([ + CreateIssue::class => MockResponse::make(['id' => 1], 201), +]); + +$github->send(new CreateIssue('Bug report'), $mockClient); + +$mockClient->assertSent(fn (CreateIssue $request) => $request->body() === ['title' => 'Bug report']); +``` Mock clients are strict by default. An unmatched request throws `NoMockResponseFoundException` instead of reaching the network. @@ -2060,13 +2597,14 @@ When no class or URL response matches, Saloon consumes the next sequence respons ### Assertions -The facade provides assertions for recorded Saloon requests: +The facade provides assertions for recorded Saloon requests. Responses served from the [response cache](#caching) are not recorded, because nothing was sent: ```php Saloon::assertSent(GetUser::class); Saloon::assertSent('https://api.github.com/users/*'); Saloon::assertNotSent(DeleteUser::class); Saloon::assertSentCount(1); +Saloon::assertSentCount(1, GetUser::class); Saloon::assertNothingSent(); ``` @@ -2094,7 +2632,11 @@ Saloon::fake([ ]); ``` -By default, fixtures are stored under `tests/Fixtures/Saloon`. Fixture names use portable path segments separated by forward slashes. Absolute paths, backslashes, empty segments, and `.` or `..` segments are rejected. +You may also create a fixture using `MockResponse::fixture('github/users/hypervel')`. By default, fixtures are stored as JSON files under `tests/Fixtures/Saloon`, so this fixture is written to `tests/Fixtures/Saloon/github/users/hypervel.json`. To record a fixture again, delete its file and run the test. + +Fixture names use portable path segments separated by forward slashes. Absolute paths, backslashes, empty segments, and `.` or `..` segments are rejected. Lowercase names avoid fixtures going missing when a test suite moves between case-insensitive and case-sensitive filesystems. + +When `Http::preventStrayRequests()` is active, recording a missing fixture is a stray request and throws. Allow the API's URLs while recording, such as by calling `Http::allowStrayRequests(['https://api.github.com/*'])`. To redact sensitive response data, extend `Fixture` and define header, JSON, or regular-expression replacement rules: @@ -2118,7 +2660,15 @@ class GitHubFixture extends Fixture } ``` -Invalid or failed regular expressions prevent the fixture from being written. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata. +Header rules apply to each value of a matching header, so a closure receives one value and returns its replacement. Invalid or failed regular expressions prevent the fixture from being written. The rules change the stored file, so the test that records a fixture still receives the real response. A fixture class may also override `defineName` to return its name, so it can be created without arguments. You may use `merge` or `through` to adjust a recorded JSON object or array during replay, and `withContext` to store additional fixture metadata. + +Mock responses and fixtures are cached like network responses. Once a cacheable request has been cached, later sends return the cached response instead of the next mock response or an updated fixture file, and a missing fixture is not recorded. Call `withoutCache` on the mock client to keep its requests from reading, writing, or invalidating the cache: + +```php +Saloon::fake([ + GetUser::class => new Fixture('github/users/hypervel'), +])->withoutCache(); +``` Tests may override the fixture directory and missing-fixture behavior: @@ -2127,7 +2677,7 @@ Saloon::fixturePath(base_path('tests/Fixtures/Api')); Saloon::throwOnMissingFixtures(); ``` -These are tests-only settings and are cleared when the test application is destroyed. +These are tests-only settings and are cleared when the test application is destroyed. To fail on missing fixtures in every run, such as in CI, set the `saloon.fixtures.throw_on_missing` configuration option instead. ## Publishing Configuration and Stubs @@ -2146,26 +2696,31 @@ You may publish the generator stubs using the `saloon-stubs` tag: php artisan vendor:publish --tag=saloon-stubs ``` -Set `saloon.integrations_path` to change where generated files are written. Set `saloon.integrations_namespace` when that path uses a custom Composer namespace. The path and namespace are configured independently; Saloon does not guess a namespace from an arbitrary filesystem path. +Set `saloon.integrations_path` to change where generated files are written. When the path is inside your application directory, generated classes follow it beneath your application's namespace, so `app_path('Integrations')` produces classes in `App\Integrations`. For a path outside the application directory, also set `saloon.integrations_namespace` to the namespace Composer loads from that path. ## Differences From Saloon -Hypervel Saloon keeps the connector, request, middleware, authentication, response, testing, OAuth 2, caching, pagination, and rate-limit concepts of Saloon while using Hypervel's framework services directly. +Hypervel Saloon keeps the connector, request, middleware, authentication, response, testing, OAuth 2, caching, pagination, and rate-limit concepts of Saloon while using Hypervel's framework services directly. When porting an existing integration, note the following differences: -- Hypervel HTTP is the only transport. Sender factories, configurable Guzzle senders, and process-global Saloon configuration are not included. -- Request customization uses Hypervel HTTP names such as `withHeader`, `withQueryParameters`, `withOptions`, `timeout`, `withToken`, and `retry`. -- Connectors contain stable defaults. Operation-specific authentication, caching, debugging, middleware, retries, and mocks belong to requests and pending requests. -- Coroutine-native pools replace promises and `sendAsync`. Paginator concurrency uses the same bounded pool. -- Cache, pagination, and rate limiting are included in `hypervel/saloon` and use Hypervel's cache, collections, coroutine, and rate-limiter services. +
+ +- The Laravel plugin and the cache, pagination, and rate-limit plugins are built in, using Hypervel's cache, collections, coroutine, and rate-limiter services. +- Hypervel HTTP is the only transport. Requests are sent through a named [HTTP connection](#http-connections) instead of a sender, and there is no process-global `Config` class; register global middleware through `Saloon::middleware()`. +- Requests and pending requests use the HTTP client's method names, such as `withHeader`, `withQueryParameters`, `withOptions`, `timeout`, and `withToken`, and their getters return plain values instead of upstream's repositories. +- Connectors are read-only, since one connector may be shared by concurrent requests. Operation-specific authentication, middleware, debugging, mocks, and retries belong to requests, the `send` call, or the connector's `boot` method. +- Retries use `retry` and `defaultRetryPolicy` instead of the `$tries`, `$retryInterval`, `$useExponentialBackoff`, and `$throwOnMaxTries` properties. +- Coroutine-native [pools](#concurrent-requests) replace `sendAsync` and promises. Paginator concurrency uses the same bounded pool. +- Responses extend Hypervel HTTP responses. Request exceptions extend the HTTP client's `RequestException` rather than `SaloonException`, so catch `RequestException` for failed responses. +- Mock clients are matched when the request is sent, after request middleware has run. Fixture settings are configured through the `Saloon` facade and configuration file, and application-wide stray-request protection uses `Http::preventStrayRequests()`, which also applies to fixture recording. +- OAuth 2 configuration is immutable, and authorization URLs return their paired state. +- NTLM authentication is not included. - Rate limits use Hypervel `AdmissionPolicy` instances instead of Saloon's mutable limit and store abstractions. -- OAuth 2 configuration is immutable, authorization URLs return their paired state, and OAuth 1 is not included. -- Saloon responses extend Hypervel HTTP responses rather than forwarding a selected subset of methods. -- Test fixture settings are configured through the `Saloon` facade instead of a process-global mock configuration object. -- Application-wide stray-request protection uses `Http::preventStrayRequests()`. Saloon mock clients separately control unmatched requests while they are active. -- The optional `xmlReader` response extension is not included. Use the built-in `xml` or `dom` methods instead. +- The `xmlReader` response method is not included. Use the `xml` or `dom` methods, or install [XML Wrangler](https://github.com/saloonphp/xml-wrangler) and pass `$response->toPsrResponse()` to `XmlReader::fromPsrResponse()`. + +
-These differences remove framework-neutral adapter layers while retaining the public concepts needed to build complete integrations and reusable SDKs for Hypervel. +The [package README](https://github.com/hypervel/components/blob/0.4/src/saloon/README.md) documents API differences and their Hypervel equivalents. ## Credits diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index b1bb8d71b6..84162e9c2a 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -618,6 +618,17 @@ public function getDispatcher(): ?Dispatcher return $this->dispatcher; } + /** + * Set the event dispatcher implementation. + * + * Boot or tests only. The dispatcher persists on the factory for the + * worker lifetime and receives every subsequent HTTP client event. + */ + public function setDispatcher(?Dispatcher $dispatcher): void + { + $this->dispatcher = $dispatcher; + } + /** * Get the array of global middleware. */ diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 77cd7c30dc..0eb4120273 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -1321,6 +1321,17 @@ protected function sendRequest(string $method, string $url, array $options = []) $mergedOptions = $this->normalizeRequestOptions($this->mergeOptions($requestOptions, $options)); + // Guzzle adds the multipart Content-Type, with the body's boundary, only when none is set. A content type + // without a boundary, such as one from asJson() or a connection default, cannot describe the body. + if (isset($mergedOptions['multipart']) && is_array($mergedOptions['headers'] ?? null)) { + $mergedOptions['headers'] = array_filter( + $mergedOptions['headers'], + static fn (array|string $value, string $name): bool => strcasecmp($name, 'Content-Type') !== 0 + || stripos(implode(', ', (array) $value), 'boundary=') !== false, + ARRAY_FILTER_USE_BOTH, + ); + } + return $this->buildClient()->{$clientMethod}($method, $url, $mergedOptions); } diff --git a/src/http/src/HttpServiceProvider.php b/src/http/src/HttpServiceProvider.php index 925b7ff66c..371614a80f 100644 --- a/src/http/src/HttpServiceProvider.php +++ b/src/http/src/HttpServiceProvider.php @@ -6,6 +6,8 @@ use Http\Discovery\ClassDiscovery; use Hypervel\Context\RequestContext; +use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Core\Events\BeforeServerFork; use Hypervel\Http\Client\Factory; use Hypervel\Http\Discovery\GuzzlePsr18Strategy; @@ -20,6 +22,7 @@ public function register(): void { $this->registerPsr18Discovery(); $this->registerRequestFactory(); + $this->registerClientEventRebindHandler(); } /** @@ -78,4 +81,16 @@ protected function registerRequestFactory(): void ?? Request::create($app->make('config')->get('app.url') ?? 'http://localhost'); }); } + + /** + * Pass a replaced event dispatcher, such as an event fake, to the resolved HTTP client factory. + */ + protected function registerClientEventRebindHandler(): void + { + $this->app->rebinding('events', function (Container $container, Dispatcher $events): void { + if ($container->resolved(Factory::class)) { + $container->make(Factory::class)->setDispatcher($events); + } + }); + } } diff --git a/src/rate-limiter/README.md b/src/rate-limiter/README.md index 968620cd03..89a9c34161 100644 --- a/src/rate-limiter/README.md +++ b/src/rate-limiter/README.md @@ -6,7 +6,7 @@ Documentation: https://hypervel.org/docs/rate-limiting ## Differences From Laravel - Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and `Hypervel\RateLimiter` namespace instead of Laravel's Cache component. -- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `SlidingWindow` defines a weighted sliding window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class. +- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `CalendarWindow` defines a window that resets at the start of each minute, hour, day or month, `SlidingWindow` defines a weighted sliding window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class. - The `consume`, `consumeMany`, `inspect`, `attempt`, `recordFailure`, and `clear` methods replace Laravel's split primitive counter API. - Rate limit keys are always hashed and include the rate limit type, its stable algorithm settings, and its global scope. Cost and callbacks do not affect identity. Changing identity settings starts new state, and `clear()` must receive the same settings that created the state. - Weighted denials report the actual unused capacity. The `attempt()` method consumes before invoking its callback and retains the charge if the callback throws. diff --git a/src/rate-limiter/src/CalendarWindow.php b/src/rate-limiter/src/CalendarWindow.php new file mode 100644 index 0000000000..b3ae62dd7e --- /dev/null +++ b/src/rate-limiter/src/CalendarWindow.php @@ -0,0 +1,272 @@ +at = strlen($at) === 5 ? $at . ':00' : $at; + + if ($period !== 'day' && $this->at !== self::MIDNIGHT) { + throw new InvalidRateLimitException('Only daily calendar windows may reset at a time other than midnight.'); + } + + $this->zone = new DateTimeZone($timezone ?? date_default_timezone_get()); + $this->timezone = $this->zone->getName(); + + parent::__construct($key, $cost, $global, $afterCallback, $responseCallback); + } + + /** + * Create a new limit that resets at the start of every minute. + */ + public static function perMinute(int $maxAttempts): static + { + return new static($maxAttempts, 'minute'); + } + + /** + * Create a new limit that resets at the start of every hour. + */ + public static function perHour(int $maxAttempts): static + { + return new static($maxAttempts, 'hour'); + } + + /** + * Create a new limit that resets every day at the given local time. + */ + public static function perDay(int $maxAttempts, string $at = self::MIDNIGHT): static + { + return new static($maxAttempts, 'day', $at); + } + + /** + * Create a new limit that resets at the start of every month. + */ + public static function perMonth(int $maxAttempts): static + { + return new static($maxAttempts, 'month'); + } + + /** + * Set the timezone whose clock and calendar the window follows. + */ + public function timezone(string $timezone): static + { + return new static( + maxAttempts: $this->maxAttempts, + period: $this->period, + at: $this->at, + timezone: $timezone, + key: $this->key, + cost: $this->cost, + global: $this->global, + afterCallback: $this->afterCallback, + responseCallback: $this->responseCallback, + ); + } + + /** + * Get the start and end of the window containing the given time, in epoch microseconds. + * + * The end is always later than the given time. + * + * @return array{int, int} + */ + public function window(int $nowMicroseconds): array + { + $now = intdiv($nowMicroseconds, 1_000_000); + + [$start, $end] = match ($this->period) { + 'minute' => $this->clockWindow($now, 60), + 'hour' => $this->clockWindow($now, 3_600), + 'day' => $this->dailyWindow($now), + 'month' => $this->monthlyWindow($now), + }; + + if ($end > intdiv(self::MAX_INTEGER, 1_000_000)) { + throw new InvalidRateLimitException('The rate limiter timestamp exceeds the supported integer range.'); + } + + return [$start * 1_000_000, $end * 1_000_000]; + } + + /** + * Create a copy with the given shared policy values. + */ + protected function newInstance( + string $key, + int $cost, + bool $global, + ?Closure $afterCallback, + ?Closure $responseCallback, + ): static { + return new static( + maxAttempts: $this->maxAttempts, + period: $this->period, + at: $this->at, + timezone: $this->timezone, + key: $key, + cost: $cost, + global: $global, + afterCallback: $afterCallback, + responseCallback: $responseCallback, + ); + } + + /** + * Get the minute or hour window containing the given timestamp. + * + * @return array{int, int} + */ + private function clockWindow(int $now, int $length): array + { + $offset = $this->offsetAt($now); + $start = $now - ($now + $offset) % $length; + $end = $start + $length; + + if ($this->offsetAt($start) === $offset && $this->offsetAt($end) === $offset) { + return [$start, $end]; + } + + // An offset change that is not a whole number of windows, such as Lord Howe + // Island's half hour, moves the local boundaries. Ending or starting the window + // at the change keeps it from overlapping its neighbor. + /** @var list $transitions */ + $transitions = $this->zone->getTransitions($start, $end); + + foreach (array_slice($transitions, 1) as $transition) { + if ($transition['ts'] <= $now) { + $start = max($start, $transition['ts']); + } else { + $end = min($end, $transition['ts']); + + break; + } + } + + return [$start, $end]; + } + + /** + * Get the daily window containing the given timestamp. + * + * @return array{int, int} + */ + private function dailyWindow(int $now): array + { + $date = $this->localDate($now); + $today = $this->resolveLocalTime($date); + + return $today <= $now + ? [$today, $this->resolveLocalTime($date->addDay())] + : [$this->resolveLocalTime($date->subDay()), $today]; + } + + /** + * Get the monthly window containing the given timestamp. + * + * @return array{int, int} + */ + private function monthlyWindow(int $now): array + { + $month = $this->localDate($now)->startOfMonth(); + + return [$this->resolveLocalTime($month), $this->resolveLocalTime($month->addMonth())]; + } + + /** + * Get the local calendar date of a timestamp as a UTC midnight. + */ + private function localDate(int $timestamp): CarbonImmutable + { + return CarbonImmutable::createFromFormat( + '!Y-m-d', + CarbonImmutable::createFromTimestamp($timestamp, $this->zone)->format('Y-m-d'), + 'UTC', + ); + } + + /** + * Resolve the reset time on a local date to a timestamp. + * + * PHP's date parser picks a zone-dependent occurrence of a time repeated by a + * clock change, so each possible offset is checked and the first occurrence wins. + * A time skipped by a clock change moves forward by the change. + */ + private function resolveLocalTime(CarbonImmutable $date): int + { + $wall = $date->setTimeFromTimeString($this->at)->getTimestamp(); + $before = $this->offsetAt($wall - 86_400); + $instants = []; + + foreach ([$before, $this->offsetAt($wall + 86_400)] as $offset) { + if ($this->offsetAt($wall - $offset) === $offset) { + $instants[] = $wall - $offset; + } + } + + return $instants === [] ? $wall - $before : min($instants); + } + + /** + * Get the timezone's UTC offset in seconds at a timestamp. + */ + private function offsetAt(int $timestamp): int + { + return $this->zone->getOffset(CarbonImmutable::createFromTimestamp($timestamp)); + } +} diff --git a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php index 9770b01958..f589682935 100644 --- a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php +++ b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php @@ -7,6 +7,7 @@ use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Backoff; use Hypervel\RateLimiter\BackoffResult; +use Hypervel\RateLimiter\CalendarWindow; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\CooldownResult; use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; @@ -32,7 +33,7 @@ protected function calculateConsume( int &$expiresAt, ): LimitResult { return match (true) { - $policy instanceof Limit => $this->calculateFixedWindow( + $policy instanceof Limit, $policy instanceof CalendarWindow => $this->calculateFixedWindow( $policy, $now, $value, @@ -123,7 +124,7 @@ protected function calculateInspection( int $expiresAt, ): LimitResult|BackoffResult|CooldownResult { return match (true) { - $policy instanceof Limit => $this->calculateFixedWindow( + $policy instanceof Limit, $policy instanceof CalendarWindow => $this->calculateFixedWindow( $policy, $now, $value, @@ -220,10 +221,10 @@ protected function calculateFailure( } /** - * Calculate a fixed-window decision. + * Calculate a fixed-window or calendar-window decision. */ private function calculateFixedWindow( - Limit $policy, + Limit|CalendarWindow $policy, int $now, int &$value, int &$secondaryValue, @@ -240,10 +241,9 @@ private function calculateFixedWindow( $value = $policy->cost; $secondaryValue = 0; - $expiresAt = $this->addExact( - $now, - $this->secondsToMicroseconds($policy->decaySeconds), - ); + $expiresAt = $policy instanceof CalendarWindow + ? $policy->window($now)[1] + : $this->addExact($now, $this->secondsToMicroseconds($policy->decaySeconds)); return new LimitResult( true, @@ -564,10 +564,10 @@ private function resetExpiredState( } /** - * Validate fixed-window state loaded from a store. + * Validate fixed-window or calendar-window state loaded from a store. */ private function validateFixedWindowState( - Limit $policy, + Limit|CalendarWindow $policy, int $value, int $secondaryValue, int $expiresAt, diff --git a/src/rate-limiter/src/KeyResolver.php b/src/rate-limiter/src/KeyResolver.php index 2a7772324b..5d1a318107 100644 --- a/src/rate-limiter/src/KeyResolver.php +++ b/src/rate-limiter/src/KeyResolver.php @@ -72,6 +72,12 @@ protected function policyIdentity(AdmissionPolicy|Backoff|Cooldown $policy): str . $this->segment('max-attempts', (string) $policy->maxAttempts) . $this->segment('decay-seconds', (string) $policy->decaySeconds) . $this->segment('global', $policy->global ? '1' : '0'), + $policy instanceof CalendarWindow => $this->segment('policy', 'calendar-window') + . $this->segment('max-attempts', (string) $policy->maxAttempts) + . $this->segment('period', $policy->period) + . $this->segment('at', $policy->at) + . $this->segment('timezone', $policy->timezone) + . $this->segment('global', $policy->global ? '1' : '0'), $policy instanceof SlidingWindow => $this->segment('policy', 'sliding-window') . $this->segment('max-attempts', (string) $policy->maxAttempts) . $this->segment('window-seconds', (string) $policy->windowSeconds) diff --git a/src/rate-limiter/src/Limiter.php b/src/rate-limiter/src/Limiter.php index 6e92642638..b2a41872ab 100644 --- a/src/rate-limiter/src/Limiter.php +++ b/src/rate-limiter/src/Limiter.php @@ -203,6 +203,7 @@ protected function validateAdmission(AdmissionPolicy $policy): void { $duration = match (true) { $policy instanceof Limit => $this->validateFixedWindow($policy), + $policy instanceof CalendarWindow => $this->validateCalendarWindow($policy), $policy instanceof SlidingWindow => $this->validateSlidingWindow($policy), $policy instanceof LeakyBucket => $this->validateLeakyBucket($policy), default => throw new InvalidRateLimitException(sprintf( @@ -228,6 +229,22 @@ protected function validateFixedWindow(Limit $policy): int return $policy->decaySeconds * 1_000_000; } + /** + * Validate a calendar-window policy's capacity. + * + * Calendar windows vary in length, so this returns no duration and stores range-check each window's end when they create it. + */ + protected function validateCalendarWindow(CalendarWindow $policy): int + { + if ($policy->cost > $policy->maxAttempts) { + throw new InvalidRateLimitException( + 'The rate limit cost may not exceed the calendar-window capacity.' + ); + } + + return 0; + } + /** * Validate a sliding-window policy and return its maximum state duration. */ diff --git a/src/rate-limiter/src/RedisStore.php b/src/rate-limiter/src/RedisStore.php index eca046a68e..1dc82b5fcb 100644 --- a/src/rate-limiter/src/RedisStore.php +++ b/src/rate-limiter/src/RedisStore.php @@ -8,6 +8,7 @@ use Hypervel\RateLimiter\Contracts\Store; use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; use Hypervel\Redis\RedisConnection; +use Hypervel\Support\CarbonImmutable; use UnexpectedValueException; class RedisStore implements Store @@ -60,6 +61,24 @@ class RedisStore implements Store redis.call('INCRBY', key, state.current - state.original) end end +LUA; + + // PHP calculates calendar boundaries before the script runs, so a new window + // only starts when Redis's clock is inside them. Otherwise calculate_calendar + // returns nil, and the script replies with Redis's time before any write so + // PHP can recalculate the window. + private const string CALENDAR_WINDOW_FUNCTIONS = <<<'LUA' +local function calculate_calendar(state, policy, consume, now) + local duration = 0 + if consume and state.fresh then + local nowMilliseconds = math.floor(now / 1000) + if nowMilliseconds < policy[3] or nowMilliseconds >= policy[4] then + return nil + end + duration = policy[4] - nowMilliseconds + end + return calculate_fixed(state, {policy[1], policy[2], duration}, consume) +end LUA; private const string SLIDING_WINDOW_FUNCTIONS = <<<'LUA' @@ -206,10 +225,12 @@ class RedisStore implements Store LUA; private const string CONSUME_MANY_SCRIPT = self::FIXED_WINDOW_FUNCTIONS . "\n" - . self::SLIDING_WINDOW_FUNCTIONS . "\n" . self::LEAKY_BUCKET_FUNCTIONS . <<<'LUA' + . self::CALENDAR_WINDOW_FUNCTIONS . "\n" . self::SLIDING_WINDOW_FUNCTIONS . "\n" + . self::LEAKY_BUCKET_FUNCTIONS . <<<'LUA' local algorithms = { fixed = {load_fixed, calculate_fixed, persist_fixed}, + calendar = {load_fixed, calculate_calendar, persist_fixed}, sliding = {load_sliding, calculate_sliding, persist_sliding}, leaky = {load_leaky, calculate_leaky, persist_leaky} } @@ -222,11 +243,11 @@ class RedisStore implements Store local policy = {tonumber(ARGV[offset + 2]), tonumber(ARGV[offset + 3]), tonumber(ARGV[offset + 4]), tonumber(ARGV[offset + 5])} policies[index] = {algorithm, policy} + if (kind == 'leaky' or kind == 'calendar') and not now then + local time = redis.call('TIME') + now = tonumber(time[1]) * 1000000 + tonumber(time[2]) + end if not states[key] then - if kind == 'leaky' and not now then - local time = redis.call('TIME') - now = tonumber(time[1]) * 1000000 + tonumber(time[2]) - end local state = algorithm[1](key, policy, now) originals[key] = {} for field, value in pairs(state) do @@ -234,7 +255,10 @@ class RedisStore implements Store end states[key] = state end - results[index] = algorithm[2](states[key], policy, true) + results[index] = algorithm[2](states[key], policy, true, now) + if not results[index] then + return {'stale', now} + end if results[index][1] == 0 then -- No writes have happened. Report capacity without the discarded charges. for position = 1, index do @@ -262,7 +286,10 @@ class RedisStore implements Store local policy = {tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4]), tonumber(ARGV[5])} local state = load(KEYS[1], policy, now) local consume = ARGV[1] == 'consume' -local result = calculate(state, policy, consume) +local result = calculate(state, policy, consume, now) +if not result then + return {'stale', now} +end if consume and result[1] == 1 then persist(KEYS[1], state) end @@ -273,6 +300,11 @@ class RedisStore implements Store . "\nlocal load, calculate, persist = load_fixed, calculate_fixed, persist_fixed\nlocal now\n" . self::SCALAR_ADMISSION_SCRIPT; + private const string CALENDAR_WINDOW_SCRIPT = self::FIXED_WINDOW_FUNCTIONS . "\n" . self::CALENDAR_WINDOW_FUNCTIONS + . "\nlocal load, calculate, persist = load_fixed, calculate_calendar, persist_fixed\n" + . "local time = redis.call('TIME')\nlocal now = tonumber(time[1]) * 1000000 + tonumber(time[2])\n" + . self::SCALAR_ADMISSION_SCRIPT; + private const string SLIDING_WINDOW_SCRIPT = self::SLIDING_WINDOW_FUNCTIONS . "\nlocal load, calculate, persist = load_sliding, calculate_sliding, persist_sliding\nlocal now\n" . self::SCALAR_ADMISSION_SCRIPT; @@ -453,24 +485,28 @@ public function consumeMany(array $policies): array return $this->consumeManyOnCluster($connection, $policies); } - $arguments = []; - $capacities = []; + $now = null; - foreach ($policies as $entry) { - [$kind, $parameters, $capacity] = $this->admissionParameters($entry['policy']); - $arguments[] = $kind; - $capacities[] = $capacity; + do { + $arguments = []; + $capacities = []; - foreach ($parameters as $parameter) { - $arguments[] = $parameter; - } + foreach ($policies as $entry) { + [$kind, $parameters, $capacity] = $this->admissionParameters($entry['policy'], $now); + $arguments[] = $kind; + $capacities[] = $capacity; + + foreach ($parameters as $parameter) { + $arguments[] = $parameter; + } - if (count($parameters) === 3) { - $arguments[] = '0'; + if (count($parameters) === 3) { + $arguments[] = '0'; + } } - } - $tuples = $connection->evalWithShaCache(self::CONSUME_MANY_SCRIPT, array_column($policies, 'key'), $arguments); + $tuples = $connection->evalWithShaCache(self::CONSUME_MANY_SCRIPT, array_column($policies, 'key'), $arguments); + } while (($now = $this->staleCalendarTime($tuples)) !== null); if (! is_array($tuples) || ! array_is_list($tuples) || $tuples === [] || count($tuples) > count($policies)) { throw new UnexpectedValueException('Redis returned a malformed rate limiter group result.'); @@ -562,17 +598,22 @@ protected function executeAdmission( string $mode, ?RedisConnection $connection = null, ): LimitResult { - [$kind, $parameters, $capacity] = $this->admissionParameters($policy); - $script = match ($kind) { - 'fixed' => self::FIXED_WINDOW_SCRIPT, - 'sliding' => self::SLIDING_WINDOW_SCRIPT, - 'leaky' => self::LEAKY_BUCKET_SCRIPT, - }; - $arguments = [$mode, ...$parameters]; - - $result = $connection === null - ? $this->execute($script, $key, $arguments) - : $connection->evalWithShaCache($script, [$key], $arguments); + $now = null; + + do { + [$kind, $parameters, $capacity] = $this->admissionParameters($policy, $now); + $script = match ($kind) { + 'fixed' => self::FIXED_WINDOW_SCRIPT, + 'calendar' => self::CALENDAR_WINDOW_SCRIPT, + 'sliding' => self::SLIDING_WINDOW_SCRIPT, + 'leaky' => self::LEAKY_BUCKET_SCRIPT, + }; + $arguments = [$mode, ...$parameters]; + + $result = $connection === null + ? $this->execute($script, $key, $arguments) + : $connection->evalWithShaCache($script, [$key], $arguments); + } while (($now = $this->staleCalendarTime($result)) !== null); return $this->limitResult($result, $capacity); } @@ -580,14 +621,24 @@ protected function executeAdmission( /** * Return the algorithm, script parameters, and capacity for an admission policy. * - * @return array{'fixed'|'leaky'|'sliding', list, int} + * A calendar window is calculated at the given epoch microsecond, or at the current time. + * + * @return array{'calendar'|'fixed'|'leaky'|'sliding', list, int} */ - protected function admissionParameters(AdmissionPolicy $policy): array + protected function admissionParameters(AdmissionPolicy $policy, ?int $nowMicroseconds = null): array { return match (true) { $policy instanceof Limit => ['fixed', [ (string) $policy->cost, (string) $policy->maxAttempts, (string) ($policy->decaySeconds * 1000), ], $policy->maxAttempts], + $policy instanceof CalendarWindow => ['calendar', [ + (string) $policy->cost, + (string) $policy->maxAttempts, + ...array_map( + static fn (int $boundary): string => (string) intdiv($boundary, 1000), + $policy->window($nowMicroseconds ?? (int) CarbonImmutable::now()->getPreciseTimestamp(6)), + ), + ], $policy->maxAttempts], $policy instanceof SlidingWindow => ['sliding', [ (string) $policy->cost, (string) $policy->maxAttempts, (string) $policy->windowSeconds, ], $policy->maxAttempts], @@ -673,6 +724,22 @@ protected function limitResult(mixed $result, int $expectedLimit): LimitResult ); } + /** + * Get Redis's epoch microseconds from a stale calendar-window reply, or null for any other reply. + */ + protected function staleCalendarTime(mixed $result): ?int + { + if (! is_array($result) || ($result[0] ?? null) !== 'stale') { + return null; + } + + if (count($result) !== 2 || ! is_int($result[1]) || $result[1] < 1) { + throw new UnexpectedValueException('Redis returned a malformed rate limiter result.'); + } + + return $result[1]; + } + /** * Validate and return a five-integer Redis result tuple. * diff --git a/src/saloon/README.md b/src/saloon/README.md index d3573c8b9d..678328265b 100644 --- a/src/saloon/README.md +++ b/src/saloon/README.md @@ -2,8 +2,52 @@ Documentation: https://hypervel.org/docs/saloon -## Differences From Laravel +## Differences From Saloon + +Requests and pending requests use fluent methods in the style of Hypervel's HTTP client, such as `withHeaders`, `withoutHeader`, `withQueryParameters`, `withOptions` and `delay`, and their getters return plain values. Upstream's `ArrayStore` and `IntegerStore` objects (`headers()->add()`, `query()->remove()`, `delay()->set()`) are not available: `headers()` returns an array, `queryParameters()` and `delayMilliseconds()` replace the `query()` and `delay()` getters, and upstream's `config()` and `defaultConfig()` are `options()` and `defaultOptions()`. Getters drop upstream's `get` prefix, so requests have `method()` and `mockClient()`, and pending requests have `connector()`, `request()`, `method()` and `fakeResponse()`; compare `fakeResponse()` with `null` in place of `hasFakeResponse()`. Pending requests change the method and URL with `withMethod` and `withUrl` instead of `setMethod` and `setUrl`, `uri()` replaces `getUrl()`, including the query string, and `resolveResponseClass($response)` returns the custom response class, or `null` for the default, in place of `getResponseClass()`. + +Connectors are read-only. Their headers, query parameters, options, body, authenticator and delay come from their `default*` methods and constructor values, because a connector may be shared by concurrent requests. Apply per-operation values to the request, or change connector defaults on the pending request in a hook or middleware. Connectors have no `middleware()` or debugging methods; register middleware and debuggers on the pending request from `boot()` or a plugin. For the same reason, requests and connectors allow absolute endpoints by overriding `allowsBaseUrlOverride()`; upstream's public `$allowBaseUrlOverride` property has no effect. `HasApiVersion` has no `setApiVersion()`: assign `$apiVersion` in the constructor or override `getApiVersion()`. + +There is no process-global `Config` class and no pluggable sender. Global middleware is `Saloon::middleware()`. Requests are sent through Hypervel's HTTP client on a named connection, `saloon.connection.name` or the connector's `resolveHttpConnection()`, so `sender()`, `defaultSender()`, sender resolvers, handler stacks and the sender's factory collection are not available; use the connection's options, `Http::globalMiddleware()`, or Saloon's PSR request hooks and middleware. Body repositories' `toStream()` and fake responses' `createPsrResponse()` take no PSR factories. Telescope's HTTP client watcher records Saloon requests with the `saloon` tag in place of the plugin's Telescope middleware, so fakes and cache hits are not recorded. The plugin's Pulse and Nightwatch middleware are not included, since Hypervel has neither package; OpenTelemetry's HTTP client instrumentation traces Saloon requests like other HTTP client requests. The plugin's `ide.json` code-generation metadata for the Laravel Idea PhpStorm plugin is not included; generate classes with the `saloon:*` commands (see [Generating Integrations](https://hypervel.org/docs/saloon#generating-integrations)). Prevent stray requests with `Http::preventStrayRequests()`, which throws the HTTP client's `StrayRequestException`, including for a request that would record a missing fixture, so allow the API's URLs with `Http::allowStrayRequests()` while recording. Fake sleeping with `Sleep::fake()`, and control the clock through the framework `Date` clock. + +`createPendingRequest()` takes no mock client. Mock clients are chosen and matched when the request is sent, after request middleware has run and the URL is final, so middleware does not see a mock response and pending requests have no `hasMockClient()` or `getMockClient()`. + +Connectors are read-only, so they have no `withMockClient()`: attach a mock client to the request, pass it to `send()`, or use `Saloon::fake()`, which also backs `MockClient::global()`. Mock clients use the HTTP client's names. `recorded()` returns the recorded responses as a collection, optionally filtered by a callback, in place of `getRecordedResponses()`, `findResponseByRequest()` and `findResponseByRequestUrl()`; `lastRequest()`, `lastPendingRequest()` and `lastResponse()` drop the `get` prefix; and `match()` replaces `guessNextResponse()` and `getNextFromSequence()`. The deprecated `assertSentJson()` is not included. `Saloon::fake()` also accepts a `MockClient` to install as the global client, and `Saloon::mockClient()` returns `null` until a fake is active instead of creating one. The plugin's deprecated response recorder (`record()`, `stopRecording()`, `isRecording()`, `recordResponse()`, `getRecordedResponses()` and `getLastRecordedResponse()`) and its `MockClient` subclass are not included: `recorded()` lists the responses recorded by the active mock client, and the `SentSaloonRequest` event observes every sent response. There is no `MockConfig`: fixtures live under the `saloon.fixtures.path` configuration, which tests override with `Saloon::fixturePath()` and `Saloon::throwOnMissingFixtures()`, and are read and written through the framework `Filesystem` rather than upstream's `Storage` helper. `getFixturePath()` returns the absolute path, fixture names separate directories with forward slashes, a missing directory is created when a fixture is first recorded, and recorded fixtures list every header value. See [Testing](https://hypervel.org/docs/saloon#testing). + +Middleware contracts are typed: `RequestMiddleware::__invoke()` returns `?FakeResponse` and `ResponseMiddleware::__invoke()` returns `?Response`, so a ported middleware class declared `: void` returns `null` instead. A pending request returned from request middleware is ignored; change the one it receives. Pipelines expose `pipes()`, `requestPipeline()`, `responsePipeline()` and `fatalPipeline()` instead of upstream's `get` methods. Connector middleware is registered from `boot()` and runs after global and plugin middleware but before the request's own middleware. + +Retries use `retry($times, $sleepMilliseconds, $when, $throw)` and a `defaultRetryPolicy()` method on requests and connectors instead of upstream's `$tries`, `$retryInterval`, `$throwOnMaxTries` and `$useExponentialBackoff` properties, `handleRetry()` methods and `send()`'s `$handleRetry` argument; use a delay closure for exponential backoff. A request's policy replaces the connector's entirely, so the two `when` conditions are not combined. The `when` callback receives the failed attempt's pending request; change `$pendingRequest->request()` to affect the next attempt. Fewer than one attempt or a negative interval throws instead of being raised to the minimum. Upstream's deprecated `sendAndRetry()` is not available. Delays apply to mocked responses as upstream, but not to cache hits. See [Retries](https://hypervel.org/docs/saloon#retries). + +Pools send requests concurrently through coroutines instead of promises, so there is no `sendAsync()`, pending requests have no `isAsynchronous()` or `setAsynchronous()`, and pools accept requests but not promises. A pool's `send()` waits for every request and returns the successful responses keyed and ordered as the requests were given; `process()` sends them without keeping the responses. Concurrency is a positive integer: upstream's callable and unlimited `0` forms are not supported, because the coroutine pool has a fixed number of slots. Handlers receive the response or exception and the request's key, without upstream's aggregate promise. Ending a request generator stops further requests, but a request already waiting for a slot still starts and running requests are not canceled. Every response that `toException()` treats as failed, including through custom failure hooks, goes to the exception handler; upstream's real asynchronous path checks them only after Guzzle rejects a transfer. Unhandled request failures, handler failures and invalid items throw `PoolException` once every started request has finished, instead of being ignored or ending the pool early. `requests()` replaces `getRequests()` and calls a request callback each time the pool is sent, so a callback that returns a new iterable lets the pool be sent again. See [Concurrent Requests](https://hypervel.org/docs/saloon#concurrent-requests). + +Caching uses Hypervel's cache stores. `Cacheable` declares `cacheFor()` and `cacheStore()` instead of upstream's `resolveCacheDriver()` and `cacheExpiryInSeconds()`, so there is no `Driver` contract and no Flysystem, PSR or Laravel driver; add other caches as framework cache stores. `CachingException` replaces `HasCachingException`. Connectors are read-only, so they implement `Cacheable` without the `HasCaching` request controls and override `cacheKey()` and `cacheableMethods()`, which replaces `getCacheableMethods()`. GET, HEAD, OPTIONS and QUERY requests are cacheable by default. There is no `CacheKeyHelper`: a custom `cacheKey()` replaces the default request identity, and `Saloon::resolveCacheScopeUsing()` adds a scope, such as the tenant, to every key. Cached entries are `CachedResponse` values holding the status, headers and body, and they expire through the cache store, so `CacheMiddleware`, `CacheRecorderMiddleware`, `hasExpired()` and `getFakeResponse()` are not included. See [Caching](https://hypervel.org/docs/saloon#caching). + +Paginators set their values with `maxPages()`, `perPageLimit()` and `startPage()`, and `totalResults()`, `request()` and `currentPage()` replace `getTotalResults()`, `getOriginalRequest()` and `getCurrentPage()`. The deprecated `getPage()` and `$page` are not included; custom `applyPagination()` methods read the protected `$pageNumber`. Page and offset paginators send their pages concurrently with `pool()`, which sends the first page, then the rest through a coroutine pool, and returns the responses keyed by page position, so there is no `async()`, `isAsyncPaginationEnabled()` or `HasAsyncPagination` and no promises to iterate. Cursor paginators can't be pooled because each cursor comes from the previous response. Iterator keys and `currentPage()` count from zero at the start page, and `maxPages()` counts pages from the start page. Upstream starts keys at `startPage - 1` and compares `maxPages` with page numbers, so `startPage(3)` with `maxPages(2)` returns no pages there. See [API Pagination](https://hypervel.org/docs/saloon#api-pagination). + +Rate limits use Hypervel's rate limiter. `resolveRateLimits()` returns framework admission policies for the pending request instead of upstream's `resolveLimits()` and `getLimits()` with `Limit` and `Bucket` objects, and state lives in the rate-limiter store chosen by `resolveRateLimitStore()` or `saloon.rate_limiter.store`, so there is no `RateLimitStore` contract, `rateLimitStore()` or plugin store. `LimitException` is not included; invalid policies and store state throw the rate limiter's own exceptions. `Limit::perMinute()` and the other `Limit` factories replace upstream's `every*()` intervals, `LeakyBucket` replaces `Bucket`, and `CalendarWindow` replaces `untilEndOfMinute()`, `untilEndOfHour()`, `untilMidnightTonight()`, `everyDayUntil()` and `untilEndOfMonth()`. Set a lower limit in place of `Limit::allow()`'s threshold. The rate limiter's Redis, Swoole, database and worker-array stores replace upstream's stores. There are no file, Laravel cache or PSR cache stores, since a general cache can't apply these policies atomically, and no Predis store, since the Redis store uses the framework's PhpRedis connections. Identical policies share one state and each charges it, rather than throwing upstream's duplicate-name exception. `waitForRateLimits($policy, $result)` replaces `sleep()` on each limit and the `handleExceededLimit()` and `throwLimitException()` hooks, `resolveRateLimiterName()` replaces `getLimiterPrefix()` (use `by()` keys in place of `name()` for per-user limits), and Saloon fakes and cache hits consume no capacity. A 429 records a cooldown, 60 seconds when `Retry-After` is missing or invalid, through `resolveRateLimitCooldown()` in place of `handleTooManyAttempts()`, `getTooManyAttemptsLimiter()`, `Limit::custom()`, `RetryAfterHelper` and `$detectTooManyAttempts`; override it to read other responses, or return `null` to record nothing. The 429 goes through normal response and error handling, and an active cooldown applies to later requests. Nothing is resent automatically, so combine `waitForRateLimits()` with a retry policy to wait and send again. + +Connectors are read-only, so `rateLimitingEnabled(PendingRequest)` replaces `useRateLimitPlugin()` and `$rateLimitingEnabled`, and `Saloon::inspectRateLimit()` replaces `hasReachedRateLimit()` and `getExceededLimit()`; compare `remaining()` with `limit()` in place of a threshold. `RateLimitReachedException` exposes `policy()` and `result()` instead of `getLimit()`, and the job middleware is `Queue\ReleaseOnRateLimit` instead of `Helpers\ApiRateLimited`. See [Rate Limiting](https://hypervel.org/docs/saloon#rate-limiting). + +A custom response resolver receives the base response, so override `resolveResponseClass(Response $response)` instead of upstream's method without arguments. + +Responses extend Hypervel's HTTP client response and keep its methods. Their getters drop upstream's `get` prefix (`pendingRequest()`, `connector()`, `request()`, `fakeResponse()`), and `toPsrRequest()` and `toPsrResponse()` replace `getPsrRequest()` and `getPsrResponse()`. `fromResponse()` creates a response from an HTTP client response in place of `fromPsrResponse()`, and response classes override the HTTP client's `newRequestException()` in place of `createException()`. `withBufferedBody()` is not included, since `body()` buffers a stream that can't be rewound, and neither is `getSenderException()`, since an error status never throws in the transport. `header()` returns the header line, an empty string when the header is missing. `headers()` lists every value. `json()` returns `null` for an empty body and uses the HTTP client's configurable decoding flags instead of `JSON_THROW_ON_ERROR`. `object()` takes decoding flags instead of a key, so use `data_get($response->object(), $key)` for keyed access. `xmlReader()` is not included: it only wraps XML Wrangler, whose collection helpers need Laravel's collections and whose dependencies need the `xsl` and `bcmath` extensions. Use `xml()` or `dom()`, or install XML Wrangler and call `XmlReader::fromPsrResponse($response->toPsrResponse())`. + +Request exceptions extend the HTTP client's `RequestException`, so they use its message format and constructor, and expose `response()`, `pendingRequest()`, `status()` and `body()` instead of upstream's getters; use `response()->reason()` in place of `getStatusMessage()`. They are not `SaloonException`s, so catch `RequestException` for failed responses. `FatalRequestException` has `pendingRequest()` in place of `getPendingRequest()`. `getRequestException(Response $response)` must return a Saloon request exception and receives no sender exception; override an exception's `prepareMessage()` to change its message. A transfer that fails after the response headers arrive throws `FatalRequestException` whatever its status, rather than returning the partial response. Debugging with `die: true` exits, which throws Swoole's `ExitException` inside a coroutine, so there is no `Debugger::$dieHandler`. + +Every request supports a body, so there is no `HasBody` contract to implement. The body traits set the default body, `body()` returns its value rather than a repository, and `withBody`, `asJson`, `asForm`, `asMultipart`, `attach` and `withData` change it in place of `setBody()`; a custom repository comes from `defaultBodyRepository()`. `ArrayBodyRepository` is abstract, so use `JsonBodyRepository` or `FormBodyRepository` instead. `MultipartValue` also accepts booleans, `null` and arrays without a filename or headers, which Guzzle sends as fields, so `withData` can add fields to a multipart body. A multipart body's `Content-Type` is added when the body is prepared, after request middleware runs, so it carries the final body's boundary unless you set a content type that declares its own. + +The `HasTimeout` plugin's `getConnectTimeout` and `getRequestTimeout` methods return `null` for an undeclared timeout instead of upstream's `Config` defaults, so existing options and the HTTP connection's configured timeouts stay in effect. See [Request Options](https://hypervel.org/docs/saloon#request-options). + +Authentication helpers use the HTTP client's names: `withToken()`, `withBasicAuth()` and `withDigestAuth()`. Upstream's deprecated `withTokenAuth()`, `withQueryAuth()`, `withHeaderAuth()` and `withCertificateAuth()` are not included; pass the authenticator to `authenticate()` instead. `authenticator()` replaces `getAuthenticator()`. Authenticator properties are read-only because an authenticator may be shared by concurrent requests; to change credentials, create a new authenticator and pass it to `authenticate()`. `DigestAuthenticator` and `withDigestAuth()` take no digest-type argument, since digest is the only type supported. Digest credentials and client certificates are transport settings read with `transportAuthentication()` and `certificate()`, not request options. The upstream NTLM authenticator is omitted. Integrations requiring NTLM must supply their own authenticator and transport middleware. -Ported from: https://github.com/saloonphp/saloon +OAuth 2 configuration is an immutable `Data\OAuthConfig` built from constructor arguments and read through public properties, replacing upstream's `Helpers\OAuth2\OAuthConfig` setters and getters, so connectors define `defaultOAuthConfig()` and per-call changes use the grant methods' `requestModifier`. `invokeRequestModifier()` is `modify()`. `authorizationUrl()` returns an `AuthorizationUrl` holding the URL and its state, instead of `getAuthorizationUrl()` storing the state on the shared connector for `getState()`. Additional authorization parameters cannot replace that state, and if `getAccessToken()` receives either state, both must be non-empty and equal. `getUser()` applies the configuration's request modifier before the per-call one, as the token methods do, and its request sends no form `Content-Type`. See [OAuth 2](https://hypervel.org/docs/saloon#oauth2). + +Ported from: + +- https://github.com/saloonphp/saloon +- https://github.com/saloonphp/laravel-plugin +- https://github.com/saloonphp/cache-plugin +- https://github.com/saloonphp/pagination-plugin +- https://github.com/saloonphp/rate-limit-plugin diff --git a/src/saloon/composer.json b/src/saloon/composer.json index 5445024de7..a54bc99f6c 100644 --- a/src/saloon/composer.json +++ b/src/saloon/composer.json @@ -38,6 +38,7 @@ "ext-simplexml": "*", "guzzlehttp/guzzle": "^7.15.2 || ^8.2", "guzzlehttp/psr7": "^2.13 || ^3.1", + "guzzlehttp/uri-template": "^1.0 || ^2.0", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", @@ -52,12 +53,12 @@ "hypervel/macroable": "^0.4", "hypervel/prompts": "^0.4", "hypervel/rate-limiter": "^0.4", - "hypervel/reflection": "^0.4", "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", "psr/http-message": "^2.0", "symfony/console": "^8.1.2", "symfony/dom-crawler": "^8.1", + "symfony/filesystem": "^8.1", "symfony/finder": "^8.1", "symfony/var-dumper": "^8.1" }, diff --git a/src/saloon/config/saloon.php b/src/saloon/config/saloon.php index f7bf32bac4..2cf479d5f7 100644 --- a/src/saloon/config/saloon.php +++ b/src/saloon/config/saloon.php @@ -10,9 +10,9 @@ | HTTP Connection |-------------------------------------------------------------------------- | - | Saloon sends requests through this named, worker-lifetime HTTP - | connection. Its options are an open transport preset and may be - | adjusted or removed independently. + | Saloon sends requests through this named HTTP client connection unless + | a connector selects another one. Its options are the defaults for + | every request sent through it, and you may change or remove them. | */ @@ -64,8 +64,9 @@ | Generated Integrations |-------------------------------------------------------------------------- | - | The path and namespace are independent. A null namespace derives - | "Http\\Integrations" beneath the application's root namespace. + | A null namespace follows the path beneath the application directory, + | so "app/Http/Integrations" becomes "App\Http\Integrations". Set the + | namespace when the path is outside the application directory. | */ diff --git a/src/saloon/src/Cache/CacheKey.php b/src/saloon/src/Cache/CacheKey.php index b47bcf6b4f..dd5bc98383 100644 --- a/src/saloon/src/Cache/CacheKey.php +++ b/src/saloon/src/Cache/CacheKey.php @@ -9,7 +9,7 @@ use Hypervel\Saloon\Http\HeaderNormalizer; use Hypervel\Saloon\Http\PendingRequest; -final class CacheKey +class CacheKey { /** * Options that can change a successful response. @@ -50,6 +50,8 @@ public function make( ? $this->requestIdentity($pendingRequest, $transportOptions) : ['custom' => $customKey]; + // sha256 rather than xxh128: the identity includes credentials, and a forged collision would return another + // caller's cached response. return 'saloon:' . hash('sha256', $this->encode([$identity, 'scope' => $scope])); } diff --git a/src/saloon/src/Cache/Traits/HasCaching.php b/src/saloon/src/Cache/Traits/HasCaching.php index 6467813e73..4ef09c89f6 100644 --- a/src/saloon/src/Cache/Traits/HasCaching.php +++ b/src/saloon/src/Cache/Traits/HasCaching.php @@ -5,6 +5,7 @@ namespace Hypervel\Saloon\Cache\Traits; use Hypervel\Saloon\Enums\Method; +use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\PendingRequest; use UnitEnum; @@ -56,6 +57,14 @@ public function invalidateCache(): static return $this; } + /** + * Clear the cached response without sending the request. + */ + public function clearCache(Connector $counterpart): void + { + $counterpart->clearCache($this); + } + /** * Get the cache store name. */ diff --git a/src/saloon/src/Console/Commands/ListCommand.php b/src/saloon/src/Console/Commands/ListCommand.php index d093faab37..e061983ac4 100644 --- a/src/saloon/src/Console/Commands/ListCommand.php +++ b/src/saloon/src/Console/Commands/ListCommand.php @@ -307,19 +307,17 @@ protected function getIntegrationRequestEndpoint(string $request): string $regex = '/public\s+function\s+resolveEndpoint\(\):\s+string\s*\{\s*return\s+(.*?);/s'; - $match = Str::match($regex, $contents); - $matchSegments = explode('/', $match); - - foreach ($matchSegments as $key => $matchSegment) { - if (Str::contains($matchSegment, '$this->')) { - $matchSegments[$key] = '{' . Str::before( - Str::after(str_replace(' ', '', $matchSegment), '>'), - '.\'' - ) . '}'; - } - } - - return str_replace('\'', '', implode('/', $matchSegments)); + // Keep single-quoted literals as written, including escaped characters, show concatenated or interpolated + // properties as {name}, and drop the quotes and joining dots. + return preg_replace_callback( + '/\'((?:[^\'\\\]|\\\.)*)\'|"((?:[^"\\\]|\\\.)*)"|\$this->(\w+)|\s*\.\s*/s', + fn (array $matches): string => match (true) { + isset($matches[3]) => '{' . $matches[3] . '}', + isset($matches[2]) => preg_replace('/\{?\$this->(\w+)\}?/', '{$1}', $matches[2]), + default => $matches[1] ?? '', + }, + Str::match($regex, $contents), + ); } /** @@ -329,7 +327,7 @@ protected function getIntegrationConnectorBaseUrl(string $connector): string { $contents = $this->files->get($connector); - $regex = '/public\s+function\s+resolveBaseUrl\(\):\s+string\s*\{\s*return\s+\'(.*?)\';\s*/s'; + $regex = '/public\s+function\s+resolveBaseUrl\(\):\s+string\s*\{\s*return\s+[\'"](.*?)[\'"];/s'; $matches = Str::match($regex, $contents); return Str::after($matches, '://'); diff --git a/src/saloon/src/Console/Commands/MakeCommand.php b/src/saloon/src/Console/Commands/MakeCommand.php index 5f362f3b13..04371a4725 100644 --- a/src/saloon/src/Console/Commands/MakeCommand.php +++ b/src/saloon/src/Console/Commands/MakeCommand.php @@ -11,6 +11,7 @@ use Hypervel\Support\Str; use LogicException; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Filesystem\Path; use function Hypervel\Prompts\suggest; @@ -95,15 +96,40 @@ protected function qualifyClass(string $name): string : $namespace . '\\' . $name; } + /** + * Get the configured integrations path. + * + * Dot segments are removed so the path can be compared with the application directory and mapped to a namespace. + */ + protected function integrationsPath(): string + { + return Path::canonicalize($this->config->string('saloon.integrations_path')); + } + /** * Get the configured integrations namespace. + * + * Without a configured namespace, the integrations path must be inside the application directory, whose + * subdirectories follow the application's root namespace. */ protected function integrationNamespace(): string { $namespace = $this->config->get('saloon.integrations_namespace'); if ($namespace === null) { - return rtrim($this->rootNamespace(), '\\') . '\Http\Integrations'; + $rootNamespace = rtrim($this->rootNamespace(), '\\'); + $appPath = Path::canonicalize($this->hypervel->path()); + $integrationsPath = $this->integrationsPath(); + + if ($integrationsPath === $appPath) { + return $rootNamespace; + } + + if (! str_starts_with($integrationsPath, $appPath . '/')) { + throw new LogicException('The [saloon.integrations_namespace] configuration value is required when the integrations path is outside the application directory.'); + } + + return $rootNamespace . '\\' . str_replace('/', '\\', substr($integrationsPath, strlen($appPath) + 1)); } if (! is_string($namespace) || $namespace === '') { @@ -164,7 +190,7 @@ protected function promptForMissingArgumentsUsing(): array */ protected function getExistingIntegrations(string $search = ''): array { - $integrationsPath = $this->config->string('saloon.integrations_path'); + $integrationsPath = $this->integrationsPath(); if (! $this->files->isDirectory($integrationsPath)) { return []; @@ -201,9 +227,6 @@ protected function getPath(string $name): string $relativeName = Str::after($name, $this->integrationNamespace() . '\\'); } - return rtrim($this->config->string('saloon.integrations_path'), '/\\') - . DIRECTORY_SEPARATOR - . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relativeName) - . '.php'; + return $this->integrationsPath() . '/' . str_replace('\\', '/', $relativeName) . '.php'; } } diff --git a/src/saloon/src/Contracts/OAuthAuthenticator.php b/src/saloon/src/Contracts/OAuthAuthenticator.php index b08d19b904..7c732e53fa 100644 --- a/src/saloon/src/Contracts/OAuthAuthenticator.php +++ b/src/saloon/src/Contracts/OAuthAuthenticator.php @@ -4,7 +4,7 @@ namespace Hypervel\Saloon\Contracts; -use Carbon\CarbonInterface; +use DateTimeImmutable; interface OAuthAuthenticator extends Authenticator { @@ -21,7 +21,7 @@ public function getRefreshToken(): ?string; /** * Get the expiry. */ - public function getExpiresAt(): ?CarbonInterface; + public function getExpiresAt(): ?DateTimeImmutable; /** * Determine if the authenticator has expired. diff --git a/src/saloon/src/Data/MultipartValue.php b/src/saloon/src/Data/MultipartValue.php index e59dc7e6ce..224912c1a9 100644 --- a/src/saloon/src/Data/MultipartValue.php +++ b/src/saloon/src/Data/MultipartValue.php @@ -4,29 +4,60 @@ namespace Hypervel\Saloon\Data; +use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; use Psr\Http\Message\StreamInterface; -final readonly class MultipartValue +final class MultipartValue { + /** + * The stream that wraps a resource value. + */ + private ?StreamInterface $resourceStream = null; + /** * Create a multipart value. * - * @param float|int|resource|StreamInterface|string $value + * Guzzle expands an array value into one field per element, named with brackets such as `roles[0]`, so an array + * cannot have a filename or headers. + * + * @param null|array|bool|float|int|resource|StreamInterface|string $value * @param array|string> $headers */ public function __construct( - public string $name, - public mixed $value, - public ?string $filename = null, - public array $headers = [], + public readonly string $name, + public readonly mixed $value, + public readonly ?string $filename = null, + public readonly array $headers = [], ) { - if (! $value instanceof StreamInterface + if (is_array($value)) { + if ($filename !== null || $headers !== []) { + throw new InvalidArgumentException('An array value cannot have a filename or headers.'); + } + } elseif ($value !== null + && ! is_bool($value) + && ! $value instanceof StreamInterface && ! is_resource($value) && ! is_string($value) && ! is_int($value) && (! is_float($value) || ! is_finite($value))) { - throw new InvalidArgumentException(sprintf('The value property must be either a %s, resource, string, or finite number.', StreamInterface::class)); + throw new InvalidArgumentException(sprintf('The value property must be either a %s, resource, string, finite number, boolean, null, or array.', StreamInterface::class)); } } + + /** + * Get the contents to add to a multipart stream. + * + * @return null|array|bool|float|int|StreamInterface|string + */ + public function contents(): array|bool|float|int|StreamInterface|string|null + { + if (! is_resource($this->value)) { + return $this->value; + } + + // A Guzzle stream closes its resource when destroyed, so every multipart stream built from this value shares + // one wrapper. Otherwise releasing one, such as a PSR request snapshot taken before sending, closes the file. + return $this->resourceStream ??= Utils::streamFor($this->value); + } } diff --git a/src/saloon/src/Data/OAuthConfig.php b/src/saloon/src/Data/OAuthConfig.php index 8df385189c..dbcb7037fd 100644 --- a/src/saloon/src/Data/OAuthConfig.php +++ b/src/saloon/src/Data/OAuthConfig.php @@ -21,7 +21,7 @@ /** * Create an OAuth 2 configuration. * - * @param list $defaultScopes + * @param list $defaultScopes * @param null|callable(Request): void $requestModifier */ public function __construct( @@ -40,6 +40,20 @@ public function __construct( $this->requestModifier = $requestModifier === null ? null : $requestModifier(...); } + /** + * Merge the default scopes with the given scopes, ignoring null and empty scopes. + * + * @param list $scopes + * @return array + */ + public function scopes(array $scopes = []): array + { + return array_filter( + [...$this->defaultScopes, ...$scopes], + static fn (?string $scope): bool => $scope !== null && $scope !== '', + ); + } + /** * Apply the configured request modifier. * @@ -56,19 +70,23 @@ public function modify(Request $request): Request /** * Validate the OAuth 2 configuration. + * + * @throws OAuthConfigValidationException */ - public function validate(bool $withRedirectUri = true): void + public function validate(bool $withRedirectUrl = true): bool { if ($this->clientId === '') { - throw new OAuthConfigValidationException('The client ID is empty or has not been provided.'); + throw new OAuthConfigValidationException('The Client ID is empty or has not been provided.'); } if ($this->clientSecret === '') { - throw new OAuthConfigValidationException('The client secret is empty or has not been provided.'); + throw new OAuthConfigValidationException('The Client Secret is empty or has not been provided.'); } - if ($withRedirectUri && $this->redirectUri === '') { - throw new OAuthConfigValidationException('The redirect URI is empty or has not been provided.'); + if ($withRedirectUrl && $this->redirectUri === '') { + throw new OAuthConfigValidationException('The Redirect URI is empty or has not been provided.'); } + + return true; } } diff --git a/src/saloon/src/Data/RecordedResponse.php b/src/saloon/src/Data/RecordedResponse.php index 4abebde40e..48d824582e 100644 --- a/src/saloon/src/Data/RecordedResponse.php +++ b/src/saloon/src/Data/RecordedResponse.php @@ -14,13 +14,14 @@ class RecordedResponse implements JsonSerializable /** * Create a recorded response. * - * @param array> $headers + * @param array|string> $headers + * @param array|string $data * @param array $context */ public function __construct( public int $statusCode, public array $headers = [], - public string $data = '', + public array|string $data = '', public array $context = [], ) { } @@ -30,11 +31,11 @@ public function __construct( */ public static function fromFile(string $contents): static { - /** @var array{statusCode: int, headers: array>, data: string, encoding?: string, context?: array} $fileData */ + /** @var array{statusCode: int, headers: array|string>, data: array|string, encoding?: string, context?: array} $fileData */ $fileData = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); $data = $fileData['data']; - if (($fileData['encoding'] ?? null) === 'base64') { + if (is_string($data) && ($fileData['encoding'] ?? null) === 'base64') { $decoded = base64_decode($data, true); if ($decoded === false) { @@ -83,7 +84,7 @@ public function toMockResponse(): MockResponse /** * Convert the recorded response to its fixture representation. * - * @return array{statusCode: int, headers: array>, data: string, context: array, encoding?: 'base64'} + * @return array{statusCode: int, headers: array|string>, data: array|string, context: array, encoding?: 'base64'} */ public function jsonSerialize(): array { @@ -94,7 +95,7 @@ public function jsonSerialize(): array 'context' => $this->context, ]; - if (! mb_check_encoding($this->data, 'UTF-8')) { + if (is_string($this->data) && ! mb_check_encoding($this->data, 'UTF-8')) { $response['data'] = base64_encode($this->data); $response['encoding'] = 'base64'; } diff --git a/src/saloon/src/Enums/PipeOrder.php b/src/saloon/src/Enums/PipeOrder.php index 5d7ff3662c..30a66f6444 100644 --- a/src/saloon/src/Enums/PipeOrder.php +++ b/src/saloon/src/Enums/PipeOrder.php @@ -6,6 +6,6 @@ enum PipeOrder: string { - case First = 'first'; - case Last = 'last'; + case FIRST = 'first'; + case LAST = 'last'; } diff --git a/src/saloon/src/Enums/VersionMode.php b/src/saloon/src/Enums/VersionMode.php new file mode 100644 index 0000000000..a0593e1aa4 --- /dev/null +++ b/src/saloon/src/Enums/VersionMode.php @@ -0,0 +1,12 @@ +uri())); + parent::__construct(sprintf('Saloon was unable to guess a mock response for your request [%s], consider using a wildcard url mock or a connector mock.', $pendingRequest->uri())); } } diff --git a/src/saloon/src/Exceptions/PoolException.php b/src/saloon/src/Exceptions/PoolException.php index 978b02e58b..93c2bbc688 100644 --- a/src/saloon/src/Exceptions/PoolException.php +++ b/src/saloon/src/Exceptions/PoolException.php @@ -24,7 +24,7 @@ public function __construct( ) { parent::__construct( 'One or more requests or callbacks failed while processing the Saloon pool.', - previous: $orchestrationFailure, + previous: $orchestrationFailure ?? array_first($failures) ?? array_first($callbackFailures), ); } diff --git a/src/saloon/src/Exceptions/Request/FatalRequestException.php b/src/saloon/src/Exceptions/Request/FatalRequestException.php index bf6c414cba..f74c20e048 100644 --- a/src/saloon/src/Exceptions/Request/FatalRequestException.php +++ b/src/saloon/src/Exceptions/Request/FatalRequestException.php @@ -9,7 +9,8 @@ use Throwable; /** - * Report a transport failure that occurred before an API returned a response. + * Report a request exchange that could not complete, such as a failed connection or a transfer that broke while + * receiving the response. */ class FatalRequestException extends SaloonException { diff --git a/src/saloon/src/Exceptions/Request/RequestException.php b/src/saloon/src/Exceptions/Request/RequestException.php index 11a998ae08..3b71008e56 100644 --- a/src/saloon/src/Exceptions/Request/RequestException.php +++ b/src/saloon/src/Exceptions/Request/RequestException.php @@ -40,6 +40,8 @@ public function status(): int return $this->response->status(); } + // getStatusMessage() is not included: use response()->reason(). + /** * Get the response body. */ diff --git a/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php b/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php new file mode 100644 index 0000000000..a9e4593d86 --- /dev/null +++ b/src/saloon/src/Exceptions/Request/Statuses/BadGatewayException.php @@ -0,0 +1,11 @@ + $callbacks) - * @method static \Hypervel\Contracts\Cache\Factory cache() + * @method static void clearCache(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request) * @method static \Hypervel\Saloon\SaloonManager clearFake() + * @method static \Hypervel\Saloon\Http\PendingRequest createPendingRequest(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request) * @method static \Hypervel\Saloon\Http\Faking\MockClient fake(array|\Hypervel\Saloon\Http\Faking\MockClient $responses = []) * @method static \Hypervel\Saloon\SaloonManager fixturePath(string $path) * @method static string getFixturePath() + * @method static ($policy is \Hypervel\RateLimiter\Cooldown ? \Hypervel\RateLimiter\CooldownResult : \Hypervel\RateLimiter\LimitResult) inspectRateLimit(\Hypervel\Saloon\Http\Connector|\Hypervel\Saloon\Http\Request $resource, \Hypervel\RateLimiter\AdmissionPolicy|\Hypervel\RateLimiter\Cooldown $policy) * @method static \Hypervel\Saloon\Http\MiddlewarePipeline middleware() * @method static \Hypervel\Saloon\Http\Faking\MockClient|null mockClient() - * @method static \Hypervel\RateLimiter\RateLimiter rateLimiter() * @method static string|null resolveCacheScope(\Hypervel\Saloon\Http\PendingRequest $pendingRequest) * @method static \Hypervel\Saloon\SaloonManager resolveCacheScopeUsing(null|Closure $resolver) * @method static \Hypervel\Saloon\Http\Response send(\Hypervel\Saloon\Http\Connector $connector, \Hypervel\Saloon\Http\Request $request, \Hypervel\Saloon\Http\Faking\MockClient|null $mockClient = null) - * @method static \Hypervel\Saloon\Http\Sender sender() + * @method static void setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events) * @method static \Hypervel\Saloon\SaloonManager throwOnMissingFixtures(bool $throw = true) * @method static bool throwsOnMissingFixtures() * diff --git a/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php b/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php index d858c26ba7..a06bb96a38 100644 --- a/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php +++ b/src/saloon/src/Http/Auth/AccessTokenAuthenticator.php @@ -4,23 +4,23 @@ namespace Hypervel\Saloon\Http\Auth; -use Carbon\CarbonInterface; +use DateTimeImmutable; use Hypervel\Saloon\Contracts\OAuthAuthenticator; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Support\Facades\Date; use SensitiveParameter; -readonly class AccessTokenAuthenticator implements OAuthAuthenticator +class AccessTokenAuthenticator implements OAuthAuthenticator { /** * Create an access token authenticator. */ public function __construct( #[SensitiveParameter] - public string $accessToken, + public readonly string $accessToken, #[SensitiveParameter] - public ?string $refreshToken = null, - public ?CarbonInterface $expiresAt = null, + public readonly ?string $refreshToken = null, + public readonly ?DateTimeImmutable $expiresAt = null, ) { } @@ -29,7 +29,7 @@ public function __construct( */ public function set(PendingRequest $pendingRequest): void { - $pendingRequest->replaceHeaders(['Authorization' => 'Bearer ' . $this->accessToken]); + $pendingRequest->replaceHeaders(['Authorization' => 'Bearer ' . $this->getAccessToken()]); } /** @@ -67,7 +67,7 @@ public function getRefreshToken(): ?string /** * Get the expiry. */ - public function getExpiresAt(): ?CarbonInterface + public function getExpiresAt(): ?DateTimeImmutable { return $this->expiresAt; } diff --git a/src/saloon/src/Http/Auth/BasicAuthenticator.php b/src/saloon/src/Http/Auth/BasicAuthenticator.php index c646b72dfe..0eb69420a0 100644 --- a/src/saloon/src/Http/Auth/BasicAuthenticator.php +++ b/src/saloon/src/Http/Auth/BasicAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class BasicAuthenticator implements Authenticator +class BasicAuthenticator implements Authenticator { /** * Create a basic authenticator. */ public function __construct( - public string $username, + public readonly string $username, #[SensitiveParameter] - public string $password, + public readonly string $password, ) { } @@ -25,6 +25,6 @@ public function __construct( */ public function set(PendingRequest $pendingRequest): void { - $pendingRequest->setTransportAuthentication([$this->username, $this->password]); + $pendingRequest->replaceHeaders(['Authorization' => 'Basic ' . base64_encode($this->username . ':' . $this->password)]); } } diff --git a/src/saloon/src/Http/Auth/CertificateAuthenticator.php b/src/saloon/src/Http/Auth/CertificateAuthenticator.php index 6ca00ddfba..d01b07e380 100644 --- a/src/saloon/src/Http/Auth/CertificateAuthenticator.php +++ b/src/saloon/src/Http/Auth/CertificateAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class CertificateAuthenticator implements Authenticator +class CertificateAuthenticator implements Authenticator { /** * Create a certificate authenticator. */ public function __construct( - public string $path, + public readonly string $path, #[SensitiveParameter] - public ?string $password = null, + public readonly ?string $password = null, ) { } diff --git a/src/saloon/src/Http/Auth/CookieAuthenticator.php b/src/saloon/src/Http/Auth/CookieAuthenticator.php index 4ce6088338..099b503c49 100644 --- a/src/saloon/src/Http/Auth/CookieAuthenticator.php +++ b/src/saloon/src/Http/Auth/CookieAuthenticator.php @@ -9,16 +9,16 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class CookieAuthenticator implements Authenticator +class CookieAuthenticator implements Authenticator { /** * Create a cookie authenticator. */ public function __construct( - public string $name, + public readonly string $name, #[SensitiveParameter] - public string $value, - public ?string $domain = null, + public readonly string $value, + public readonly ?string $domain = null, ) { } diff --git a/src/saloon/src/Http/Auth/DigestAuthenticator.php b/src/saloon/src/Http/Auth/DigestAuthenticator.php index 769e389e7d..b864659722 100644 --- a/src/saloon/src/Http/Auth/DigestAuthenticator.php +++ b/src/saloon/src/Http/Auth/DigestAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class DigestAuthenticator implements Authenticator +class DigestAuthenticator implements Authenticator { /** * Create a digest authenticator. */ public function __construct( - public string $username, + public readonly string $username, #[SensitiveParameter] - public string $password, + public readonly string $password, ) { } diff --git a/src/saloon/src/Http/Auth/HeaderAuthenticator.php b/src/saloon/src/Http/Auth/HeaderAuthenticator.php index 357f12d9b8..63c0d9350b 100644 --- a/src/saloon/src/Http/Auth/HeaderAuthenticator.php +++ b/src/saloon/src/Http/Auth/HeaderAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class HeaderAuthenticator implements Authenticator +class HeaderAuthenticator implements Authenticator { /** * Create a header authenticator. */ public function __construct( #[SensitiveParameter] - public string $accessToken, - public string $headerName = 'Authorization', + public readonly string $accessToken, + public readonly string $headerName = 'Authorization', ) { } diff --git a/src/saloon/src/Http/Auth/QueryAuthenticator.php b/src/saloon/src/Http/Auth/QueryAuthenticator.php index fc70e515a6..4c572e6945 100644 --- a/src/saloon/src/Http/Auth/QueryAuthenticator.php +++ b/src/saloon/src/Http/Auth/QueryAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class QueryAuthenticator implements Authenticator +class QueryAuthenticator implements Authenticator { /** * Create a query authenticator. */ public function __construct( - public string $parameter, + public readonly string $parameter, #[SensitiveParameter] - public string $value, + public readonly string $value, ) { } diff --git a/src/saloon/src/Http/Auth/TokenAuthenticator.php b/src/saloon/src/Http/Auth/TokenAuthenticator.php index d8c41fc2da..f37f29df9a 100644 --- a/src/saloon/src/Http/Auth/TokenAuthenticator.php +++ b/src/saloon/src/Http/Auth/TokenAuthenticator.php @@ -8,15 +8,15 @@ use Hypervel\Saloon\Http\PendingRequest; use SensitiveParameter; -readonly class TokenAuthenticator implements Authenticator +class TokenAuthenticator implements Authenticator { /** * Create a token authenticator. */ public function __construct( #[SensitiveParameter] - public string $token, - public string $prefix = 'Bearer', + public readonly string $token, + public readonly string $prefix = 'Bearer', ) { } diff --git a/src/saloon/src/Http/Connector.php b/src/saloon/src/Http/Connector.php index a86b0cf77b..ea7b257126 100644 --- a/src/saloon/src/Http/Connector.php +++ b/src/saloon/src/Http/Connector.php @@ -6,8 +6,11 @@ use Hypervel\Container\Container; use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; +use Hypervel\RateLimiter\Cooldown; use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Contracts\Body\BodyRepository; +use Hypervel\Saloon\Data\RetryPolicy; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Faking\MockClient; use Hypervel\Saloon\SaloonManager; @@ -56,6 +59,37 @@ public function send(Request $request, ?MockClient $mockClient = null): Response return $manager->send($this, $request, $mockClient); } + // sendAsync() is not included: pool() sends concurrently through coroutines instead of promises. Upstream's + // deprecated sendAndRetry() is not included either; use retry() or defaultRetryPolicy(). See the package README. + + /** + * Prepare a request for sending through this connector. + * + * The pending request has run its hooks and request middleware but has not been finalized or sent. + * + * @template TRequestDto + * @param Request $request + * @return PendingRequest + */ + public function createPendingRequest(Request $request): PendingRequest + { + /** @var SaloonManager $manager */ + $manager = Container::getInstance()->make('saloon'); + + return $manager->createPendingRequest($this, $request); + } + + /** + * Clear the cached response for a request without sending it. + */ + public function clearCache(Request $counterpart): void + { + /** @var SaloonManager $manager */ + $manager = Container::getInstance()->make('saloon'); + + $manager->clearCache($this, $counterpart); + } + /** * Create a bounded request pool. * @@ -80,6 +114,9 @@ public function resolveHttpConnection(): ?string return null; } + // sender() and defaultSender() are not included: requests are sent through the HTTP client connection. See the + // package README. + /** * Resolve whether requests may replace this connector's base URL. */ @@ -134,6 +171,14 @@ final public function delayMilliseconds(): ?int return $this->defaultDelay(); } + /** + * Get the default retry policy for requests without their own. + */ + final public function retryPolicy(): ?RetryPolicy + { + return $this->defaultRetryPolicy(); + } + /** * Copy the default body repository for a pending request. * @@ -192,6 +237,14 @@ protected function defaultDelay(): ?int return null; } + /** + * Resolve the default retry policy. + */ + protected function defaultRetryPolicy(): ?RetryPolicy + { + return null; + } + /** * Resolve the default body repository. */ @@ -222,11 +275,11 @@ final public function resolveCacheableMethods(): array } /** - * Determine if this connector defines rate limits. + * Determine if the connector's rate limits apply to an operation. * * @internal */ - public function usesRateLimits(): bool + public function usesRateLimits(PendingRequest $pendingRequest): bool { return false; } @@ -253,11 +306,21 @@ public function resolveRateLimitStoreName(): UnitEnum|string|null } /** - * Determine if connector rate limits should be awaited. + * Resolve the identity whose limits and cooldowns the connector uses. + * + * @internal + */ + public function rateLimiterName(): string + { + return static::class; + } + + /** + * Determine if a denied connector rate limit should be awaited. * * @internal */ - public function shouldWaitForRateLimits(): bool + public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return false; } @@ -269,7 +332,7 @@ public function shouldWaitForRateLimits(): bool */ public function resolveRateLimitCooldownKeyFor(PendingRequest $pendingRequest): string { - return static::class; + return ''; } /** diff --git a/src/saloon/src/Http/Debugger.php b/src/saloon/src/Http/Debugger.php index 6bfdf75929..909b888b10 100644 --- a/src/saloon/src/Http/Debugger.php +++ b/src/saloon/src/Http/Debugger.php @@ -39,7 +39,7 @@ public static function dumpResponse(Response $response, ResponseInterface $psrRe } /** - * Terminate the current process. + * Stop the current request or command. */ public static function terminate(): never { diff --git a/src/saloon/src/Http/Faking/Fixture.php b/src/saloon/src/Http/Faking/Fixture.php index 7b8c9efb81..7e19d63edf 100644 --- a/src/saloon/src/Http/Faking/Fixture.php +++ b/src/saloon/src/Http/Faking/Fixture.php @@ -104,11 +104,16 @@ public function getMockResponse(): ?MockResponse return $response; } - /** @var null|string $body */ $body = $response->body()->all(); - $contents = $body === null || $body === '' - ? [] - : json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + if (is_array($body)) { + $contents = $body; + } else { + /** @var null|string $body */ + $contents = $body === null || $body === '' + ? [] + : json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } if (! is_array($contents)) { throw new FixtureException('Fixture merge and through transforms require a JSON array or object body.'); @@ -122,7 +127,7 @@ public function getMockResponse(): ?MockResponse $contents = ($this->through)($contents); } - $response->body()->set(json_encode($contents, JSON_THROW_ON_ERROR)); + $response->body()->set(is_array($body) ? $contents : json_encode($contents, JSON_THROW_ON_ERROR)); return $response; } @@ -134,11 +139,16 @@ public function getMockResponse(): ?MockResponse */ public function store(RecordedResponse $recordedResponse): static { + // Redaction rules match the response text, so array data is encoded first rather than skipping them. + if (is_array($recordedResponse->data)) { + $recordedResponse->data = json_encode($recordedResponse->data, JSON_THROW_ON_ERROR); + } + $recordedResponse = $this->swapSensitiveHeaders($recordedResponse); $recordedResponse = $this->swapSensitiveJson($recordedResponse); $recordedResponse = $this->swapSensitiveBodyWithRegex($recordedResponse); $recordedResponse = $this->beforeSave($recordedResponse); - $recordedResponse->context = array_merge($recordedResponse->context, $this->context->all()); + $recordedResponse->context = array_merge($this->context->all(), $recordedResponse->context); $fixturePath = $this->getFixturePath(); $this->files->ensureDirectoryExists(dirname($fixturePath)); @@ -197,8 +207,9 @@ protected function swapSensitiveHeaders(RecordedResponse $recordedResponse): Rec continue; } - $replacement = $replacement instanceof Closure ? $replacement($values) : $replacement; - $recordedResponse->headers[$name] = is_array($replacement) ? $replacement : [$replacement]; + $redact = static fn (string $value): string => $replacement instanceof Closure ? $replacement($value) : $replacement; + + $recordedResponse->headers[$name] = is_array($values) ? array_map($redact, $values) : $redact($values); } return $recordedResponse; @@ -253,7 +264,9 @@ protected function swapSensitiveBodyWithRegex(RecordedResponse $recordedResponse /** * Define sensitive response headers. * - * @return array): (list|string)|string> + * Rules apply to each value of a matching header. + * + * @return array */ protected function defineSensitiveHeaders(): array { diff --git a/src/saloon/src/Http/Faking/MockClient.php b/src/saloon/src/Http/Faking/MockClient.php index 879e5e6b25..d080712ed8 100644 --- a/src/saloon/src/Http/Faking/MockClient.php +++ b/src/saloon/src/Http/Faking/MockClient.php @@ -13,14 +13,15 @@ use Hypervel\Saloon\SaloonManager; use Hypervel\Support\Collection; use Hypervel\Support\Str; -use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; use ReflectionFunction; +use ReflectionIntersectionType; +use ReflectionNamedType; +use ReflectionType; +use ReflectionUnionType; class MockClient { - use ReflectsClosures; - /** * Responses consumed in registration order. * @@ -70,6 +71,11 @@ class MockClient */ protected ?array $allowedStrayRequestUrls = []; + /** + * Whether requests using this mock client bypass the response cache. + */ + protected bool $withoutResponseCache = false; + /** * Create a mock client. * @@ -138,7 +144,7 @@ public function match(PendingRequest $pendingRequest): MockResponse|Fixture|null } foreach ($this->urlResponses as $url => $response) { - if (Str::is($url, (string) $pendingRequest->uri())) { + if ($this->urlMatches($url, $pendingRequest)) { return $this->resolveValue($response, $pendingRequest); } } @@ -301,6 +307,29 @@ public function assertSentCount(int $count, ?string $requestClass = null): void PHPUnit::assertCount($count, $responses); } + /** + * Bypass the response cache for requests using this mock client. + * + * @return $this + */ + public function withoutCache(): static + { + $this->withoutResponseCache = true; + + return $this; + } + + /** + * Determine if requests using this mock client bypass the response cache. + */ + public function shouldBypassResponseCache(): bool + { + return $this->withoutResponseCache; + } + + // The plugin's deprecated MockClient subclass, with startMocking(), isMocking() and resolve(), is not included; + // Saloon::fake() and global() manage the global mock client. See the package README. + /** * Register or retrieve the manager-backed global mock client. * @@ -356,21 +385,62 @@ protected function responseMatches(Response $response, string|callable $value): if (is_string($value)) { return is_a($value, Request::class, true) ? $request instanceof $value - : Str::is($value, (string) $response->pendingRequest()->uri()); + : $this->urlMatches($value, $response->pendingRequest()); } $closure = $value(...); $parameters = (new ReflectionFunction($closure))->getParameters(); - if ($parameters !== [] && $parameters[0]->hasType()) { - $types = $this->firstClosureParameterTypes($closure); + if ($parameters !== [] && ! $this->requestMatchesType($request, $parameters[0]->getType())) { + return false; + } - if (! array_any($types, fn (string $type): bool => $request instanceof $type)) { - return false; + return (bool) $closure($request, $response); + } + + /** + * Determine if the request satisfies a closure parameter type. + */ + protected function requestMatchesType(Request $request, ?ReflectionType $type): bool + { + if ($type instanceof ReflectionNamedType) { + if ($type->isBuiltin()) { + return in_array($type->getName(), ['mixed', 'object'], true); } + + $class = $type->getName(); + + return $request instanceof $class; } - return (bool) $closure($request, $response); + if ($type instanceof ReflectionUnionType) { + return array_any( + $type->getTypes(), + fn (ReflectionType $subType): bool => $this->requestMatchesType($request, $subType), + ); + } + + if ($type instanceof ReflectionIntersectionType) { + return array_all( + $type->getTypes(), + fn (ReflectionType $subType): bool => $this->requestMatchesType($request, $subType), + ); + } + + return true; + } + + /** + * Determine if the pending request's URL matches the pattern. + * + * A pattern may omit the scheme and host, and matches the URL with or without its query string. + */ + protected function urlMatches(string $pattern, PendingRequest $pendingRequest): bool + { + $pattern = Str::start($pattern, '*'); + $uri = $pendingRequest->uri(); + + return Str::is($pattern, (string) $uri) || Str::is($pattern, (string) $uri->withQuery('')); } /** @@ -388,7 +458,7 @@ protected function allowsStrayRequest(PendingRequest $pendingRequest): bool return array_any( $this->allowedStrayRequestUrls, - fn (string $url): bool => Str::is($url, (string) $pendingRequest->uri()), + fn (string $url): bool => $this->urlMatches($url, $pendingRequest), ); } diff --git a/src/saloon/src/Http/HeaderNormalizer.php b/src/saloon/src/Http/HeaderNormalizer.php index 04b4aa506a..e15cf0d642 100644 --- a/src/saloon/src/Http/HeaderNormalizer.php +++ b/src/saloon/src/Http/HeaderNormalizer.php @@ -12,22 +12,35 @@ final class HeaderNormalizer /** * Normalize request header values. * - * @param array $headers + * @param array $headers * @return array|string> */ public static function normalize(array $headers): array { - foreach ($headers as $name => $value) { - if (! is_string($name)) { - throw new InvalidHeaderException('HTTP header names must be strings.'); - } + self::ensureValidNames($headers); + foreach ($headers as $name => $value) { $headers[$name] = self::value($value); } return $headers; } + /** + * Ensure every header is keyed by its name. + * + * @param array $headers + * @phpstan-assert array $headers + */ + public static function ensureValidNames(array $headers): void + { + foreach (array_keys($headers) as $name) { + if (! is_string($name)) { + throw new InvalidHeaderException('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].'); + } + } + } + /** * Normalize one request header value. * diff --git a/src/saloon/src/Http/MiddlewarePipeline.php b/src/saloon/src/Http/MiddlewarePipeline.php index ba28fb6999..f5985898bb 100644 --- a/src/saloon/src/Http/MiddlewarePipeline.php +++ b/src/saloon/src/Http/MiddlewarePipeline.php @@ -49,7 +49,9 @@ public function __construct() */ public function onRequest(callable $callable, ?string $name = null, ?PipeOrder $order = null): static { - $this->requestPipeline->pipe(function (PendingRequest $pendingRequest) use ($callable): PendingRequest { + // The wrapper is static so it does not capture the pipeline, which would form a reference cycle that keeps + // the pipeline alive until garbage collection. + $this->requestPipeline->pipe(static function (PendingRequest $pendingRequest) use ($callable): PendingRequest { $result = $callable($pendingRequest); if ($result instanceof FakeResponse) { @@ -70,7 +72,8 @@ public function onRequest(callable $callable, ?string $name = null, ?PipeOrder $ */ public function onResponse(callable $callable, ?string $name = null, ?PipeOrder $order = null): static { - $this->responsePipeline->pipe(function (Response $response) use ($callable): Response { + // Static for the same reason as the request wrapper. + $this->responsePipeline->pipe(static function (Response $response) use ($callable): Response { $result = $callable($response); return $result instanceof Response ? $result : $response; @@ -87,7 +90,8 @@ public function onResponse(callable $callable, ?string $name = null, ?PipeOrder */ public function onFatalException(callable $callable, ?string $name = null, ?PipeOrder $order = null): static { - $this->fatalPipeline->pipe(function (FatalRequestException $throwable) use ($callable): FatalRequestException { + // Static for the same reason as the request wrapper. + $this->fatalPipeline->pipe(static function (FatalRequestException $throwable) use ($callable): FatalRequestException { $callable($throwable); return $throwable; diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php index c16c932b6a..b1c82e659a 100644 --- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php +++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php @@ -18,10 +18,7 @@ protected function defaultBody(): array { return [ 'grant_type' => 'client_credentials', - 'scope' => implode($this->scopeSeparator, [ - ...$this->oauthConfig->defaultScopes, - ...$this->scopes, - ]), + 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)), ]; } diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php index 75d61ffc89..a3123e106f 100644 --- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php +++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php @@ -23,7 +23,7 @@ class GetClientCredentialsTokenRequest extends Request /** * Create a client-credentials token request. * - * @param list $scopes + * @param list $scopes */ public function __construct( protected OAuthConfig $oauthConfig, @@ -59,10 +59,7 @@ protected function defaultBody(): array 'grant_type' => 'client_credentials', 'client_id' => $this->oauthConfig->clientId, 'client_secret' => $this->oauthConfig->clientSecret, - 'scope' => implode($this->scopeSeparator, [ - ...$this->oauthConfig->defaultScopes, - ...$this->scopes, - ]), + 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)), ]; } } diff --git a/src/saloon/src/Http/PendingRequest.php b/src/saloon/src/Http/PendingRequest.php index 7ac7b7db11..b0c38d1ee4 100644 --- a/src/saloon/src/Http/PendingRequest.php +++ b/src/saloon/src/Http/PendingRequest.php @@ -8,9 +8,7 @@ use DateInterval; use DateTimeInterface; use GuzzleHttp\Psr7\Request as PsrRequest; -use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Http\Client\Response as HttpResponse; -use Hypervel\RateLimiter\RateLimiter; use Hypervel\Saloon\Cache\Contracts\Cacheable; use Hypervel\Saloon\Cache\Exceptions\CachingException; use Hypervel\Saloon\Contracts\Authenticator; @@ -23,7 +21,6 @@ use Hypervel\Saloon\Http\PendingRequest\BootPlugins; use Hypervel\Saloon\Repositories\ArrayRepository; use Hypervel\Saloon\Repositories\Body\MultipartBodyRepository; -use Hypervel\Saloon\Repositories\IntegerRepository; use Hypervel\Saloon\Traits\Auth\AuthenticatesRequests; use Hypervel\Saloon\Traits\Body\HasBody; use Hypervel\Saloon\Traits\HasDebugging; @@ -47,10 +44,22 @@ class PendingRequest use HasDebugging; use HasRequestProperties { withQueryParameters as protected addQueryParameters; + withoutQueryParameters as protected removeQueryParameters; withQueryString as protected replaceQueryString; + withUrlParameters as protected addUrlParameters; } use Macroable; + /** + * The HTTP method used by the operation. + */ + protected Method $method; + + /** + * The absolute URL override. + */ + protected ?string $url; + /** * The finalized request URI. */ @@ -102,6 +111,11 @@ class PendingRequest */ protected ?FakeResponse $fakeResponse = null; + /** + * Whether the connector and request plugins are booting. + */ + protected bool $bootingPlugins = false; + /** * Create a side-effect-free pending request. * @@ -110,13 +124,17 @@ class PendingRequest public function __construct( protected Connector $connector, protected Request $request, - protected CacheFactory $cache, - protected RateLimiter $rateLimiter, ) { - $this->headerRepository = new ArrayRepository(array_merge( - $connector->headers(), - $request->headers(), - )); + $this->method = $request->method(); + $this->url = $request->url(); + $this->urlParameters = $request->urlParameters(); + $headers = array_merge($connector->headers(), $request->headers()); + + // Plugins and authenticators match header names case-insensitively, so headers written as a list such as + // ['Accept: application/json'] are rejected here with a clear message instead of failing inside them. + HeaderNormalizer::ensureValidNames($headers); + + $this->headerRepository = new ArrayRepository($headers); $this->queryRepository = new ArrayRepository(array_merge( $connector->queryParameters(), $request->queryParameters(), @@ -126,10 +144,7 @@ public function __construct( $connector->options(), $request->options(), )); - $this->delayRepository = new IntegerRepository( - $request->delayMilliseconds() ?? $connector->delayMilliseconds(), - ); - $this->middlewarePipeline = clone $request->middleware(); + $this->delay = $request->delayMilliseconds() ?? $connector->delayMilliseconds(); $connectorBody = $connector->copyDefaultBodyRepository(); $requestBody = $request->copyBodyRepository(); @@ -145,7 +160,7 @@ public function __construct( } $this->cookies = $request->cookies(); - $this->retryPolicy = $request->retryPolicy(); + $this->retryPolicy = $request->retryPolicy() ?? $connector->retryPolicy(); $this->authenticator = $request->authenticator() ?? $connector->authenticator(); } @@ -172,7 +187,54 @@ public function request(): Request */ public function method(): Method { - return $this->request->method(); + return $this->method; + } + + /** + * Change the HTTP method used by the operation. + * + * @return $this + */ + public function withMethod(Method $method): static + { + $this->method = $method; + + return $this; + } + + /** + * Get the absolute URL override. + */ + public function url(): ?string + { + return $this->url; + } + + /** + * Replace the connector base URL and request endpoint for this operation. + * + * @return $this + */ + public function withUrl(string $url): static + { + $this->uri = null; + $this->url = $url; + + return $this; + } + + /** + * Specify the URL parameters and invalidate the finalized URI. + * + * @param array $parameters + * @return $this + */ + public function withUrlParameters(array $parameters = []): static + { + $this->uri = null; + $this->addUrlParameters($parameters); + + return $this; } /** @@ -184,12 +246,11 @@ public function uri(): UriInterface return $this->uri; } - $url = $this->request->url(); - $uri = $url !== null - ? UrlResolver::resolve('', $url, false) + $uri = $this->url !== null + ? UrlResolver::resolve('', UrlResolver::expand($this->url, $this->urlParameters), false) : UrlResolver::resolve( - $this->connector->resolveBaseUrl(), - $this->request->resolveEndpoint(), + UrlResolver::expand($this->connector->resolveBaseUrl(), $this->urlParameters), + UrlResolver::expand($this->request->resolveEndpoint(), $this->urlParameters), $this->request->allowsBaseUrlOverride() ?? $this->connector->allowsBaseUrlOverride(), ); $query = $this->queryString(); @@ -202,6 +263,8 @@ public function uri(): UriInterface /** * Finalize the request URI after ordinary request middleware. + * + * @internal */ public function finalizeUri(): static { @@ -224,6 +287,23 @@ public function withQueryParameters(array $parameters): static return $this; } + /** + * Remove query parameters and invalidate the finalized URI. + * + * Only values added through the query parameter methods are removed. Values embedded in the base URL, endpoint + * or raw query string are unchanged. + * + * @param array|string $keys + * @return $this + */ + public function withoutQueryParameters(array|string $keys): static + { + $this->uri = null; + $this->removeQueryParameters($keys); + + return $this; + } + /** * Replace the raw query string and invalidate the finalized URI. */ @@ -235,20 +315,30 @@ public function withQueryString(string $query): static } /** - * Authenticate the pending request immediately. + * Authenticate the pending request. + * + * The authenticator is applied immediately, except while plugins boot: it is then applied once every plugin has + * booted. * * @return $this */ public function authenticate(Authenticator $authenticator): static { $this->setAuthenticator($authenticator); - $authenticator->set($this); + + // A later plugin may still change the URL or headers the authenticator reads, such as an API version in the + // host, so applyAuthentication() applies the selected authenticator after the plugins boot. + if (! $this->bootingPlugins) { + $authenticator->set($this); + } return $this; } /** - * Apply the configured authenticator. + * Apply the selected authenticator. + * + * @internal */ public function applyAuthentication(): static { @@ -259,16 +349,26 @@ public function applyAuthentication(): static /** * Boot the connector and request plugins. + * + * @internal */ public function bootPlugins(): static { - (new BootPlugins)($this); + $this->bootingPlugins = true; + + try { + (new BootPlugins)($this); + } finally { + $this->bootingPlugins = false; + } return $this; } /** - * Merge worker-global middleware into this operation. + * Merge global or request middleware into this operation's own pipeline. + * + * @internal */ public function mergeMiddleware(MiddlewarePipeline $middleware): static { @@ -279,6 +379,8 @@ public function mergeMiddleware(MiddlewarePipeline $middleware): static /** * Execute the request middleware pipeline. + * + * @internal */ public function executeRequestPipeline(): static { @@ -305,6 +407,8 @@ public function executeFatalPipeline(FatalRequestException $exception): void /** * Prepare the request body after ordinary request middleware. + * + * @internal */ public function prepareBody(): static { @@ -325,6 +429,8 @@ public function prepareBody(): static /** * Get the prepared request body. + * + * @internal */ public function preparedBody(): ?StreamInterface { @@ -381,6 +487,7 @@ public function certificate(): array|string|null * * @param callable(RequestInterface, PendingRequest): void $observer * @return $this + * @internal */ public function observePsrRequest(callable $observer): static { @@ -401,6 +508,8 @@ public function handlePsrRequest(RequestInterface $request): RequestInterface /** * Notify the final PSR request observers. + * + * @internal */ public function notifyPsrRequestObservers(RequestInterface $request): void { @@ -439,6 +548,7 @@ public function createPsrRequest(): RequestInterface * Set the final application-owned PSR request. * * @return $this + * @internal */ public function setPsrRequest(RequestInterface $request): static { @@ -465,6 +575,11 @@ public function toPsrRequest(): RequestInterface $headers = $this->headers(); if (($contentType = $this->multipartContentType()) !== null) { + $headers = array_filter( + $headers, + static fn (string $name): bool => strcasecmp($name, 'Content-Type') !== 0, + ARRAY_FILTER_USE_KEY, + ); $headers['Content-Type'] = $contentType; } @@ -478,6 +593,8 @@ public function toPsrRequest(): RequestInterface /** * Restore the attempt body before another attempt. + * + * @internal */ public function restoreAttemptBody(): bool { @@ -496,6 +613,9 @@ public function restoreAttemptBody(): bool return true; } + // setBody() is not included: the body methods change the body, and defaultBodyRepository() supplies a custom + // repository. See the package README. + /** * Set the fake response. * @@ -516,8 +636,15 @@ public function fakeResponse(): ?FakeResponse return $this->fakeResponse; } + // hasFakeResponse() is not included: compare fakeResponse() with null. + + // isAsynchronous() and setAsynchronous() are not included: every pending request is sent synchronously, and pools + // send concurrently through coroutines. See the package README. + /** * Validate the request's caching configuration. + * + * @internal */ public function validateCachingConfiguration(): static { @@ -533,6 +660,8 @@ public function validateCachingConfiguration(): static /** * Determine if this operation is cacheable. + * + * @internal */ public function isCacheable(): bool { @@ -546,6 +675,8 @@ public function isCacheable(): bool /** * Determine if the matching cache entry should be invalidated. + * + * @internal */ public function shouldInvalidateCache(): bool { @@ -554,6 +685,8 @@ public function shouldInvalidateCache(): bool /** * Get the cache duration. + * + * @internal */ public function cacheFor(): DateInterval|DateTimeInterface|int { @@ -563,6 +696,8 @@ public function cacheFor(): DateInterval|DateTimeInterface|int /** * Get the selected cache store. + * + * @internal */ public function cacheStore(): UnitEnum|string|null { @@ -577,6 +712,8 @@ public function cacheStore(): UnitEnum|string|null /** * Resolve the custom cache key. + * + * @internal */ public function resolveCacheKey(): ?string { @@ -584,22 +721,6 @@ public function resolveCacheKey(): ?string ?? $this->connector->resolveCacheKey($this); } - /** - * Get the cache factory. - */ - public function cache(): CacheFactory - { - return $this->cache; - } - - /** - * Get the rate limiter manager. - */ - public function rateLimiter(): RateLimiter - { - return $this->rateLimiter; - } - /** * Resolve the request or connector that owns cache duration and storage. */ @@ -616,6 +737,7 @@ protected function cacheableResource(): ?Cacheable * Create the response selected by this operation. * * @return Response + * @internal */ public function createResponse(HttpResponse $response, RequestInterface $request): Response { @@ -653,15 +775,25 @@ protected function streamOffset(?StreamInterface $stream): ?int } /** - * Resolve the multipart content type when the caller did not provide one. + * Resolve the multipart content type unless the caller provided one with a boundary. */ protected function multipartContentType(): ?string { $repository = $this->bodyRepository(); - return $repository instanceof MultipartBodyRepository && ! $this->hasHeader('Content-Type') - ? $repository->contentType() - : null; + if (! $repository instanceof MultipartBodyRepository) { + return null; + } + + // A content type without a boundary, such as a connector's JSON default or one kept from the body format + // the request used before attach(), cannot describe a multipart body, so it is replaced. + foreach (HeaderNormalizer::normalize($this->headers()) as $name => $value) { + if (strcasecmp($name, 'Content-Type') === 0 && stripos(implode(', ', (array) $value), 'boundary=') !== false) { + return null; + } + } + + return $repository->contentType(); } /** diff --git a/src/saloon/src/Http/Pipeline.php b/src/saloon/src/Http/Pipeline.php index 7d1c2216ff..f048e5f531 100644 --- a/src/saloon/src/Http/Pipeline.php +++ b/src/saloon/src/Http/Pipeline.php @@ -54,9 +54,9 @@ public function pipe(callable $callable, ?string $name = null, ?PipeOrder $order $pipe = new Pipe($callable, $name, $order); match ($order) { - PipeOrder::First => $this->firstPipes[] = $pipe, + PipeOrder::FIRST => $this->firstPipes[] = $pipe, null => $this->pipes[] = $pipe, - PipeOrder::Last => $this->lastPipes[] = $pipe, + PipeOrder::LAST => $this->lastPipes[] = $pipe, }; if ($name !== null) { diff --git a/src/saloon/src/Http/Pool.php b/src/saloon/src/Http/Pool.php index b9903d0b25..23cb58b96d 100644 --- a/src/saloon/src/Http/Pool.php +++ b/src/saloon/src/Http/Pool.php @@ -211,7 +211,8 @@ protected function orchestrate(bool $collectResponses): array &$callbackFailures, ): void { try { - $response = $this->connector->send($request); + // Throwable responses are failures, so the exception handler receives every failed request. + $response = $this->connector->send($request)->throw(); } catch (CanceledException $exception) { $failures[$inputPosition] = [$key, new ChildCancellationException( 'A child coroutine running a Saloon pool request was canceled while its owner remained active.', diff --git a/src/saloon/src/Http/Request.php b/src/saloon/src/Http/Request.php index e4cef0612e..b027f110dd 100644 --- a/src/saloon/src/Http/Request.php +++ b/src/saloon/src/Http/Request.php @@ -4,8 +4,10 @@ namespace Hypervel\Saloon\Http; -use Hypervel\Contracts\Container\SelfBuilding; +use Hypervel\Contracts\Container\Transient; use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; +use Hypervel\RateLimiter\Cooldown; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Traits\Auth\AuthenticatesRequests; use Hypervel\Saloon\Traits\Body\HasBody; @@ -24,7 +26,7 @@ use UnitEnum; /** @template-covariant TDto */ -abstract class Request implements SelfBuilding +abstract class Request implements Transient { /** @use CreatesDtoFromResponse */ use CreatesDtoFromResponse; @@ -52,14 +54,6 @@ abstract class Request implements SelfBuilding */ protected ?string $url = null; - /** - * Create a fresh request for container resolution. - */ - public static function newInstance(): static - { - return new static; - } - /** * Get the HTTP method used by the request. */ @@ -153,11 +147,11 @@ public function resolveCacheableMethods(): ?array } /** - * Determine if this request defines rate limits. + * Determine if the request's rate limits apply to an operation. * * @internal */ - public function usesRateLimits(): bool + public function usesRateLimits(PendingRequest $pendingRequest): bool { return false; } @@ -184,11 +178,21 @@ public function resolveRateLimitStoreName(): UnitEnum|string|null } /** - * Determine if request rate limits should be awaited. + * Resolve the identity whose limits and cooldowns the request uses. + * + * @internal + */ + public function rateLimiterName(): string + { + return static::class; + } + + /** + * Determine if a denied request rate limit should be awaited. * * @internal */ - public function shouldWaitForRateLimits(): bool + public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return false; } @@ -200,7 +204,7 @@ public function shouldWaitForRateLimits(): bool */ public function resolveRateLimitCooldownKeyFor(PendingRequest $pendingRequest): string { - return static::class; + return ''; } /** @@ -221,7 +225,6 @@ public function __clone(): void $this->headerRepository = $this->headerRepository !== null ? clone $this->headerRepository : null; $this->queryRepository = $this->queryRepository !== null ? clone $this->queryRepository : null; $this->optionRepository = $this->optionRepository !== null ? clone $this->optionRepository : null; - $this->delayRepository = $this->delayRepository !== null ? clone $this->delayRepository : null; $this->middlewarePipeline = $this->middlewarePipeline !== null ? clone $this->middlewarePipeline : null; $this->bodyRepository = $this->bodyRepository !== null ? clone $this->bodyRepository : null; } diff --git a/src/saloon/src/Http/Response.php b/src/saloon/src/Http/Response.php index 4211a3cab0..0b94be4e36 100644 --- a/src/saloon/src/Http/Response.php +++ b/src/saloon/src/Http/Response.php @@ -11,6 +11,20 @@ use Hypervel\Saloon\Exceptions\Request\ClientException; use Hypervel\Saloon\Exceptions\Request\RequestException; use Hypervel\Saloon\Exceptions\Request\ServerException; +use Hypervel\Saloon\Exceptions\Request\Statuses\BadGatewayException; +use Hypervel\Saloon\Exceptions\Request\Statuses\BadRequestException; +use Hypervel\Saloon\Exceptions\Request\Statuses\ConflictException; +use Hypervel\Saloon\Exceptions\Request\Statuses\ForbiddenException; +use Hypervel\Saloon\Exceptions\Request\Statuses\GatewayTimeoutException; +use Hypervel\Saloon\Exceptions\Request\Statuses\InternalServerErrorException; +use Hypervel\Saloon\Exceptions\Request\Statuses\MethodNotAllowedException; +use Hypervel\Saloon\Exceptions\Request\Statuses\NotFoundException; +use Hypervel\Saloon\Exceptions\Request\Statuses\PaymentRequiredException; +use Hypervel\Saloon\Exceptions\Request\Statuses\RequestTimeOutException; +use Hypervel\Saloon\Exceptions\Request\Statuses\ServiceUnavailableException; +use Hypervel\Saloon\Exceptions\Request\Statuses\TooManyRequestsException; +use Hypervel\Saloon\Exceptions\Request\Statuses\UnauthorizedException; +use Hypervel\Saloon\Exceptions\Request\Statuses\UnprocessableEntityException; use InvalidArgumentException; use LogicException; use Psr\Http\Message\RequestInterface; @@ -102,6 +116,9 @@ public function stream(): StreamInterface return $this->response->getBody(); } + // withBufferedBody() is not included: body() buffers a stream that can't be rewound. getSenderException() is not + // included either, since an error status never throws in the transport. See the package README. + /** * Get the pending request. * @@ -204,11 +221,39 @@ protected function newRequestException(): RequestException return $exception; } - return match (true) { - $this->clientError() => new ClientException($this, $this->truncateExceptionsAt), - $this->serverError() => new ServerException($this, $this->truncateExceptionsAt), - default => new RequestException($this, $this->truncateExceptionsAt), + $status = $this->status(); + + $exception = match (true) { + $status === 400 => BadRequestException::class, + $status === 401 => UnauthorizedException::class, + $status === 402 => PaymentRequiredException::class, + $status === 403 => ForbiddenException::class, + $status === 404 => NotFoundException::class, + $status === 405 => MethodNotAllowedException::class, + $status === 408 => RequestTimeOutException::class, + $status === 409 => ConflictException::class, + $status === 422 => UnprocessableEntityException::class, + $status === 429 => TooManyRequestsException::class, + $status === 500 => InternalServerErrorException::class, + $status === 502 => BadGatewayException::class, + $status === 503 => ServiceUnavailableException::class, + $status === 504 => GatewayTimeoutException::class, + $this->serverError() => ServerException::class, + $this->clientError() => ClientException::class, + default => RequestException::class, }; + + return new $exception($this, $this->truncateExceptionsAt); + } + + /** + * Get the JSON decoded body as an array. Provide a key to find a specific item in the JSON. + * + * Alias of json() + */ + public function array(int|string|null $key = null, mixed $default = null): mixed + { + return $this->json(is_int($key) ? (string) $key : $key, $default); } /** @@ -237,7 +282,7 @@ public function dtoOrFail(): mixed { if ($this->failed()) { throw new LogicException( - 'Unable to create a data transfer object because the response failed.', + 'Unable to create data transfer object as the response has failed.', 0, $this->toException(), ); @@ -256,6 +301,8 @@ public function xml(mixed ...$arguments): SimpleXMLElement|false return simplexml_load_string($this->body(), ...$arguments); } + // xmlReader() is not included; see the package README. + /** * Parse the HTML or XML response into a DOM crawler. */ @@ -272,6 +319,22 @@ public function dataUrl(): string return 'data:' . $this->header('Content-Type') . ';base64,' . base64_encode($this->body()); } + /** + * Determine if the response is in JSON format. + */ + public function isJson(): bool + { + return str_contains(mb_strtolower($this->header('Content-Type')), 'json'); + } + + /** + * Determine if the response is in XML format. + */ + public function isXml(): bool + { + return str_contains(mb_strtolower($this->header('Content-Type')), 'xml'); + } + /** * Create a temporary resource containing the response body. * diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php index 1c9945498e..02161f3b7c 100644 --- a/src/saloon/src/Http/Sender.php +++ b/src/saloon/src/Http/Sender.php @@ -7,7 +7,9 @@ use GuzzleHttp\Cookie\SetCookie; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Telescope\TelescopeTag; +use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\Factory; +use Hypervel\Http\Client\RequestException as HttpRequestException; use Psr\Http\Message\RequestInterface; class Sender @@ -74,10 +76,17 @@ public function send(PendingRequest $pendingRequest, array $transport): Response return $request; }); - $httpResponse = $httpRequest->send( - $pendingRequest->method()->value, - (string) $pendingRequest->uri(), - ); + try { + $httpResponse = $httpRequest->send( + $pendingRequest->method()->value, + (string) $pendingRequest->uri(), + ); + } catch (HttpRequestException $exception) { + // A transfer that fails after the response headers arrive keeps its partial response. The HTTP client + // throws that as a request exception for a 4xx or 5xx status and as a connection exception otherwise, so + // both reach Saloon as an exchange that could not complete. + throw new ConnectionException($exception->getMessage(), 0, $exception); + } return $pendingRequest->createResponse( $httpResponse, diff --git a/src/saloon/src/Http/SoloRequest.php b/src/saloon/src/Http/SoloRequest.php index 1c1661dac6..fe73d7e55e 100644 --- a/src/saloon/src/Http/SoloRequest.php +++ b/src/saloon/src/Http/SoloRequest.php @@ -5,7 +5,7 @@ namespace Hypervel\Saloon\Http; use Hypervel\Saloon\Http\Connectors\NullConnector; -use Hypervel\Saloon\Http\Faking\MockClient; +use Hypervel\Saloon\Traits\Request\HasConnector; /** * @template TDto @@ -13,28 +13,8 @@ */ abstract class SoloRequest extends Request { - /** - * The connector used by the standalone request. - */ - protected ?Connector $connector = null; - - /** - * Get the connector used by the standalone request. - */ - public function connector(): Connector - { - return $this->connector ??= $this->resolveConnector(); - } - - /** - * Send the standalone request. - * - * @return Response - */ - public function send(?MockClient $mockClient = null): Response - { - return $this->connector()->send($this, $mockClient); - } + /** @use HasConnector */ + use HasConnector; /** * Resolve whether this request may use an absolute endpoint. diff --git a/src/saloon/src/Http/UrlResolver.php b/src/saloon/src/Http/UrlResolver.php index 8466f7d6cb..d655a4bd30 100644 --- a/src/saloon/src/Http/UrlResolver.php +++ b/src/saloon/src/Http/UrlResolver.php @@ -5,16 +5,39 @@ namespace Hypervel\Saloon\Http; use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\UriTemplate\UriTemplate; use Hypervel\Saloon\Exceptions\PendingRequestException; use Psr\Http\Message\UriInterface; final class UrlResolver { + /** + * Substitute the URL parameters in the given URL. + * + * @param array $parameters + */ + public static function expand(string $url, array $parameters): string + { + if ($parameters === [] || ! str_contains($url, '{')) { + return $url; + } + + return UriTemplate::expand($url, $parameters); + } + /** * Resolve the connector base URL and request endpoint. */ public static function resolve(string $baseUrl, string $endpoint, bool $allowBaseUrlOverride): UriInterface { + $absolute = preg_match('~^[A-Za-z][A-Za-z0-9+.-]*://~', $endpoint) === 1; + + // Only a scheme followed by "//" names another host. Other endpoints are paths, so they are parsed with one + // leading slash: URI parsing would read "documents:batchGet" as a scheme and "//users" as a host. + if (! $absolute && $endpoint !== '' && ! str_starts_with($endpoint, '?') && ! str_starts_with($endpoint, '#')) { + $endpoint = '/' . ltrim($endpoint, '/'); + } + $base = new Uri($baseUrl); $endpointUri = new Uri($endpoint); @@ -22,11 +45,11 @@ public static function resolve(string $baseUrl, string $endpoint, bool $allowBas self::ensureAbsoluteHttpUri($base, 'connector base URL'); } - if ($endpointUri->getScheme() !== '' || $endpointUri->getHost() !== '') { + if ($absolute) { self::ensureAbsoluteHttpUri($endpointUri, 'request endpoint'); if ($baseUrl !== '' && ! $allowBaseUrlOverride) { - throw new PendingRequestException('The request endpoint cannot replace the connector base URL.'); + throw new PendingRequestException('The request endpoint cannot replace the connector base URL. To request a different host, use a connector with that host as its base URL, or return true from allowsBaseUrlOverride() on the connector or request when the endpoint is trusted.'); } return $endpointUri; @@ -69,6 +92,10 @@ public static function withQuery(UriInterface $uri, array $parameters): UriInter $retained = []; foreach ($pairs as $pair) { + if ($pair === '') { + continue; + } + $encodedName = explode('=', $pair, 2)[0]; $name = urldecode($encodedName); diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php index 3573470708..684c130488 100644 --- a/src/saloon/src/Pagination/Paginator.php +++ b/src/saloon/src/Pagination/Paginator.php @@ -110,27 +110,7 @@ public function __construct( $this->request = clone $request; $this->request->middleware() - ->onResponse(static fn (Response $response): Response => $response->throw()) - ->onResponse(function (Response $response): void { - if (! $this->detectInfiniteLoop || $this->pooling) { - return; - } - - // Retrying the current page replaces its checksum instead of counting as another page. - $this->lastFiveBodyChecksums[$this->currentPage] = hash('xxh128', $response->body()); - - if (count($this->lastFiveBodyChecksums) < 5) { - return; - } - - if (count(array_unique($this->lastFiveBodyChecksums)) === 1) { - throw new PaginationException( - 'Potential infinite loop detected because the last five pages had the same body.', - ); - } - - unset($this->lastFiveBodyChecksums[array_key_first($this->lastFiveBodyChecksums)]); - }); + ->onResponse(static fn (Response $response): Response => $response->throw()); } /** @@ -147,6 +127,24 @@ public function current(): Response $request = $this->applyPagination(clone $this->request); $response = $this->connector->send($request); + + // Checked here instead of in request middleware, where the callback would keep the paginator alive through + // its own request and every response. + if ($this->detectInfiniteLoop && ! $this->pooling) { + // Loading a page again after a mapping failure replaces its checksum instead of adding another page. + $this->lastFiveBodyChecksums[$this->currentPage] = $this->getBodyChecksum($response); + + if (count($this->lastFiveBodyChecksums) >= 5) { + if (count(array_unique($this->lastFiveBodyChecksums)) === 1) { + throw new PaginationException( + 'Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', + ); + } + + unset($this->lastFiveBodyChecksums[array_key_first($this->lastFiveBodyChecksums)]); + } + } + $this->currentPageItems = $this->pageItems($response); // Keep the preceding response until mapping succeeds so a failed load retries the same page. $this->currentResponse = $response; @@ -394,6 +392,16 @@ public function count(): int return $count; } + /** + * Get the checksum of the response body. + * + * @param Response $response + */ + protected function getBodyChecksum(Response $response): string + { + return hash('xxh128', $response->body()); + } + /** * Resolve response items using the request override when present. * diff --git a/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php b/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php index 0475980bc6..d99ecbbd1f 100644 --- a/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php +++ b/src/saloon/src/RateLimit/Exceptions/RateLimitReachedException.php @@ -15,12 +15,14 @@ class RateLimitReachedException extends SaloonException * Create a rate limit exception. */ public function __construct( + string $limiterName, protected readonly AdmissionPolicy|Cooldown $policy, protected readonly Decision $result, ) { - parent::__construct($policy->key === '' - ? 'The request rate limit has been reached.' - : sprintf('The request rate limit [%s] has been reached.', $policy->key)); + parent::__construct(sprintf( + 'The request rate limit [%s] has been reached.', + $policy->key === '' ? $limiterName : $limiterName . ':' . $policy->key, + )); } /** diff --git a/src/saloon/src/RateLimit/Traits/HasRateLimits.php b/src/saloon/src/RateLimit/Traits/HasRateLimits.php index 930566cf2a..f2c5d3268d 100644 --- a/src/saloon/src/RateLimit/Traits/HasRateLimits.php +++ b/src/saloon/src/RateLimit/Traits/HasRateLimits.php @@ -6,6 +6,8 @@ use Carbon\Exceptions\InvalidFormatException; use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; +use Hypervel\RateLimiter\Cooldown; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Response; use Hypervel\Support\CarbonImmutable; @@ -14,13 +16,13 @@ trait HasRateLimits { /** - * Determine if this resource defines rate limits. + * Determine if this resource's rate limits apply to an operation. * * @internal */ - final public function usesRateLimits(): bool + final public function usesRateLimits(PendingRequest $pendingRequest): bool { - return true; + return $this->rateLimitingEnabled($pendingRequest); } /** @@ -45,13 +47,23 @@ final public function resolveRateLimitStoreName(): UnitEnum|string|null } /** - * Determine if denied rate limits should be awaited. + * Resolve the identity whose limits and cooldowns this resource uses. + * + * @internal + */ + final public function rateLimiterName(): string + { + return $this->resolveRateLimiterName(); + } + + /** + * Determine if a denied rate limit should be awaited. * * @internal */ - final public function shouldWaitForRateLimits(): bool + final public function shouldWaitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { - return $this->waitForRateLimits(); + return $this->waitForRateLimits($policy, $result); } /** @@ -74,6 +86,14 @@ final public function resolveRateLimitCooldownFor(Response $response): ?int return $this->resolveRateLimitCooldown($response); } + /** + * Determine if this resource's rate limits apply to an operation. + */ + protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool + { + return true; + } + /** * Resolve the admission policies for an operation. * @@ -90,9 +110,20 @@ protected function resolveRateLimitStore(): UnitEnum|string|null } /** - * Determine if denied rate limits should be awaited. + * Resolve the identity whose limits and cooldowns this resource uses. + * + * Resources that return the same name share limits and cooldowns within + * the same store, key scope and policy identity. + */ + protected function resolveRateLimiterName(): string + { + return static::class; + } + + /** + * Determine if a denied rate limit should be awaited. */ - protected function waitForRateLimits(): bool + protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return false; } @@ -102,7 +133,7 @@ protected function waitForRateLimits(): bool */ protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string { - return static::class; + return ''; } /** @@ -117,8 +148,20 @@ protected function resolveRateLimitCooldown(Response $response): ?int return null; } - $retryAfter = trim($response->header('Retry-After')); + // A 429 without a usable delay still blocks for upstream's 60-second default. A valid delay that has + // already passed blocks nothing. + $seconds = $this->parseRateLimitRetryAfter(trim($response->header('Retry-After'))) ?? 60; + + return $seconds > 0 ? $seconds : null; + } + /** + * Parse a Retry-After value into seconds. + * + * Return null when the value is missing, malformed or too long for the rate limiter. + */ + private function parseRateLimitRetryAfter(string $retryAfter): ?int + { if ($retryAfter === '') { return null; } @@ -224,12 +267,10 @@ private function parseRateLimitRetryAfterHttpDate(string $format, string $timest } /** - * Return a positive cooldown that the rate limiter can represent. + * Return a delay the rate limiter can represent, or null when it is too long. */ private function checkedRateLimitCooldownSeconds(int $seconds): ?int { - return $seconds > 0 && $seconds <= intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) - ? $seconds - : null; + return $seconds <= intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) ? $seconds : null; } } diff --git a/src/saloon/src/Repositories/Body/MultipartBodyRepository.php b/src/saloon/src/Repositories/Body/MultipartBodyRepository.php index 9a1de69d48..e5a5494782 100644 --- a/src/saloon/src/Repositories/Body/MultipartBodyRepository.php +++ b/src/saloon/src/Repositories/Body/MultipartBodyRepository.php @@ -76,7 +76,7 @@ public function merge(array ...$arrays): static /** * Add an element to the repository. * - * @param float|int|resource|StreamInterface|string $contents + * @param null|array|bool|float|int|resource|StreamInterface|string $contents * @param array|string> $headers * @return $this */ @@ -181,7 +181,7 @@ protected function parseMultipartArray(array $value): array /** * Get the multipart boundary. */ - public function boundary(): string + public function getBoundary(): string { return $this->boundary; } @@ -202,17 +202,20 @@ public function toStream(): StreamInterface $parts = array_map(static function (MultipartValue $value): array { $part = [ 'name' => $value->name, - 'contents' => is_int($value->value) || is_float($value->value) - ? (string) $value->value - : $value->value, + 'contents' => $value->contents(), ]; if ($value->filename !== null) { $part['filename'] = $value->filename; } + // Guzzle's multipart stream accepts one string per part header, so list values are combined the way + // HTTP combines repeated fields. if ($value->headers !== []) { - $part['headers'] = $value->headers; + $part['headers'] = array_map( + static fn (array|string $header): string => is_array($header) ? implode(', ', $header) : $header, + $value->headers, + ); } return $part; diff --git a/src/saloon/src/Repositories/Body/StreamBodyRepository.php b/src/saloon/src/Repositories/Body/StreamBodyRepository.php index 8e40827d0e..94586aba22 100644 --- a/src/saloon/src/Repositories/Body/StreamBodyRepository.php +++ b/src/saloon/src/Repositories/Body/StreamBodyRepository.php @@ -21,6 +21,11 @@ class StreamBodyRepository implements BodyRepository */ protected mixed $stream = null; + /** + * The stream that wraps a resource body. + */ + protected ?StreamInterface $resourceStream = null; + /** * Create a stream body repository. * @@ -43,6 +48,13 @@ public function set(mixed $value): static throw new InvalidArgumentException('The body value must be a resource, null, or an instance of ' . StreamInterface::class . '.'); } + // A Guzzle stream closes its resource when destroyed, so a resource is wrapped once, when it is supplied. + // Copies of the repository and every conversion then share one wrapper, which closes the resource only when + // nothing uses it, and setting the same resource again keeps that wrapper. + if ($value !== $this->stream) { + $this->resourceStream = is_resource($value) ? Utils::streamFor($value) : null; + } + $this->stream = $value; return $this; @@ -91,6 +103,6 @@ public function toStream(): StreamInterface { return $this->stream instanceof StreamInterface ? $this->stream - : Utils::streamFor($this->stream); + : $this->resourceStream ?? Utils::streamFor(); } } diff --git a/src/saloon/src/Repositories/IntegerRepository.php b/src/saloon/src/Repositories/IntegerRepository.php deleted file mode 100644 index aa518f4656..0000000000 --- a/src/saloon/src/Repositories/IntegerRepository.php +++ /dev/null @@ -1,61 +0,0 @@ -set($value); - } - - /** - * Set the repository value. - * - * @return $this - */ - public function set(?int $value): static - { - $this->data = $value; - - return $this; - } - - /** - * Get the repository value. - */ - public function get(): ?int - { - return $this->data; - } - - /** - * Determine if the repository is empty. - */ - public function isEmpty(): bool - { - return $this->data === null; - } - - /** - * Determine if the repository is not empty. - */ - public function isNotEmpty(): bool - { - return ! $this->isEmpty(); - } -} diff --git a/src/saloon/src/SaloonManager.php b/src/saloon/src/SaloonManager.php index 0cde149de9..79c2247e8b 100644 --- a/src/saloon/src/SaloonManager.php +++ b/src/saloon/src/SaloonManager.php @@ -14,11 +14,14 @@ use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Contracts\Decision; use Hypervel\RateLimiter\Cooldown; +use Hypervel\RateLimiter\CooldownResult; use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\LimitResult; use Hypervel\RateLimiter\RateLimiter; use Hypervel\Saloon\Cache\CacheKey; use Hypervel\Saloon\Cache\Data\CachedResponse; use Hypervel\Saloon\Data\RecordedResponse; +use Hypervel\Saloon\Data\RetryPolicy; use Hypervel\Saloon\Events\SendingSaloonRequest; use Hypervel\Saloon\Events\SentSaloonRequest; use Hypervel\Saloon\Exceptions\BodyException; @@ -95,20 +98,7 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl while (true) { ++$attempt; - $pendingRequest = new PendingRequest( - $connector, - $request, - $this->cache, - $this->rateLimiter, - ); - $pendingRequest - ->applyAuthentication() - ->bootPlugins(); - $connector->boot($pendingRequest); - $request->boot($pendingRequest); - $pendingRequest - ->mergeMiddleware($this->middleware) - ->executeRequestPipeline(); + $pendingRequest = $this->createPendingRequest($connector, $request); if ($this->events->hasListeners(SendingSaloonRequest::class)) { $this->events->dispatch(new SendingSaloonRequest($pendingRequest)); @@ -124,25 +114,17 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl // its URI, headers, cookies, and prepared body directly. $transport = $this->sender->resolveTransport($pendingRequest); - $fixture = null; - - if ($pendingRequest->fakeResponse() === null && $mockClient !== null) { - $matchedResponse = $mockClient->match($pendingRequest); - - if ($matchedResponse instanceof Fixture) { - $fixture = $matchedResponse; - $matchedResponse = $matchedResponse->getMockResponse(); - } - - $pendingRequest->setFakeResponse($matchedResponse); - } - $response = null; $responseFromSender = false; $cacheRepository = null; $cacheKey = null; + $fixture = null; - if ($pendingRequest->fakeResponse() === null && $pendingRequest->isCacheable()) { + // The cache is read before the mock client is matched, so a hit consumes no mock response or fixture. A + // fake supplied by request middleware bypasses the cache. + if ($pendingRequest->fakeResponse() === null + && $mockClient?->shouldBypassResponseCache() !== true + && $pendingRequest->isCacheable()) { [$cacheRepository, $cacheKey] = $this->resolveCache($pendingRequest, $transport); if ($pendingRequest->shouldInvalidateCache()) { @@ -159,6 +141,17 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl } } + if ($response === null && $pendingRequest->fakeResponse() === null && $mockClient !== null) { + $matchedResponse = $mockClient->match($pendingRequest); + + if ($matchedResponse instanceof Fixture) { + $fixture = $matchedResponse; + $matchedResponse = $matchedResponse->getMockResponse(); + } + + $pendingRequest->setFakeResponse($matchedResponse); + } + if ($response === null && $pendingRequest->fakeResponse() === null) { $this->enforceRateLimits($pendingRequest); } @@ -167,6 +160,9 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl try { if ($response === null) { + // Fake responses stand in for a send, so they wait for the delay too. Cache hits send nothing. + $this->sleepMilliseconds($pendingRequest->delayMilliseconds() ?? 0); + if (($fakeResponse = $pendingRequest->fakeResponse()) !== null) { if (($exception = $fakeResponse->getException($pendingRequest)) !== null) { throw $exception; @@ -178,7 +174,6 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl ->setMocked($fakeResponse instanceof MockResponse) ->setFakeResponse($fakeResponse); } else { - $this->sleepMilliseconds($pendingRequest->delayMilliseconds() ?? 0); $response = $this->sender->send($pendingRequest, $transport); $responseFromSender = true; } @@ -192,10 +187,10 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl $this->recordRateLimitCooldowns($pendingRequest, $response); } - if ($responseFromSender - && $cacheRepository !== null + if ($cacheRepository !== null && $cacheKey !== null - && $response->successful()) { + && ! $response->isCached() + && ! $response->failed()) { $cacheRepository->put( $cacheKey, CachedResponse::fromResponse($response), @@ -203,7 +198,10 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl ); } - $mockClient?->recordResponse($response); + // A cache hit sends nothing, so mock client assertions do not count it. + if (! $response->isCached()) { + $mockClient?->recordResponse($response); + } if ($this->events->hasListeners(SentSaloonRequest::class)) { $this->events->dispatch(new SentSaloonRequest($pendingRequest, $response)); @@ -215,15 +213,19 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl if ($exception === null) { return $response; } - } catch (ConnectionException $exception) { - $exception = new FatalRequestException($exception, $pendingRequest); + } catch (ConnectionException|FatalRequestException $exception) { + // A fake response may throw the fatal exception itself to simulate a failed connection. + if ($exception instanceof ConnectionException) { + $exception = new FatalRequestException($exception, $pendingRequest); + } + $pendingRequest->executeFatalPipeline($exception); } catch (RequestException $exception) { $response = $exception->response(); $exceptionWasThrown = true; } - $retryPolicy = $pendingRequest->retryPolicy(); + $retryPolicy = $pendingRequest->retryPolicy() ?? new RetryPolicy; $maximumAttempts = $retryPolicy->maximumAttempts(); $shouldRetry = $attempt < $maximumAttempts && ($retryPolicy->when === null || ($retryPolicy->when)($exception, $pendingRequest)); @@ -250,27 +252,53 @@ public function send(Connector $connector, Request $request, ?MockClient $mockCl } /** - * Get the Saloon sender. + * Prepare a request for sending through a connector. + * + * The pending request has run its plugins, authenticator, boot hooks and request middleware, in that order. It + * has not been finalized, matched against mock responses or sent. + * + * Middleware is registered on the operation's own pipeline in this order: global middleware, plugin middleware, + * middleware added by the connector's boot method, the request's middleware, then middleware added by the + * request's boot method. Connectors are read-only, so connector middleware is registered from the boot method and + * still runs before the request's. + * + * @template TDto + * @param Request $request + * @return PendingRequest */ - public function sender(): Sender + public function createPendingRequest(Connector $connector, Request $request): PendingRequest { - return $this->sender; + $pendingRequest = new PendingRequest($connector, $request); + $pendingRequest + ->mergeMiddleware($this->middleware) + ->bootPlugins() + ->applyAuthentication(); + $connector->boot($pendingRequest); + $pendingRequest->mergeMiddleware($request->middleware()); + $request->boot($pendingRequest); + + return $pendingRequest->executeRequestPipeline(); } /** - * Get the cache factory. + * Inspect a connector or request rate limit without consuming it. + * + * @return ($policy is Cooldown ? CooldownResult : LimitResult) */ - public function cache(): CacheFactory + public function inspectRateLimit(Connector|Request $resource, AdmissionPolicy|Cooldown $policy): LimitResult|CooldownResult { - return $this->cache; + return $this->rateLimiterFor($resource)->inspect($policy, $this->limiterNameFor($resource)); } /** - * Get the rate limiter manager. + * Set the event dispatcher. + * + * Boot or tests only. The dispatcher persists on the manager for the + * worker lifetime and receives every subsequent Saloon request event. */ - public function rateLimiter(): RateLimiter + public function setEventDispatcher(Dispatcher $events): void { - return $this->rateLimiter; + $this->events = $events; } /** @@ -285,7 +313,7 @@ public function middleware(): MiddlewarePipeline } /** - * Replace the global mock client. + * Add responses to the global mock client, or replace it with the given client. * * Tests only. The client persists on the manager until the test * application is destroyed or `clearFake()` is called. @@ -294,9 +322,13 @@ public function middleware(): MiddlewarePipeline */ public function fake(array|MockClient $responses = []): MockClient { - return $this->mockClient = $responses instanceof MockClient - ? $responses - : new MockClient($responses); + if ($responses instanceof MockClient) { + return $this->mockClient = $responses; + } + + ($this->mockClient ??= new MockClient)->addResponses($responses); + + return $this->mockClient; } /** @@ -366,6 +398,10 @@ public function assertSentCount(int $count, ?string $requestClass = null): void ($this->mockClient ?? new MockClient)->assertSentCount($count, $requestClass); } + // The plugin's deprecated record(), stopRecording(), isRecording(), recordResponse(), getRecordedResponses() and + // getLastRecordedResponse() are not included: recorded() lists the responses recorded by the active mock client, + // and the SentSaloonRequest event observes every sent response. See the package README. + /** * Register the cache scope resolver. * @@ -391,6 +427,24 @@ public function resolveCacheScope(PendingRequest $pendingRequest): ?string : null; } + /** + * Clear the cached response for a request without sending it. + * + * The request is prepared as it is for sending, through its plugins, boot methods and middleware, so the same + * store, key and scope are resolved. Sending listeners do not run. + */ + public function clearCache(Connector $connector, Request $request): void + { + $pendingRequest = $this->createPendingRequest($connector, $request) + ->finalizeUri() + ->prepareBody() + ->validateCachingConfiguration(); + + [$repository, $key] = $this->resolveCache($pendingRequest, $this->sender->resolveTransport($pendingRequest)); + + $repository->forget($key); + } + /** * Set the fixture storage path. * @@ -492,7 +546,7 @@ protected function enforceRateLimits(PendingRequest $pendingRequest): void */ protected function enforceRateLimitsFor(PendingRequest $pendingRequest, Connector|Request $resource): void { - if (! $resource->usesRateLimits()) { + if (! $resource->usesRateLimits($pendingRequest)) { return; } @@ -514,7 +568,7 @@ protected function enforceRateLimitsFor(PendingRequest $pendingRequest, Connecto } $limiter = $this->rateLimiterFor($resource); - $limiterName = 'saloon:' . $resource::class; + $limiterName = $this->limiterNameFor($resource); $cooldown = Cooldown::for($resource->resolveRateLimitCooldownKeyFor($pendingRequest)); while (true) { @@ -554,7 +608,7 @@ protected function recordRateLimitCooldownFor( Response $response, Connector|Request $resource, ): void { - if (! $resource->usesRateLimits() + if (! $resource->usesRateLimits($pendingRequest) || ($seconds = $resource->resolveRateLimitCooldownFor($response)) === null) { return; } @@ -562,10 +616,18 @@ protected function recordRateLimitCooldownFor( $this->rateLimiterFor($resource)->block( Cooldown::for($resource->resolveRateLimitCooldownKeyFor($pendingRequest)), $seconds, - 'saloon:' . $resource::class, + $this->limiterNameFor($resource), ); } + /** + * Resolve the limiter name used for a resource's limits and cooldowns. + */ + protected function limiterNameFor(Connector|Request $resource): string + { + return 'saloon:' . $resource->rateLimiterName(); + } + /** * Resolve the framework limiter selected by a resource. */ @@ -587,8 +649,8 @@ protected function waitForRateLimit( AdmissionPolicy|Cooldown $policy, Decision $result, ): void { - if (! $resource->shouldWaitForRateLimits()) { - throw new RateLimitReachedException($policy, $result); + if (! $resource->shouldWaitForRateLimits($policy, $result)) { + throw new RateLimitReachedException($this->limiterNameFor($resource), $policy, $result); } Sleep::sleep($result->retryAfter()); diff --git a/src/saloon/src/SaloonServiceProvider.php b/src/saloon/src/SaloonServiceProvider.php index 4208bf20aa..76660901d5 100644 --- a/src/saloon/src/SaloonServiceProvider.php +++ b/src/saloon/src/SaloonServiceProvider.php @@ -4,11 +4,14 @@ namespace Hypervel\Saloon; +use Hypervel\Cache\CacheManager; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Http\Client\Factory as HttpFactory; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\Saloon\Cache\Data\CachedResponse; use Hypervel\Saloon\Console\Commands\ListCommand; use Hypervel\Saloon\Console\Commands\MakeAuthenticator; use Hypervel\Saloon\Console\Commands\MakeConnector; @@ -37,13 +40,23 @@ public function register(): void )); $this->app->alias('saloon', SaloonManager::class); + + // Event fakes replace the dispatcher after the manager may already hold the original. + $this->app->rebinding('events', function (Container $container, Dispatcher $events): void { + if ($container->resolved('saloon')) { + $container->make('saloon')->setEventDispatcher($events); + } + }); } /** * Bootstrap the package services. */ - public function boot(HttpFactory $httpFactory, ConfigRepository $config): void + public function boot(HttpFactory $httpFactory, ConfigRepository $config, CacheManager $cache): void { + // Cached responses are stored as CachedResponse objects, which serializing stores must be allowed to restore. + $cache->allowSerializableClassesUsing(static fn (): array => [CachedResponse::class]); + $connection = $config->string('saloon.connection.name'); $options = $config->array('saloon.connection.options'); @@ -54,6 +67,9 @@ public function boot(HttpFactory $httpFactory, ConfigRepository $config): void $options, ); + // The plugin's Telescope, Pulse and Nightwatch middleware are not included: Saloon requests go through the + // HTTP client, which Telescope's watcher records, and Hypervel has no Pulse or Nightwatch package. + $this->registerConsoleResources(); } diff --git a/src/saloon/src/Traits/Auth/AuthenticatesRequests.php b/src/saloon/src/Traits/Auth/AuthenticatesRequests.php index 6aefd811c1..6f02dff055 100644 --- a/src/saloon/src/Traits/Auth/AuthenticatesRequests.php +++ b/src/saloon/src/Traits/Auth/AuthenticatesRequests.php @@ -75,5 +75,8 @@ public function withDigestAuth(string $username, #[SensitiveParameter] string $p return $this->authenticate(new DigestAuthenticator($username, $password)); } + // Upstream's deprecated withTokenAuth(), withQueryAuth(), withHeaderAuth() and withCertificateAuth() are not + // included; pass the authenticator to authenticate(). + // NtlmAuthenticator and withNtlmAuth() are omitted; use a custom authenticator if needed. } diff --git a/src/saloon/src/Traits/Body/HasBody.php b/src/saloon/src/Traits/Body/HasBody.php index cf55406a8c..32a51ea91a 100644 --- a/src/saloon/src/Traits/Body/HasBody.php +++ b/src/saloon/src/Traits/Body/HasBody.php @@ -6,6 +6,7 @@ use Hypervel\Saloon\Contracts\Body\BodyRepository; use Hypervel\Saloon\Data\MultipartValue; +use Hypervel\Saloon\Http\StructuredDataNormalizer; use Hypervel\Saloon\Repositories\Body\ArrayBodyRepository; use Hypervel\Saloon\Repositories\Body\FormBodyRepository; use Hypervel\Saloon\Repositories\Body\JsonBodyRepository; @@ -15,6 +16,10 @@ use Psr\Http\Message\StreamInterface; use Stringable; +/** + * Every request and pending request uses this trait, so body traits need no upstream `HasBody` contract or + * `ChecksForHasBody` check. + */ trait HasBody { /** @@ -68,13 +73,15 @@ public function asForm(array $data = []): static } /** - * Use a multipart request body. + * Use a multipart request body, keeping any existing multipart values. * * @return $this */ public function asMultipart(): static { - $this->bodyRepository = new MultipartBodyRepository; + if (! $this->bodyRepository() instanceof MultipartBodyRepository) { + $this->bodyRepository = new MultipartBodyRepository; + } return $this; } @@ -82,22 +89,30 @@ public function asMultipart(): static /** * Attach a multipart value to the request. * - * @param float|int|resource|StreamInterface|string $contents - * @param array $headers + * @param array>|string $name + * @param null|array|bool|float|int|resource|StreamInterface|string $contents + * @param array|string> $headers * @return $this */ public function attach( - string $name, + array|string $name, mixed $contents = '', ?string $filename = null, array $headers = [], ): static { - $repository = $this->bodyRepository; + if (is_array($name)) { + foreach ($name as $file) { + $this->attach(...$file); + } - if (! $repository instanceof MultipartBodyRepository) { - $repository = $this->bodyRepository = new MultipartBodyRepository; + return $this; } + $this->asMultipart(); + + /** @var MultipartBodyRepository $repository */ + $repository = $this->bodyRepository; + $repository->attach(new MultipartValue($name, $contents, $filename, $headers)); return $this; @@ -106,6 +121,8 @@ public function attach( /** * Merge structured values into the request body. * + * A multipart body receives each value as an appended field. + * * @param array $data * @return $this */ @@ -113,6 +130,14 @@ public function withData(array $data): static { $repository = $this->bodyRepository(); + if ($repository instanceof MultipartBodyRepository) { + foreach (StructuredDataNormalizer::forUrlEncoding($data) as $name => $value) { + $repository->add((string) $name, $value); + } + + return $this; + } + if (! $repository instanceof ArrayBodyRepository) { $repository = $this->bodyRepository = new JsonBodyRepository; $this->contentType('application/json'); diff --git a/src/saloon/src/Traits/HasDebugging.php b/src/saloon/src/Traits/HasDebugging.php index 5bef60fd4c..adae18b63a 100644 --- a/src/saloon/src/Traits/HasDebugging.php +++ b/src/saloon/src/Traits/HasDebugging.php @@ -35,7 +35,7 @@ static function (RequestInterface $request, PendingRequest $pendingRequest) use }, ); }, - order: PipeOrder::Last, + order: PipeOrder::LAST, ); return $this; @@ -65,7 +65,7 @@ static function (Response $response) use ($onResponse, $die): Response { return $response; }, - order: PipeOrder::First, + order: PipeOrder::FIRST, ); return $this; diff --git a/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php b/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php index 2535c39f8d..b80239e87a 100644 --- a/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php +++ b/src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php @@ -4,10 +4,12 @@ namespace Hypervel\Saloon\Traits\OAuth2; +use DateTimeImmutable; use Hypervel\Saloon\Contracts\OAuthAuthenticator; use Hypervel\Saloon\Data\AuthorizationUrl; use Hypervel\Saloon\Data\OAuthConfig; use Hypervel\Saloon\Exceptions\InvalidStateException; +use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\OAuth2\GetAccessTokenRequest; use Hypervel\Saloon\Http\OAuth2\GetRefreshTokenRequest; @@ -18,6 +20,7 @@ use Hypervel\Support\Str; use InvalidArgumentException; use SensitiveParameter; +use UnexpectedValueException; /** * @phpstan-require-extends Connector @@ -25,13 +28,13 @@ */ trait AuthorizationCodeGrant { - use CreatesOAuthAuthenticator; use HasOAuthConfig; + use ParsesOAuthTokenResponses; /** * Create an authorization URL and its paired state. * - * @param list $scopes + * @param list $scopes * @param array $additionalQueryParameters */ public function authorizationUrl( @@ -55,24 +58,24 @@ public function authorizationUrl( throw new InvalidArgumentException('The PKCE challenge must be non-empty and use the [S256] or [plain] method.'); } - $resolvedScopes = [...$config->defaultScopes, ...$scopes]; - $queryParameters = [ - 'response_type' => 'code', - 'client_id' => $config->clientId, - 'redirect_uri' => $config->redirectUri, - 'state' => $state, - ]; + $resolvedScopes = $config->scopes($scopes); + $queryParameters = ['response_type' => 'code']; if ($resolvedScopes !== []) { $queryParameters['scope'] = implode($scopeSeparator, $resolvedScopes); } + $queryParameters['client_id'] = $config->clientId; + $queryParameters['redirect_uri'] = $config->redirectUri; + $queryParameters['state'] = $state; + if ($codeChallenge !== null) { $queryParameters['code_challenge'] = $codeChallenge; $queryParameters['code_challenge_method'] = $codeChallengeMethod; } - $queryParameters += $additionalQueryParameters; + // Additional parameters may replace the others, but not the state paired with the returned URL. + $queryParameters = [...$queryParameters, ...$additionalQueryParameters, 'state' => $state]; $uri = UrlResolver::resolve( $this->resolveBaseUrl(), $config->authorizeEndpoint, @@ -105,7 +108,14 @@ public function getAccessToken( $config = $this->oauthConfig(); $config->validate(); $this->validateState($state, $expectedState); - $request = $config->modify($this->resolveAccessTokenRequest($code, $config, $codeVerifier)); + $request = $this->resolveAccessTokenRequest($code, $config); + + // Adding the verifier here keeps PKCE working with a custom token request resolver. + if ($codeVerifier !== null) { + $request->withData(['code_verifier' => $codeVerifier]); + } + + $request = $config->modify($request); $requestModifier?->__invoke($request); $response = $this->send($request); @@ -135,8 +145,12 @@ public function refreshAccessToken( $config->validate(); if ($refreshToken instanceof OAuthAuthenticator) { - $refreshToken = $refreshToken->getRefreshToken() - ?? throw new InvalidArgumentException('The provided OAuth authenticator does not contain a refresh token.'); + if ($refreshToken->isNotRefreshable()) { + throw new InvalidArgumentException('The provided OAuthAuthenticator does not contain a refresh token.'); + } + + /** @var string $refreshToken */ + $refreshToken = $refreshToken->getRefreshToken(); } $request = $config->modify($this->resolveRefreshTokenRequest($config, $refreshToken)); @@ -152,32 +166,61 @@ public function refreshAccessToken( return $this->createOAuthAuthenticatorFromResponse($response, $refreshToken); } + /** + * Create an OAuth authenticator from a token response. + */ + protected function createOAuthAuthenticatorFromResponse( + #[SensitiveParameter] + Response $response, + #[SensitiveParameter] + ?string $fallbackRefreshToken = null, + ): OAuthAuthenticator { + [$accessToken, $expiresAt, $data] = $this->parseOAuthTokenResponse($response); + $refreshToken = $data['refresh_token'] ?? $fallbackRefreshToken; + + if ($refreshToken !== null && ! is_string($refreshToken)) { + throw new UnexpectedValueException('The OAuth token response contains an invalid refresh token.'); + } + + return $this->createOAuthAuthenticator($accessToken, $refreshToken, $expiresAt); + } + + /** + * Create an OAuth authenticator. + */ + protected function createOAuthAuthenticator( + #[SensitiveParameter] + string $accessToken, + #[SensitiveParameter] + ?string $refreshToken = null, + ?DateTimeImmutable $expiresAt = null, + ): OAuthAuthenticator { + return new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt); + } + /** * Retrieve the authenticated OAuth user. * * @template TRequest of Request * @param null|callable(TRequest): void $requestModifier */ - public function getUser(OAuthAuthenticator $authenticator, ?callable $requestModifier = null): Response + public function getUser(OAuthAuthenticator $oauthAuthenticator, ?callable $requestModifier = null): Response { $config = $this->oauthConfig(); - $request = $config->modify($this->resolveUserRequest($config))->authenticate($authenticator); + $request = $config->modify($this->resolveUserRequest($config))->authenticate($oauthAuthenticator); $requestModifier?->__invoke($request); return $this->send($request); } + // getState() is not included: authorizationUrl() returns the state with its URL, since the connector may be shared. + /** * Resolve the access-token request. */ - protected function resolveAccessTokenRequest( - #[SensitiveParameter] - string $code, - OAuthConfig $oauthConfig, - #[SensitiveParameter] - ?string $codeVerifier = null, - ): Request { - return new GetAccessTokenRequest($code, $oauthConfig, $codeVerifier); + protected function resolveAccessTokenRequest(#[SensitiveParameter] string $code, OAuthConfig $oauthConfig): Request + { + return new GetAccessTokenRequest($code, $oauthConfig); } /** diff --git a/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php b/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php index 88860d9c49..3c16bc86d6 100644 --- a/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php +++ b/src/saloon/src/Traits/OAuth2/ClientCredentialsBasicAuthGrant.php @@ -20,7 +20,7 @@ trait ClientCredentialsBasicAuthGrant /** * Resolve the client-credentials request. * - * @param list $scopes + * @param list $scopes */ protected function resolveAccessTokenRequest( OAuthConfig $oauthConfig, diff --git a/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php b/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php index c0eec1cbe7..d877a6d5a3 100644 --- a/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php +++ b/src/saloon/src/Traits/OAuth2/ClientCredentialsGrant.php @@ -4,12 +4,15 @@ namespace Hypervel\Saloon\Traits\OAuth2; +use DateTimeImmutable; use Hypervel\Saloon\Contracts\OAuthAuthenticator; use Hypervel\Saloon\Data\OAuthConfig; +use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenRequest; use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Http\Response; +use SensitiveParameter; /** * @phpstan-require-extends Connector @@ -17,14 +20,14 @@ */ trait ClientCredentialsGrant { - use CreatesOAuthAuthenticator; use HasOAuthConfig; + use ParsesOAuthTokenResponses; /** * Request a client-credentials access token. * * @template TRequest of Request - * @param list $scopes + * @param list $scopes * @param null|callable(TRequest): void $requestModifier * @return ($returnResponse is true ? Response : OAuthAuthenticator) */ @@ -35,7 +38,7 @@ public function getAccessToken( ?callable $requestModifier = null, ): OAuthAuthenticator|Response { $config = $this->oauthConfig(); - $config->validate(false); + $config->validate(withRedirectUrl: false); $request = $config->modify($this->resolveAccessTokenRequest($config, $scopes, $scopeSeparator)); $requestModifier?->__invoke($request); $response = $this->send($request); @@ -49,10 +52,31 @@ public function getAccessToken( return $this->createOAuthAuthenticatorFromResponse($response); } + /** + * Create an OAuth authenticator from a token response. + */ + protected function createOAuthAuthenticatorFromResponse(#[SensitiveParameter] Response $response): OAuthAuthenticator + { + [$accessToken, $expiresAt] = $this->parseOAuthTokenResponse($response); + + return $this->createOAuthAuthenticator($accessToken, $expiresAt); + } + + /** + * Create an OAuth authenticator. + */ + protected function createOAuthAuthenticator( + #[SensitiveParameter] + string $accessToken, + ?DateTimeImmutable $expiresAt = null, + ): OAuthAuthenticator { + return new AccessTokenAuthenticator($accessToken, null, $expiresAt); + } + /** * Resolve the client-credentials request. * - * @param list $scopes + * @param list $scopes */ protected function resolveAccessTokenRequest( OAuthConfig $oauthConfig, diff --git a/src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php b/src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php similarity index 54% rename from src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php rename to src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php index f633860ae8..2998182988 100644 --- a/src/saloon/src/Traits/OAuth2/CreatesOAuthAuthenticator.php +++ b/src/saloon/src/Traits/OAuth2/ParsesOAuthTokenResponses.php @@ -4,11 +4,9 @@ namespace Hypervel\Saloon\Traits\OAuth2; -use Carbon\CarbonInterface; -use Hypervel\Saloon\Contracts\OAuthAuthenticator; -use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\Response; +use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use SensitiveParameter; use UnexpectedValueException; @@ -17,54 +15,29 @@ * @phpstan-require-extends Connector * @phpstan-ignore trait.unused (user-facing OAuth 2 trait) */ -trait CreatesOAuthAuthenticator +trait ParsesOAuthTokenResponses { /** - * Create an OAuth authenticator from a token response. + * Parse the access token, expiry and data of a token response. + * + * @return array{string, ?CarbonImmutable, array} */ - protected function createOAuthAuthenticatorFromResponse( - #[SensitiveParameter] - Response $response, - #[SensitiveParameter] - ?string $fallbackRefreshToken = null, - ): OAuthAuthenticator { + protected function parseOAuthTokenResponse(#[SensitiveParameter] Response $response): array + { $data = $response->json(); $accessToken = is_array($data) ? ($data['access_token'] ?? null) : null; - if (! is_string($accessToken) || $accessToken === '') { + if (! is_array($data) || ! is_string($accessToken) || $accessToken === '') { throw new UnexpectedValueException('The OAuth token response does not contain a valid access token.'); } - $refreshToken = $data['refresh_token'] ?? $fallbackRefreshToken; - - if ($refreshToken !== null && ! is_string($refreshToken)) { - throw new UnexpectedValueException('The OAuth token response contains an invalid refresh token.'); - } - - return $this->createOAuthAuthenticator( - $accessToken, - $refreshToken, - $this->resolveOAuthExpiry($data['expires_in'] ?? null), - ); - } - - /** - * Create an OAuth authenticator. - */ - protected function createOAuthAuthenticator( - #[SensitiveParameter] - string $accessToken, - #[SensitiveParameter] - ?string $refreshToken = null, - ?CarbonInterface $expiresAt = null, - ): OAuthAuthenticator { - return new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt); + return [$accessToken, $this->resolveOAuthExpiry($data['expires_in'] ?? null), $data]; } /** * Resolve an OAuth token expiry. */ - protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonInterface + protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonImmutable { if ($expiresIn === null) { return null; @@ -85,7 +58,8 @@ protected function resolveOAuthExpiry(mixed $expiresIn): ?CarbonInterface throw new UnexpectedValueException('The OAuth token response contains an invalid expiry duration.'); } - $now = Date::now(); + // Date may be configured to create mutable dates, so copy the clock into an immutable expiry. + $now = CarbonImmutable::instance(Date::now()); if ($now->getTimestamp() > PHP_INT_MAX - $expiresIn) { throw new UnexpectedValueException('The OAuth token response contains an invalid expiry duration.'); diff --git a/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php b/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php index c44487e763..e4a43ebed8 100644 --- a/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php +++ b/src/saloon/src/Traits/Plugins/AlwaysThrowOnErrors.php @@ -17,7 +17,7 @@ public function bootAlwaysThrowOnErrors(PendingRequest $pendingRequest): void { $pendingRequest->middleware()->onResponse( static fn (Response $response): Response => $response->throw(), - order: PipeOrder::Last, + order: PipeOrder::LAST, ); } } diff --git a/src/saloon/src/Traits/Plugins/HasApiVersion.php b/src/saloon/src/Traits/Plugins/HasApiVersion.php new file mode 100644 index 0000000000..4b5d8f6dbb --- /dev/null +++ b/src/saloon/src/Traits/Plugins/HasApiVersion.php @@ -0,0 +1,71 @@ +apiVersion; + } + + /** + * Boot HasApiVersion plugin. + */ + public function bootHasApiVersion(PendingRequest $pendingRequest): void + { + $version = $this->getApiVersion(); + + if ($version === null || $version === '') { + return; + } + + // Replacing the header lets a request's version override its connector's, since added headers append. + match ($this->versionMode) { + VersionMode::Header => $pendingRequest->replaceHeaders([$this->versionKey => $version]), + VersionMode::QueryParam => $pendingRequest->withQueryParameters([$this->versionKey => $version]), + VersionMode::Url => $pendingRequest->withUrlParameters(['version' => $this->ensureVersionIsSafeForUrl($version)]), + }; + } + + /** + * Ensure the version can only ever form a single subdomain label or path segment. + * + * @throws InvalidArgumentException + */ + protected function ensureVersionIsSafeForUrl(string $version): string + { + if (preg_match('/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/', $version) !== 1) { + throw new InvalidArgumentException(sprintf('The API version "%s" is not safe to use in a URL. Versions may only contain letters, numbers, dashes, underscores and single dots.', $version)); + } + + return $version; + } +} diff --git a/src/saloon/src/Traits/Plugins/HasTimeout.php b/src/saloon/src/Traits/Plugins/HasTimeout.php new file mode 100644 index 0000000000..91cf987a13 --- /dev/null +++ b/src/saloon/src/Traits/Plugins/HasTimeout.php @@ -0,0 +1,47 @@ +options() + $pendingRequest->request()->options(); + $timeouts = [ + RequestOptions::CONNECT_TIMEOUT => $this->getConnectTimeout(), + RequestOptions::TIMEOUT => $this->getRequestTimeout(), + ]; + + foreach ($timeouts as $option => $timeout) { + if ($timeout !== null && ! array_key_exists($option, $options)) { + $pendingRequest->withOptions([$option => $timeout]); + } + } + } + + /** + * Get the request connection timeout. + */ + public function getConnectTimeout(): ?float + { + return property_exists($this, 'connectTimeout') ? $this->connectTimeout : null; + } + + /** + * Get the request timeout. + */ + public function getRequestTimeout(): ?float + { + return property_exists($this, 'requestTimeout') ? $this->requestTimeout : null; + } +} diff --git a/src/saloon/src/Traits/Request/HasConnector.php b/src/saloon/src/Traits/Request/HasConnector.php new file mode 100644 index 0000000000..bd795e0211 --- /dev/null +++ b/src/saloon/src/Traits/Request/HasConnector.php @@ -0,0 +1,67 @@ +loadedConnector ??= $this->resolveConnector(); + } + + /** + * Set the loaded connector at runtime. + * + * @return $this + */ + public function setConnector(Connector $connector): static + { + $this->loadedConnector = $connector; + + return $this; + } + + /** + * Create a new connector instance. + */ + protected function resolveConnector(): Connector + { + return new $this->connector; + } + + /** + * Prepare the request for sending through its connector. + * + * @return PendingRequest + */ + public function createPendingRequest(): PendingRequest + { + return $this->connector()->createPendingRequest($this); + } + + /** + * Send the request through its connector. + * + * @return Response + */ + public function send(?MockClient $mockClient = null): Response + { + return $this->connector()->send($this, $mockClient); + } +} diff --git a/src/saloon/src/Traits/RequestProperties/HasDelay.php b/src/saloon/src/Traits/RequestProperties/HasDelay.php index d847f99c29..0af51cd6a6 100644 --- a/src/saloon/src/Traits/RequestProperties/HasDelay.php +++ b/src/saloon/src/Traits/RequestProperties/HasDelay.php @@ -4,15 +4,14 @@ namespace Hypervel\Saloon\Traits\RequestProperties; -use Hypervel\Saloon\Repositories\IntegerRepository; use InvalidArgumentException; trait HasDelay { /** - * The request delay. + * The request delay in milliseconds. */ - protected ?IntegerRepository $delayRepository = null; + protected ?int $delay = null; /** * Delay the request by the given milliseconds. @@ -25,7 +24,7 @@ public function delay(int $milliseconds): static throw new InvalidArgumentException('The request delay must be a representable non-negative number of milliseconds.'); } - $this->delayRepository()->set($milliseconds); + $this->delay = $milliseconds; return $this; } @@ -35,7 +34,7 @@ public function delay(int $milliseconds): static */ public function delayMilliseconds(): ?int { - return $this->delayRepository()->get(); + return $this->delay ?? $this->defaultDelay(); } /** @@ -45,12 +44,4 @@ protected function defaultDelay(): ?int { return null; } - - /** - * Get the request delay repository. - */ - protected function delayRepository(): IntegerRepository - { - return $this->delayRepository ??= new IntegerRepository($this->defaultDelay()); - } } diff --git a/src/saloon/src/Traits/RequestProperties/HasHeaders.php b/src/saloon/src/Traits/RequestProperties/HasHeaders.php index 3ac4778276..7fc6cb9645 100644 --- a/src/saloon/src/Traits/RequestProperties/HasHeaders.php +++ b/src/saloon/src/Traits/RequestProperties/HasHeaders.php @@ -89,6 +89,35 @@ public function replaceHeaders(array $headers): static return $this; } + /** + * Remove a header from the request. + * + * @return $this + */ + public function withoutHeader(string $name): static + { + return $this->withoutHeaders([$name]); + } + + /** + * Remove the given headers from the request. + * + * @param array $names + * @return $this + */ + public function withoutHeaders(array $names): static + { + $names = array_map(strtolower(...), $names); + + $this->headerRepository()->set(array_filter( + $this->headers(), + static fn (string $header): bool => ! in_array(strtolower($header), $names, true), + ARRAY_FILTER_USE_KEY, + )); + + return $this; + } + /** * Specify the request content type. * diff --git a/src/saloon/src/Traits/RequestProperties/HasOptions.php b/src/saloon/src/Traits/RequestProperties/HasOptions.php index 5c14e1bbfe..3d1c816368 100644 --- a/src/saloon/src/Traits/RequestProperties/HasOptions.php +++ b/src/saloon/src/Traits/RequestProperties/HasOptions.php @@ -38,6 +38,19 @@ public function withOptions(array $options): static return $this; } + /** + * Remove the given options from the request. + * + * @param array|string $keys + * @return $this + */ + public function withoutOptions(array|string $keys): static + { + $this->optionRepository()->set(array_diff_key($this->options(), array_flip((array) $keys))); + + return $this; + } + /** * Specify the maximum number of redirects to allow. * diff --git a/src/saloon/src/Traits/RequestProperties/HasQuery.php b/src/saloon/src/Traits/RequestProperties/HasQuery.php index 09ede6c2b0..e50fcd6cdd 100644 --- a/src/saloon/src/Traits/RequestProperties/HasQuery.php +++ b/src/saloon/src/Traits/RequestProperties/HasQuery.php @@ -59,6 +59,22 @@ public function withQueryParameters(array $parameters): static return $this; } + /** + * Remove the given query parameters from the request. + * + * Only values added through the query parameter methods are removed. Values embedded in the base URL, endpoint + * or raw query string are unchanged. + * + * @param array|string $keys + * @return $this + */ + public function withoutQueryParameters(array|string $keys): static + { + $this->queryRepository()->set(array_diff_key($this->queryParameters(), array_flip((array) $keys))); + + return $this; + } + /** * Resolve the default request query parameters. * diff --git a/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php b/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php index 01bbbd019f..0dc35c155f 100644 --- a/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php +++ b/src/saloon/src/Traits/RequestProperties/HasRequestProperties.php @@ -13,4 +13,5 @@ trait HasRequestProperties use HasOptions; use HasQuery; use HasRetryPolicy; + use HasUrlParameters; } diff --git a/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php b/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php index d1d454526a..7a1d185b73 100644 --- a/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php +++ b/src/saloon/src/Traits/RequestProperties/HasRetryPolicy.php @@ -34,8 +34,16 @@ public function retry( /** * Get the request retry policy. */ - public function retryPolicy(): RetryPolicy + public function retryPolicy(): ?RetryPolicy { - return $this->retryPolicy ??= new RetryPolicy; + return $this->retryPolicy ?? $this->defaultRetryPolicy(); + } + + /** + * Resolve the default retry policy. + */ + protected function defaultRetryPolicy(): ?RetryPolicy + { + return null; } } diff --git a/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php b/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php new file mode 100644 index 0000000000..967f39606a --- /dev/null +++ b/src/saloon/src/Traits/RequestProperties/HasUrlParameters.php @@ -0,0 +1,39 @@ + + */ + protected array $urlParameters = []; + + /** + * Get the parameters substituted into URL templates. + * + * @return array + */ + public function urlParameters(): array + { + return $this->urlParameters; + } + + /** + * Specify the URL parameters that can be substituted into the request URL. + * + * @param array $parameters + * @return $this + */ + public function withUrlParameters(array $parameters = []): static + { + // Replace by key so numeric template names such as {1} are not renumbered. + $this->urlParameters = array_replace($this->urlParameters, $parameters); + + return $this; + } +} diff --git a/src/saloon/stubs/saloon.authenticator.stub b/src/saloon/stubs/saloon.authenticator.stub index cced117ecc..9cffc98729 100644 --- a/src/saloon/stubs/saloon.authenticator.stub +++ b/src/saloon/stubs/saloon.authenticator.stub @@ -9,10 +9,19 @@ use Hypervel\Saloon\Http\PendingRequest; class {{ class }} implements Authenticator { + /** + * Create a new authenticator instance. + */ + public function __construct() + { + // + } + /** * Apply the authentication to the request. */ public function set(PendingRequest $pendingRequest): void { + // } } diff --git a/src/saloon/stubs/saloon.plugin.stub b/src/saloon/stubs/saloon.plugin.stub index 9b00e4b54a..3451012c52 100644 --- a/src/saloon/stubs/saloon.plugin.stub +++ b/src/saloon/stubs/saloon.plugin.stub @@ -13,5 +13,6 @@ trait {{ class }} */ public function boot{{ class }}(PendingRequest $pendingRequest): void { + // } } diff --git a/src/saloon/stubs/saloon.response.stub b/src/saloon/stubs/saloon.response.stub index 1877ea0d01..dc2f30ff8b 100644 --- a/src/saloon/stubs/saloon.response.stub +++ b/src/saloon/stubs/saloon.response.stub @@ -8,4 +8,5 @@ use Hypervel\Saloon\Http\Response; class {{ class }} extends Response { + // } diff --git a/src/support/src/CarbonImmutable.php b/src/support/src/CarbonImmutable.php index 91ec5afae6..a8d87b8b14 100644 --- a/src/support/src/CarbonImmutable.php +++ b/src/support/src/CarbonImmutable.php @@ -12,10 +12,12 @@ * Carbon's immutable magic modifier metadata names its base class even though * these methods preserve subclasses at runtime. * + * @method static addDay() * @method static addMicrosecond() * @method static addMicroseconds(int|float $value = 1) * @method static addMinute() * @method static addMinutes(int|float $value = 1) + * @method static addMonth() * @method static addSecond() * @method static addSeconds(int|float $value = 1) * @method static ceilSecond(float $precision = 1) diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php index f1796263ef..3feb759bb3 100644 --- a/src/support/src/Facades/Http.php +++ b/src/support/src/Facades/Http.php @@ -48,6 +48,7 @@ * @method static \GuzzleHttp\Promise\PromiseInterface response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static \Hypervel\Http\Client\ResponseSequence sequence(array $responses = []) * @method static \Hypervel\Http\Client\Factory setConnectionConfig(string $name, array $config) + * @method static void setDispatcher(\Hypervel\Contracts\Events\Dispatcher|null $dispatcher) * @method static mixed withoutGlobalConfiguration(\Closure $callback) * @method static \Hypervel\Http\Client\PendingRequest accept(string $contentType) * @method static \Hypervel\Http\Client\PendingRequest acceptJson() diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index 0eb80c1f56..cbbd58497d 100644 --- a/src/telescope/src/Watchers/ClientRequestWatcher.php +++ b/src/telescope/src/Watchers/ClientRequestWatcher.php @@ -377,7 +377,12 @@ protected function getResponsePayload(ResponseInterface $response): array|string return Telescope::PURGED_VALUE; } - if (Str::startsWith(strtolower($response->getHeaderLine('content-type') ?: ''), 'text/plain')) { + $contentType = strtolower($response->getHeaderLine('content-type')); + + // Scalar JSON such as 0, false or null is shown as sent instead of being labeled empty or HTML. + if (Str::startsWith($contentType, 'text/plain') + || ($jsonError === JSON_ERROR_NONE && Str::contains($contentType, ['/json', '+json'])) + ) { if (strlen($content) >= $sizeLimit) { return $truncate ? substr($content, 0, $sizeLimit) . ' (truncated...)' @@ -387,7 +392,7 @@ protected function getResponsePayload(ResponseInterface $response): array|string return $content; } - if (empty($content)) { + if ($content === '') { return 'Empty Response'; } } catch (Throwable $e) { diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php index f846cd14e2..4941f29cec 100644 --- a/src/telescope/src/Watchers/RequestWatcher.php +++ b/src/telescope/src/Watchers/RequestWatcher.php @@ -189,7 +189,12 @@ protected function response(Response $response): array|string return Telescope::PURGED_VALUE; } - if (Str::startsWith(strtolower($response->headers->get('Content-Type') ?? ''), 'text/plain')) { + $contentType = strtolower($response->headers->get('Content-Type') ?? ''); + + // Scalar JSON such as 0, false or null is shown as sent instead of being labeled empty or HTML. + if (Str::startsWith($contentType, 'text/plain') + || ($jsonError === JSON_ERROR_NONE && Str::contains($contentType, ['/json', '+json'])) + ) { return $this->contentWithinLimits($content) ? $content : Telescope::PURGED_VALUE; } } @@ -205,7 +210,7 @@ protected function response(Response $response): array|string ]; } - if (is_string($content) && empty($content)) { + if ($content === '') { return 'Empty Response'; } diff --git a/tests/Contracts/Telescope/TelescopeTagTest.php b/tests/Contracts/Telescope/TelescopeTagTest.php index 4b791debe1..06808e559b 100644 --- a/tests/Contracts/Telescope/TelescopeTagTest.php +++ b/tests/Contracts/Telescope/TelescopeTagTest.php @@ -18,6 +18,7 @@ public function testCasesMapToExpectedWireValues(): void $this->assertSame('algolia', TelescopeTag::Algolia->value); $this->assertSame('meilisearch', TelescopeTag::Meilisearch->value); $this->assertSame('saloon', TelescopeTag::Saloon->value); + $this->assertSame('turbopuffer', TelescopeTag::Turbopuffer->value); $this->assertSame('typesense', TelescopeTag::Typesense->value); } diff --git a/tests/Data/Saloon/SaloonIntegrationTest.php b/tests/Data/Saloon/SaloonIntegrationTest.php index cabcd4c7eb..375f6e0f35 100644 --- a/tests/Data/Saloon/SaloonIntegrationTest.php +++ b/tests/Data/Saloon/SaloonIntegrationTest.php @@ -6,12 +6,10 @@ use GuzzleHttp\Psr7\Request as PsrRequest; use GuzzleHttp\Psr7\Response as PsrResponse; -use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Http\Client\Response as HttpResponse; -use Hypervel\RateLimiter\RateLimiter; use Hypervel\Saloon\Contracts\DataObjects\WithResponse; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Connector; @@ -20,7 +18,6 @@ use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Traits\Responses\HasResponse; use Hypervel\Testbench\TestCase; -use Mockery as m; class SaloonIntegrationTest extends TestCase { @@ -65,12 +62,7 @@ public function testConnectorDataIsUsedWhenTheRequestReturnsNothing(): void */ protected function response(Connector $connector, Request $request): Response { - $pendingRequest = new PendingRequest( - $connector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); + $pendingRequest = new PendingRequest($connector, $request); $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users/7'); return Response::fromResponse( @@ -88,11 +80,17 @@ protected function response(Connector $connector, Request $request): Response /** @extends Connector */ class DataConnector extends Connector { + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Create a data object from the response. + */ public function createDtoFromResponse(Response $response): SaloonUserData { return SaloonUserData::from([ @@ -107,11 +105,17 @@ class DataRequest extends Request { protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users/7'; } + /** + * Create a data object from the response. + */ public function createDtoFromResponse(Response $response): SaloonUserData { return SaloonUserData::from([ @@ -126,6 +130,9 @@ class PlainDataRequest extends Request { protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users/7'; @@ -136,6 +143,9 @@ class SaloonUserData extends Data implements WithResponse { use HasResponse; + /** + * Create a new data object instance. + */ public function __construct( public int $id, public string $name, diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index d5ec1be7a3..2eb7300b5b 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -1840,6 +1840,74 @@ public function testFilesCanBeAttached(): void }); } + /** + * @param Closure(Factory): PendingRequest $pendingRequest + */ + #[DataProvider('contentTypesWithoutABoundary')] + public function testAttachedFilesReplaceAContentTypeWithoutABoundary(Closure $pendingRequest): void + { + $sent = null; + + $this->factory->fake(function (Request $request) use (&$sent): PromiseInterface { + $sent = $request; + + return Factory::response(); + }); + + $pendingRequest($this->factory)->attach('file', 'data', 'file.txt')->post('http://foo.com/file'); + + $contentType = $sent->header('Content-Type'); + + $this->assertCount(1, $contentType); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType[0]); + $this->assertStringStartsWith( + '--' . substr($contentType[0], strlen('multipart/form-data; boundary=')) . "\r\n", + $sent->body(), + ); + } + + /** + * Get the ways a request can carry a content type without a boundary. + * + * @return iterable + */ + public static function contentTypesWithoutABoundary(): iterable + { + yield 'asJson' => [fn (Factory $factory): PendingRequest => $factory->asJson()]; + yield 'string header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['Content-Type' => 'application/json'])]; + yield 'lowercase list header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['content-type' => ['application/json']])]; + yield 'stringable header' => [fn (Factory $factory): PendingRequest => $factory->withHeaders(['Content-Type' => new Stringable('application/json')])]; + yield 'connection default' => [fn (Factory $factory): PendingRequest => $factory + ->registerConnection('api', ['headers' => ['Content-Type' => 'application/json']]) + ->connection('api')]; + } + + #[DataProvider('contentTypesWithABoundary')] + public function testAttachedFilesKeepAContentTypeWithABoundary(mixed $contentType): void + { + $this->factory->fake(); + + $this->factory->withHeaders(['Content-Type' => $contentType]) + ->attach('file', 'data', 'file.txt') + ->post('http://foo.com/file'); + + $this->factory->assertSent( + fn (Request $request): bool => $request->header('Content-Type') === ['multipart/related; boundary=custom'], + ); + } + + /** + * Get the supported representations of a content type that declares a boundary. + * + * @return iterable + */ + public static function contentTypesWithABoundary(): iterable + { + yield 'string' => ['multipart/related; boundary=custom']; + yield 'list' => [['multipart/related; boundary=custom']]; + yield 'stringable' => [new Stringable('multipart/related; boundary=custom')]; + } + public function testAttachPreservesEmptyContents(): void { $this->factory->fake(); diff --git a/tests/Http/HttpServiceProviderTest.php b/tests/Http/HttpServiceProviderTest.php index d5e3a9c106..2915665243 100644 --- a/tests/Http/HttpServiceProviderTest.php +++ b/tests/Http/HttpServiceProviderTest.php @@ -7,9 +7,12 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Core\Events\BeforeServerFork; +use Hypervel\Http\Client\Events\ResponseReceived; use Hypervel\Http\Client\Factory; use Hypervel\Http\HttpServiceProvider; -use Hypervel\Tests\TestCase; +use Hypervel\Support\Facades\Event; +use Hypervel\Support\Facades\Http; +use Hypervel\Testbench\TestCase; use Mockery as m; use Swoole\Server; @@ -53,4 +56,28 @@ public function testBeforeForkDoesNotResolveAnUnusedFactory(): void $listeners[BeforeServerFork::class](new BeforeServerFork(m::mock(Server::class))); } + + public function testAScopedEventFakeReachesTheResolvedFactoryAndIsRemovedAfterwards(): void + { + Http::fake(['*' => Http::response()]); + $received = []; + Event::listen(ResponseReceived::class, function (ResponseReceived $event) use (&$received): void { + $received[] = $event->request->url(); + }); + + Http::get('https://example.com/before'); + + Event::fakeFor(function (): void { + Http::get('https://example.com/inside'); + + Event::assertDispatched( + ResponseReceived::class, + fn (ResponseReceived $event): bool => $event->request->url() === 'https://example.com/inside', + ); + }); + + Http::get('https://example.com/after'); + + $this->assertSame(['https://example.com/before', 'https://example.com/after'], $received); + } } diff --git a/tests/Integration/Engine/Fixtures/http_server_v2.php b/tests/Integration/Engine/Fixtures/http_server_v2.php index 8124e30ffb..d1be3760b5 100644 --- a/tests/Integration/Engine/Fixtures/http_server_v2.php +++ b/tests/Integration/Engine/Fixtures/http_server_v2.php @@ -5,7 +5,7 @@ /** * HTTP/2 test server for engine integration tests. * - * Listens on port 19505 and handles cookie-based test endpoints. + * Listens on port 19505 and handles cookie and multipart test endpoints. * This is a simplified version that doesn't require HttpMessage classes. */ @@ -48,6 +48,40 @@ $response->setHeader('Content-Type', 'application/json'); $response->end(json_encode($cookies)); })(), + '/mixed-multipart' => (function () use ($request, $response): void { + // Return the parsed multipart fields and file for Saloon's multipart tests + $file = $request->files['file'] ?? null; + + $response->setHeader('Content-Type', 'application/json'); + $response->end(json_encode([ + 'name' => $request->post['name'] ?? null, + 'fields' => $request->post ?? [], + 'file_contents' => $file === null ? null : file_get_contents($file['tmp_name']), + ])); + })(), + '/user' => (function () use ($response): void { + // Return the user from Saloon's test API + $response->setHeader('Content-Type', 'application/json'); + $response->end(json_encode(['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam'])); + })(), + '/error' => (function () use ($response): void { + // Fail like the error endpoint of Saloon's test API + $response->status(500); + $response->setHeader('Content-Type', 'application/json'); + $response->end(json_encode(['message' => 'Server Error'])); + })(), + '/header-error' => (function () use ($request, $response): void { + // Fail until Saloon's retry tests send the X-Yee-Haw header with the value 3 + if (($request->header['x-yee-haw'] ?? null) === '3') { + $response->end('Success!'); + + return; + } + + $response->status(500); + $response->setHeader('Content-Type', 'application/json'); + $response->end(json_encode(['message' => 'Server Error'])); + })(), default => (function () use ($request, $response) { $body = $request->rawContent(); $ret = 'Hello World.'; diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php index 5d2f7edf97..32e8e29691 100644 --- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php +++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php @@ -8,6 +8,7 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Backoff; +use Hypervel\RateLimiter\CalendarWindow; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\KeyResolver; use Hypervel\RateLimiter\LeakyBucket; @@ -167,6 +168,55 @@ public function testLeakyBucketUsesRedisTimeAndRecoversCapacity(): void $this->assertGreaterThan(1_700_000_000_000_000, (int) $storedTat); } + #[DataProvider('calendarClockOffsetProvider')] + public function testCalendarWindowsFollowRedisTimeWhenThePhpClockIsInAnotherPeriod(int $offsetSeconds): void + { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds($offsetSeconds)); + $policy = CalendarWindow::perHour(5)->timezone('UTC')->by('calendar-clock'); + $limiter = $this->limiter(); + + $first = $limiter->consume($policy); + $second = $limiter->consume($policy); + $redis = $this->redisClient(); + + // RedisCluster::time() needs a node, so read the clock of the node that holds the window's key. + $redisTime = $this->usingRedisCluster() ? $redis->time($this->physicalKey($policy)) : $redis->time(); + $redisSeconds = (int) $redisTime[0]; + + $this->assertTrue($first->allowed()); + $this->assertSame(4, $first->remaining()); + $this->assertSame(3, $second->remaining()); + $this->assertEqualsWithDelta(3_600 - $redisSeconds % 3_600, $second->resetAfter(), 1); + $this->assertLessThanOrEqual(3_600_000, $redis->pttl($this->physicalKey($policy))); + } + + /** + * Get PHP clock offsets that place the PHP calendar window outside Redis's. + */ + public static function calendarClockOffsetProvider(): array + { + return [ + 'PHP clock behind Redis' => [-7_200], + 'PHP clock ahead of Redis' => [7_200], + ]; + } + + public function testAStaleCalendarWindowDiscardsTheGroupsTentativeChargesBeforeRetrying(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::now()->subHours(2)); + $fixed = Limit::perMinute(10)->by('calendar-group'); + $calendar = CalendarWindow::perHour(10)->timezone('UTC')->by('calendar-group'); + $limiter = $this->limiter(); + + $results = $limiter->consumeMany([$fixed, $calendar]); + + $this->assertCount(2, $results); + $this->assertSame(9, $results[0]->remaining()); + $this->assertSame(9, $results[1]->remaining()); + $this->assertLessThanOrEqual(3_600, $results[1]->resetAfter()); + $this->assertSame(9, $limiter->inspect($fixed)->remaining()); + } + public function testExponentialBackoffUsesOneRedisStateEntry(): void { $backoff = Backoff::exponential( diff --git a/tests/Integration/Saloon/Cache/Feature/CacheTest.php b/tests/Integration/Saloon/Cache/Feature/CacheTest.php new file mode 100644 index 0000000000..f4b456a3f2 --- /dev/null +++ b/tests/Integration/Saloon/Cache/Feature/CacheTest.php @@ -0,0 +1,69 @@ +setUpInteractsWithServer(); + } + + public function testARequestWithTheHasCachingTraitWillCacheTheResponseWithARealRequest(): void + { + $responseA = TestConnector::make($this->serverUrl())->send(new CachedUserRequest); + + $responseBody = [ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ]; + + $this->assertFalse($responseA->isFaked()); + $this->assertFalse($responseA->isCached()); + $this->assertSame($responseBody, $responseA->json()); + + // Now send a response without the mock middleware, and it should be cached! + + $responseB = TestConnector::make($this->serverUrl())->send(new CachedUserRequest); + + $this->assertTrue($responseB->isFaked()); + $this->assertTrue($responseB->isCached()); + $this->assertSame($responseBody, $responseB->json()); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php b/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php new file mode 100644 index 0000000000..4690b5234d --- /dev/null +++ b/tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php @@ -0,0 +1,101 @@ +setUpInteractsWithServer(); + } + + public function testCanSendARealMultipartRequestAndFilesAreSent(): void + { + $connector = new TestConnector($this->serverUrl()); + $request = new MixedMultipartRequest; + + $request->attach('name', 'Howdy'); + $request->attach('file', file_get_contents(__DIR__ . '/../../Fixtures/Howdy.txt'), 'hi.txt'); + + $data = $connector->send($request)->json(); + + $this->assertSame('Howdy', $data['name']); + $this->assertSame('Hello World!' . PHP_EOL, $data['file_contents']); + } + + public function testCanSendAnEmptyStringAsTheContents(): void + { + $connector = new TestConnector($this->serverUrl()); + $request = new MixedMultipartRequest; + + $request->attach('name', 'Howdy'); + $request->attach('file', '', 'hi.txt'); + + $data = $connector->send($request)->json(); + + $this->assertSame('Howdy', $data['name']); + $this->assertSame('', $data['file_contents']); + } + + public function testWithDataSendsFieldsAlongsideAnAttachedFile(): void + { + $connector = new TestConnector($this->serverUrl()); + $request = (new MixedMultipartRequest) + ->attach('file', file_get_contents(__DIR__ . '/../../Fixtures/Howdy.txt'), 'hi.txt') + ->withData([ + 'name' => 'Howdy', + 'active' => true, + 'inactive' => false, + 'nothing' => null, + 'roles' => ['admin', 'editor'], + 'profile' => ['city' => 'London'], + ]); + + $data = $connector->send($request)->json(); + + $this->assertSame([ + 'name' => 'Howdy', + 'active' => '1', + 'inactive' => '', + 'nothing' => '', + 'roles' => ['admin', 'editor'], + 'profile' => ['city' => 'London'], + ], $data['fields']); + $this->assertSame('Hello World!' . PHP_EOL, $data['file_contents']); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/PoolTest.php b/tests/Integration/Saloon/Feature/PoolTest.php new file mode 100644 index 0000000000..cd1eb34404 --- /dev/null +++ b/tests/Integration/Saloon/Feature/PoolTest.php @@ -0,0 +1,98 @@ +setUpInteractsWithServer(); + } + + public function testYouCanCreateAPoolOnAConnector(): void + { + $connector = new TestConnector($this->serverUrl()); + $handled = []; + $exceptions = []; + + $pool = $connector->pool([ + new UserRequest, + new UserRequest, + new UserRequest, + new UserRequest, + new UserRequest, + new ErrorRequest, + ]); + + $pool->setConcurrency(6); + + $pool->withResponseHandler(function (Response $response, int $key) use (&$handled): void { + $handled[$key] = $response; + }); + + $pool->withExceptionHandler(function (RequestException $exception, int $key) use (&$exceptions): void { + $exceptions[$key] = $exception; + }); + + $responses = $pool->send(); + + // Responses follow input order whatever order the requests complete in. + $this->assertSame([0, 1, 2, 3, 4], array_keys($responses)); + + ksort($handled); + + $this->assertSame($responses, $handled); + + foreach ($responses as $response) { + $this->assertFalse($response->isMocked()); + $this->assertSame(200, $response->status()); + $this->assertSame([ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ], $response->json()); + } + + $this->assertSame([5], array_keys($exceptions)); + $this->assertSame(500, $exceptions[5]->response()->status()); + $this->assertFalse($exceptions[5]->response()->isMocked()); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/RequestExceptionTest.php b/tests/Integration/Saloon/Feature/RequestExceptionTest.php new file mode 100644 index 0000000000..20701c6fa9 --- /dev/null +++ b/tests/Integration/Saloon/Feature/RequestExceptionTest.php @@ -0,0 +1,72 @@ +setUpInteractsWithServer(); + } + + // Upstream's GuzzleSender case also expects Guzzle's ServerException as the previous exception. The HTTP client + // does not throw for an error status, so there is none. + public function testYouCanUseTheToExceptionMethodToGetTheDefaultRequestExceptionExceptionWithARealResponse(): void + { + $response = (new TestConnector($this->serverUrl()))->send(new ErrorRequest); + + $this->assertInstanceOf(Response::class, $response); + + $exception = $response->toException(); + + $this->assertInstanceOf(InternalServerErrorException::class, $exception); + $this->assertInstanceOf(ServerException::class, $exception); + $this->assertSame( + sprintf("HTTP request returned status code 500:\n%s\n", $response->body()), + $exception->getMessage(), + ); + $this->assertNull($exception->getPrevious()); + + $this->expectExceptionObject($exception); + + $response->throw(); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/RequestTest.php b/tests/Integration/Saloon/Feature/RequestTest.php new file mode 100644 index 0000000000..412e178d26 --- /dev/null +++ b/tests/Integration/Saloon/Feature/RequestTest.php @@ -0,0 +1,106 @@ +setUpInteractsWithServer(); + } + + public function testARequestCanBeMadeSuccessfully(): void + { + $connector = new TestConnector($this->serverUrl()); + $response = $connector->send(new UserRequest); + + $data = $response->json(); + + // Upstream also asserts that the pending request is not asynchronous; coroutine pools replace sendAsync(). + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->isMocked()); + $this->assertSame(200, $response->status()); + + $this->assertSame([ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ], $data); + } + + public function testARequestCanHandleAnExceptionProperly(): void + { + $connector = new TestConnector($this->serverUrl()); + $response = $connector->send(new ErrorRequest); + + $this->assertFalse($response->isMocked()); + $this->assertSame(500, $response->status()); + } + + public function testARequestWithHasConnectorCanBeSentIndividually(): void + { + $request = new HasConnectorUserRequest; + + $this->assertInstanceOf(TestConnector::class, $request->connector()); + + // The engine test server replaces the connector's default URL. Upstream also asserts the connector's + // sender; Hypervel connectors send through the framework HTTP client instead. + $request->setConnector(new TestConnector($this->serverUrl())); + + $this->assertInstanceOf(PendingRequest::class, $request->createPendingRequest()); + + $response = $request->send(); + + $data = $response->json(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->isMocked()); + $this->assertSame(200, $response->status()); + + $this->assertSame([ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ], $data); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/RetryConnectorTest.php b/tests/Integration/Saloon/Feature/RetryConnectorTest.php new file mode 100644 index 0000000000..caa7b2b4de --- /dev/null +++ b/tests/Integration/Saloon/Feature/RetryConnectorTest.php @@ -0,0 +1,77 @@ +setUpInteractsWithServer(); + } + + public function testRetryAgainstALiveEndpointToTestTheTransport(): void + { + $requestCount = 0; + $index = 0; + $connector = new RetryConnector(6, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ) use (&$index): bool { + $pendingRequest->request()->replaceHeaders(['X-Yee-Haw' => (string) $index++]); + + return true; + }, url: $this->serverUrl()); + $request = new HeaderErrorRequest; + $request->middleware()->onRequest(function () use (&$requestCount): void { + ++$requestCount; + }); + + $response = $connector->send($request); + + // Request count is five because: + // Request 1 - no header + // Request 2 - header but 0 + // Request 3 - header but 1 + // Request 4 - header but 2 + // Request 5 - header but 3 + $this->assertSame(5, $requestCount); + $this->assertSame('Success!', $response->body()); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/RetryRequestTest.php b/tests/Integration/Saloon/Feature/RetryRequestTest.php new file mode 100644 index 0000000000..0178e5fcb7 --- /dev/null +++ b/tests/Integration/Saloon/Feature/RetryRequestTest.php @@ -0,0 +1,76 @@ +setUpInteractsWithServer(); + } + + public function testRetryAgainstALiveEndpointToTestTheTransport(): void + { + $requestCount = 0; + $index = 0; + $request = new HeaderErrorRetryRequest(6, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ) use (&$index): bool { + $pendingRequest->request()->replaceHeaders(['X-Yee-Haw' => (string) $index++]); + + return true; + }); + $request->middleware()->onRequest(function () use (&$requestCount): void { + ++$requestCount; + }); + + $response = (new TestConnector($this->serverUrl()))->send($request); + + // Request count is five because: + // Request 1 - no header + // Request 2 - header but 0 + // Request 3 - header but 1 + // Request 4 - header but 2 + // Request 5 - header but 3 + $this->assertSame(5, $requestCount); + $this->assertSame('Success!', $response->body()); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Feature/SoloRequestTest.php b/tests/Integration/Saloon/Feature/SoloRequestTest.php new file mode 100644 index 0000000000..7e982fa636 --- /dev/null +++ b/tests/Integration/Saloon/Feature/SoloRequestTest.php @@ -0,0 +1,77 @@ +setUpInteractsWithServer(); + } + + public function testASoloRequestCanBeSentSynchronously(): void + { + $request = new SoloUserRequest($this->serverUrl()); + $response = $request->send(); + + $data = $response->json(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertFalse($response->isMocked()); + $this->assertSame(200, $response->status()); + + $this->assertSame([ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ], $data); + } + + public function testASynchronousSoloRequestCanHandleAnExceptionProperly(): void + { + $request = new SoloErrorRequest($this->serverUrl()); + $response = $request->send(); + + $this->assertFalse($response->isMocked()); + $this->assertSame(500, $response->status()); + } + + // REMOVED: "a solo request can be sent asynchronously" and "a asynchronous solo request can handle an exception + // property". Coroutine pools replace promises and sendAsync(). + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Fixtures/Howdy.txt b/tests/Integration/Saloon/Fixtures/Howdy.txt new file mode 100644 index 0000000000..980a0d5f19 --- /dev/null +++ b/tests/Integration/Saloon/Fixtures/Howdy.txt @@ -0,0 +1 @@ +Hello World! diff --git a/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php b/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php new file mode 100644 index 0000000000..f7fb65d56c --- /dev/null +++ b/tests/Integration/Saloon/Fixtures/Requests/MixedMultipartRequest.php @@ -0,0 +1,27 @@ +serverUrl . '/error'; + } +} diff --git a/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php b/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php new file mode 100644 index 0000000000..984ca1fc86 --- /dev/null +++ b/tests/Integration/Saloon/Fixtures/Requests/SoloUserRequest.php @@ -0,0 +1,33 @@ +serverUrl . '/user'; + } +} diff --git a/tests/Integration/Saloon/SaloonClientTest.php b/tests/Integration/Saloon/SaloonClientTest.php index 043385a741..edcd3da494 100644 --- a/tests/Integration/Saloon/SaloonClientTest.php +++ b/tests/Integration/Saloon/SaloonClientTest.php @@ -9,7 +9,6 @@ use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\Request; -use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\SaloonServiceProvider; use Hypervel\Testbench\TestCase; @@ -19,11 +18,17 @@ class SaloonClientTest extends TestCase protected int $serverPort = 19505; + /** + * Get the package providers. + */ protected function getPackageProviders(ApplicationContract $app): array { return [SaloonServiceProvider::class]; } + /** + * Set up the test server connection. + */ protected function setUp(): void { parent::setUp(); @@ -56,20 +61,6 @@ public function testSequentialRequestsReuseTheNamedConnection(): void $this->assertSame([0, 0, 0], array_slice($connectionTimes, 1)); } - public function testPoolSendsConcurrentRequestsThroughTheSameConnector(): void - { - $connector = new SaloonEngineConnector($this->serverUrl()); - $responses = $connector->pool( - array_map(fn (): SaloonEngineRequest => new SaloonEngineRequest, range(1, 6)), - concurrency: 3, - )->send(); - - $this->assertSame( - array_fill(0, 6, 'Hello World.'), - array_map(fn (Response $response): string => $response->body(), $responses), - ); - } - /** * Get the test server URL. */ @@ -88,6 +79,9 @@ public function __construct(protected readonly string $baseUrl) { } + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return $this->baseUrl; @@ -98,6 +92,9 @@ class SaloonEngineRequest extends Request { protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return ''; diff --git a/tests/Integration/Saloon/Unit/ConfigTest.php b/tests/Integration/Saloon/Unit/ConfigTest.php new file mode 100644 index 0000000000..0b4878586f --- /dev/null +++ b/tests/Integration/Saloon/Unit/ConfigTest.php @@ -0,0 +1,66 @@ +setUpInteractsWithServer(); + } + + public function testYouCanPreventAndThenAllowStrayApiRequests(): void + { + $connector = new TestConnector($this->serverUrl()); + + Http::preventStrayRequests(); + + try { + $connector->send(new UserRequest); + + $this->fail('A stray request was sent.'); + } catch (StrayRequestException) { + } + + Http::allowStrayRequests(); + + $this->assertSame(200, $connector->send(new UserRequest)->status()); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/Integration/Saloon/Unit/ResponseTest.php b/tests/Integration/Saloon/Unit/ResponseTest.php new file mode 100644 index 0000000000..ad48af4079 --- /dev/null +++ b/tests/Integration/Saloon/Unit/ResponseTest.php @@ -0,0 +1,57 @@ +setUpInteractsWithServer(); + } + + public function testYouCanGetTheResponseStreamAsARawResource(): void + { + $response = (new TestConnector($this->serverUrl()))->send(new UserRequest); + + $resource = $response->getRawStream(); + + $this->assertIsResource($resource); + + $this->assertSame('{"name":"Sammyjo20","actual_name":"Sam","twitter":"@carre_sam"}', stream_get_contents($resource)); + } + + /** + * Get the test server URL. + */ + protected function serverUrl(): string + { + return sprintf('http://%s:%d', $this->getServerHost(), $this->getServerPort()); + } +} diff --git a/tests/RateLimiter/CalendarWindowTest.php b/tests/RateLimiter/CalendarWindowTest.php new file mode 100644 index 0000000000..60fee66d31 --- /dev/null +++ b/tests/RateLimiter/CalendarWindowTest.php @@ -0,0 +1,214 @@ +assertPolicy(CalendarWindow::perMinute(60), 60, 'minute', '00:00:00'); + $this->assertPolicy(CalendarWindow::perHour(1_000), 1_000, 'hour', '00:00:00'); + $this->assertPolicy(CalendarWindow::perDay(10_000), 10_000, 'day', '00:00:00'); + $this->assertPolicy(CalendarWindow::perDay(10_000, at: '01:30'), 10_000, 'day', '01:30:00'); + $this->assertPolicy(CalendarWindow::perDay(10_000, at: '01:30:15'), 10_000, 'day', '01:30:15'); + $this->assertPolicy(CalendarWindow::perMonth(100_000), 100_000, 'month', '00:00:00'); + } + + public function testTheTimezoneDefaultsToTheApplicationTimezoneAndMayBeChanged(): void + { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('Asia/Kolkata'); + + try { + $original = CalendarWindow::perDay(10); + } finally { + date_default_timezone_set($previousTimezone); + } + + $changed = $original->timezone('America/New_York'); + + $this->assertNotSame($original, $changed); + $this->assertSame('Asia/Kolkata', $original->timezone); + $this->assertSame('America/New_York', $changed->timezone); + } + + public function testFluentModifiersReturnImmutableCopiesThatKeepTheCalendarOptions(): void + { + $after = static fn (): bool => true; + $response = static fn (): string => 'limited'; + $original = CalendarWindow::perDay(10, at: '01:00')->timezone('Europe/Berlin'); + $modified = $original + ->by('api') + ->cost(3) + ->globally() + ->after($after) + ->response($response) + ->timezone('Australia/Sydney'); + + $this->assertNotSame($original, $modified); + $this->assertSame('', $original->key); + $this->assertSame('Europe/Berlin', $original->timezone); + + $this->assertSame(10, $modified->maxAttempts); + $this->assertSame('day', $modified->period); + $this->assertSame('01:00:00', $modified->at); + $this->assertSame('Australia/Sydney', $modified->timezone); + $this->assertSame('api', $modified->key); + $this->assertSame(3, $modified->cost); + $this->assertTrue($modified->global); + $this->assertSame($after, $modified->afterCallback); + $this->assertSame($response, $modified->responseCallback); + } + + public function testInvalidValuesAreRejected(): void + { + foreach ([ + static fn (): CalendarWindow => CalendarWindow::perMinute(0), + static fn (): CalendarWindow => CalendarWindow::perMinute(1)->cost(0), + static fn (): CalendarWindow => new CalendarWindow(1, 'week'), + static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '24:00'), + static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '1:00'), + static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:60'), + static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:00:60'), + static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '1am'), + static fn (): CalendarWindow => new CalendarWindow(1, 'month', '01:00'), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + } + + public function testInvalidTimezonesAreRejected(): void + { + $this->expectException(DateInvalidTimeZoneException::class); + + CalendarWindow::perDay(1)->timezone('Not/A_Zone'); + } + + /** + * @param callable(): CalendarWindow $policy + */ + #[DataProvider('windowProvider')] + public function testWindowsFollowTheLocalClockAndCalendar( + callable $policy, + string $now, + string $start, + string $end, + ): void { + $window = $policy()->window((int) CarbonImmutable::parse($now . ' UTC')->getPreciseTimestamp(6)); + + $this->assertSame( + [ + CarbonImmutable::parse($start . ' UTC')->getTimestamp() * 1_000_000, + CarbonImmutable::parse($end . ' UTC')->getTimestamp() * 1_000_000, + ], + $window, + ); + } + + /** + * Get UTC times and the UTC start and end of the window containing them. + */ + public static function windowProvider(): array + { + $minute = static fn (): CalendarWindow => CalendarWindow::perMinute(1)->timezone('UTC'); + $kolkataHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Asia/Kolkata'); + $newYorkHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('America/New_York'); + $berlinHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Europe/Berlin'); + $lordHoweHour = static fn (): CalendarWindow => CalendarWindow::perHour(1)->timezone('Australia/Lord_Howe'); + $utcDay = static fn (): CalendarWindow => CalendarWindow::perDay(1)->timezone('UTC'); + $utcDayAtOne = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:00')->timezone('UTC'); + $newYorkDay = static fn (): CalendarWindow => CalendarWindow::perDay(1)->timezone('America/New_York'); + $newYorkDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '01:30') + ->timezone('America/New_York'); + $newYorkDayAtSkipped = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30') + ->timezone('America/New_York'); + $berlinDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30') + ->timezone('Europe/Berlin'); + $sydneyDayAtRepeated = static fn (): CalendarWindow => CalendarWindow::perDay(1, at: '02:30') + ->timezone('Australia/Sydney'); + $utcMonth = static fn (): CalendarWindow => CalendarWindow::perMonth(1)->timezone('UTC'); + $newYorkMonth = static fn (): CalendarWindow => CalendarWindow::perMonth(1)->timezone('America/New_York'); + + return [ + 'minute' => [$minute, '2026-10-07 06:45:30', '2026-10-07 06:45:00', '2026-10-07 06:46:00'], + 'minute just before its end' => [$minute, '2026-10-07 06:45:59.999999', '2026-10-07 06:45:00', '2026-10-07 06:46:00'], + 'minute at its start' => [$minute, '2026-10-07 06:46:00', '2026-10-07 06:46:00', '2026-10-07 06:47:00'], + 'half-hour offset' => [$kolkataHour, '2026-10-07 06:45:00', '2026-10-07 06:30:00', '2026-10-07 07:30:00'], + + // New York's clocks go back from 02:00 EDT to 01:00 EST at 06:00 UTC, so 01:00 local starts twice. + 'first 01:00 hour starts' => [$newYorkHour, '2026-11-01 05:00:00', '2026-11-01 05:00:00', '2026-11-01 06:00:00'], + 'first 01:00 hour ends' => [$newYorkHour, '2026-11-01 05:59:59', '2026-11-01 05:00:00', '2026-11-01 06:00:00'], + 'repeated 01:00 hour starts' => [$newYorkHour, '2026-11-01 06:00:00', '2026-11-01 06:00:00', '2026-11-01 07:00:00'], + 'repeated 01:00 hour' => [$newYorkHour, '2026-11-01 06:30:00', '2026-11-01 06:00:00', '2026-11-01 07:00:00'], + + // New York's clocks go forward from 02:00 EST to 03:00 EDT at 07:00 UTC. + 'hour before skipped hour' => [$newYorkHour, '2026-03-08 06:59:59', '2026-03-08 06:00:00', '2026-03-08 07:00:00'], + 'hour after skipped hour' => [$newYorkHour, '2026-03-08 07:00:00', '2026-03-08 07:00:00', '2026-03-08 08:00:00'], + 'Berlin first 02:00 hour' => [$berlinHour, '2026-10-25 00:59:59', '2026-10-25 00:00:00', '2026-10-25 01:00:00'], + 'Berlin repeated 02:00 hour' => [$berlinHour, '2026-10-25 01:00:00', '2026-10-25 01:00:00', '2026-10-25 02:00:00'], + + // Lord Howe Island's clocks go back half an hour at 15:00 UTC and forward half an hour at 15:30 UTC. + 'Lord Howe hour before going back' => [$lordHoweHour, '2026-04-04 14:59:59', '2026-04-04 14:00:00', '2026-04-04 15:00:00'], + 'Lord Howe half hour after going back' => [$lordHoweHour, '2026-04-04 15:00:00', '2026-04-04 15:00:00', '2026-04-04 15:30:00'], + 'Lord Howe hour after going back' => [$lordHoweHour, '2026-04-04 15:30:00', '2026-04-04 15:30:00', '2026-04-04 16:30:00'], + 'Lord Howe hour before going forward' => [$lordHoweHour, '2026-10-03 15:29:59', '2026-10-03 14:30:00', '2026-10-03 15:30:00'], + 'Lord Howe half hour after going forward' => [$lordHoweHour, '2026-10-03 15:30:00', '2026-10-03 15:30:00', '2026-10-03 16:00:00'], + 'Lord Howe hour after going forward' => [$lordHoweHour, '2026-10-03 16:00:00', '2026-10-03 16:00:00', '2026-10-03 17:00:00'], + + 'day' => [$utcDay, '2026-10-07 12:00:00', '2026-10-07 00:00:00', '2026-10-08 00:00:00'], + 'day at midnight' => [$utcDay, '2026-10-08 00:00:00', '2026-10-08 00:00:00', '2026-10-09 00:00:00'], + 'day before its reset time' => [$utcDayAtOne, '2026-10-07 00:30:00', '2026-10-06 01:00:00', '2026-10-07 01:00:00'], + 'day at its reset time' => [$utcDayAtOne, '2026-10-07 01:00:00', '2026-10-07 01:00:00', '2026-10-08 01:00:00'], + '23-hour day' => [$newYorkDay, '2026-03-08 12:00:00', '2026-03-08 05:00:00', '2026-03-09 04:00:00'], + + // A reset time repeated by a clock change resets at its first occurrence only. + 'New York before repeated reset' => [$newYorkDayAtRepeated, '2026-11-01 05:15:00', '2026-10-31 05:30:00', '2026-11-01 05:30:00'], + 'New York after first reset' => [$newYorkDayAtRepeated, '2026-11-01 05:45:00', '2026-11-01 05:30:00', '2026-11-02 06:30:00'], + 'New York during repeated reset' => [$newYorkDayAtRepeated, '2026-11-01 06:45:00', '2026-11-01 05:30:00', '2026-11-02 06:30:00'], + 'Berlin before repeated reset' => [$berlinDayAtRepeated, '2026-10-25 00:15:00', '2026-10-24 00:30:00', '2026-10-25 00:30:00'], + 'Berlin during repeated reset' => [$berlinDayAtRepeated, '2026-10-25 01:45:00', '2026-10-25 00:30:00', '2026-10-26 01:30:00'], + 'Sydney during repeated reset' => [$sydneyDayAtRepeated, '2026-04-04 16:45:00', '2026-04-04 15:30:00', '2026-04-05 16:30:00'], + + // A reset time skipped by a clock change moves forward that day, then returns to the configured time. + 'before skipped reset' => [$newYorkDayAtSkipped, '2026-03-08 07:00:00', '2026-03-07 07:30:00', '2026-03-08 07:30:00'], + 'after skipped reset' => [$newYorkDayAtSkipped, '2026-03-08 08:00:00', '2026-03-08 07:30:00', '2026-03-09 06:30:00'], + + 'month' => [$utcMonth, '2026-01-31 12:00:00', '2026-01-01 00:00:00', '2026-02-01 00:00:00'], + 'month at year end' => [$utcMonth, '2026-12-15 12:00:00', '2026-12-01 00:00:00', '2027-01-01 00:00:00'], + 'month across a clock change' => [$newYorkMonth, '2026-11-15 12:00:00', '2026-11-01 04:00:00', '2026-12-01 05:00:00'], + ]; + } + + public function testWindowsEndingBeyondTheSupportedRangeAreRejected(): void + { + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessageIs('The rate limiter timestamp exceeds the supported integer range.'); + + CalendarWindow::perMonth(1)->timezone('UTC')->window(AdmissionPolicy::MAX_INTEGER - 1); + } + + /** + * Assert the policy's capacity, period and reset time. + */ + private function assertPolicy(CalendarWindow $policy, int $maxAttempts, string $period, string $at): void + { + $this->assertSame($maxAttempts, $policy->maxAttempts); + $this->assertSame($period, $policy->period); + $this->assertSame($at, $policy->at); + } +} diff --git a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php index 34838a9840..95e6200f25 100644 --- a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php +++ b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php @@ -6,11 +6,13 @@ use Closure; use Hypervel\RateLimiter\Backoff; +use Hypervel\RateLimiter\CalendarWindow; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; use Hypervel\RateLimiter\SlidingWindow; +use Hypervel\Support\CarbonImmutable; trait RateLimiterStoreContract { @@ -22,6 +24,7 @@ public function testStoreContractConsumesMixedPoliciesTogetherAndSharesScalarSta Limit::perMinute(10)->cost(2)->by($key), SlidingWindow::perMinute(10)->cost(3)->by($key), LeakyBucket::perMinute(1)->burst(10)->cost(4)->by($key), + CalendarWindow::perMonth(10)->timezone('UTC')->cost(5)->by($key), ]; foreach ($policies as $policy) { @@ -30,7 +33,7 @@ public function testStoreContractConsumesMixedPoliciesTogetherAndSharesScalarSta $results = $limiter->consumeMany($policies); - $this->assertCount(3, $results); + $this->assertCount(4, $results); foreach ($results as $index => $result) { $this->assertTrue($result->allowed()); @@ -71,6 +74,61 @@ public function testStoreContractRepeatedKeysConsumeTheCombinedCost(): void $this->assertSame(8, $results[0]->remaining()); $this->assertSame(5, $results[1]->remaining()); $this->assertSame(5, $limiter->inspect($policy)->remaining()); + + $calendar = CalendarWindow::perMonth(10) + ->timezone('UTC') + ->by($this->rateLimiterStoreContractKey('group-repeated-calendar')); + + $results = $limiter->consumeMany([$calendar->cost(2), $calendar->cost(3)]); + + $this->assertCount(2, $results); + $this->assertSame(8, $results[0]->remaining()); + $this->assertSame(5, $results[1]->remaining()); + $this->assertSame(5, $limiter->inspect($calendar)->remaining()); + } + + public function testStoreContractCalendarWindowsEndAtTheCalendarReset(): void + { + $limiter = $this->rateLimiterStoreContract(); + $policy = CalendarWindow::perMonth(5) + ->timezone('UTC') + ->cost(2) + ->by($this->rateLimiterStoreContractKey('calendar')); + $secondsUntilReset = static fn (): int => (int) ceil( + CarbonImmutable::now('UTC')->startOfMonth()->addMonth()->getTimestamp() + - CarbonImmutable::now()->getPreciseTimestamp(6) / 1_000_000 + ); + + $missing = $limiter->inspect($policy); + $this->assertTrue($missing->allowed()); + $this->assertSame(5, $missing->limit()); + $this->assertSame(5, $missing->remaining()); + $this->assertSame(0, $missing->resetAfter()); + + // Database and Redis stores use their own clocks, so allow for clock differences. + $accepted = $limiter->consume($policy); + $this->assertTrue($accepted->allowed()); + $this->assertSame(3, $accepted->remaining()); + $this->assertEqualsWithDelta($secondsUntilReset(), $accepted->resetAfter(), 2); + + $denied = $limiter->consume($policy->cost(4)); + $this->assertTrue($denied->denied()); + $this->assertSame(3, $denied->remaining()); + $this->assertSame($denied->resetAfter(), $denied->retryAfter()); + $this->assertEqualsWithDelta($secondsUntilReset(), $denied->retryAfter(), 2); + + if ($this->advanceRateLimiterStoreContractClock($accepted->resetAfter())) { + $expired = $limiter->inspect($policy); + $this->assertSame(5, $expired->remaining()); + $this->assertSame(0, $expired->resetAfter()); + + $renewed = $limiter->consume($policy); + $this->assertSame(3, $renewed->remaining()); + $this->assertSame($secondsUntilReset(), $renewed->resetAfter()); + } + + $this->assertTrue($limiter->clear($policy)); + $this->assertSame(5, $limiter->inspect($policy)->remaining()); } public function testStoreContractFixedWindowDecisionsAreAtomicAndComplete(): void diff --git a/tests/RateLimiter/KeyResolverTest.php b/tests/RateLimiter/KeyResolverTest.php index f6264b4947..5853a3aded 100644 --- a/tests/RateLimiter/KeyResolverTest.php +++ b/tests/RateLimiter/KeyResolverTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\RateLimiter; use Hypervel\RateLimiter\Backoff; +use Hypervel\RateLimiter\CalendarWindow; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\KeyResolver; use Hypervel\RateLimiter\LeakyBucket; @@ -36,6 +37,13 @@ public function testCanonicalIdentityHasStableGoldenVectors(): void '72519c9daf2298e61f4ce018cacde4ac', $resolver->resolve(LeakyBucket::perSecond(100)->burst(200)->by('user:1'), 'api'), ); + $this->assertSame( + 'cb8b42d25a527a30dc32c02806cdee8a', + $resolver->resolve( + CalendarWindow::perDay(1000, at: '01:00')->timezone('America/New_York')->by('user:1'), + 'api', + ), + ); $this->assertSame( 'c80de4e32af3dae58519b7d54ceb3c12', $resolver->resolve(Backoff::exponential( @@ -96,6 +104,41 @@ public function testSlidingWindowIdentityIncludesStablePolicySettings(): void $this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited'))); } + public function testLeakyBucketIdentityIncludesStablePolicySettings(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + $policy = LeakyBucket::perSecond(10)->burst(20)->by('user:1'); + $key = $resolver->resolve($policy); + + $this->assertNotSame($key, $resolver->resolve(LeakyBucket::perSecond(11)->burst(20)->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve(LeakyBucket::perSecond(10, 2)->burst(20)->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve($policy->burst(21))); + $this->assertNotSame($key, $resolver->resolve($policy->globally())); + $this->assertSame($key, $resolver->resolve($policy->cost(5))); + } + + public function testCalendarWindowIdentityIncludesStablePolicySettings(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + $policy = CalendarWindow::perDay(60, at: '01:00')->timezone('UTC')->by('user:1'); + $key = $resolver->resolve($policy); + + $this->assertSame($key, $resolver->resolve( + CalendarWindow::perDay(60, at: '01:00:00')->timezone('UTC')->by('user:1'), + )); + $this->assertNotSame($key, $resolver->resolve( + CalendarWindow::perDay(61, at: '01:00')->timezone('UTC')->by('user:1'), + )); + $this->assertNotSame($key, $resolver->resolve( + CalendarWindow::perDay(60, at: '02:00')->timezone('UTC')->by('user:1'), + )); + $this->assertNotSame($key, $resolver->resolve(CalendarWindow::perHour(60)->timezone('UTC')->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve($policy->timezone('Europe/Berlin'))); + $this->assertNotSame($key, $resolver->resolve($policy->globally())); + $this->assertNotSame($key, $resolver->resolve(Limit::perDay(60)->by('user:1'))); + $this->assertSame($key, $resolver->resolve($policy->cost(5))); + } + public function testMissingScopeResolverMatchesAResolverReturningNull(): void { $policy = Limit::perMinute(60)->by('user:1'); diff --git a/tests/RateLimiter/LimiterTest.php b/tests/RateLimiter/LimiterTest.php index f963f3e407..ef7eed5091 100644 --- a/tests/RateLimiter/LimiterTest.php +++ b/tests/RateLimiter/LimiterTest.php @@ -8,6 +8,7 @@ use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Backoff; use Hypervel\RateLimiter\BackoffResult; +use Hypervel\RateLimiter\CalendarWindow; use Hypervel\RateLimiter\Contracts\Store; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\CooldownResult; @@ -103,6 +104,7 @@ public function testCrossFieldValidationHappensBeforeKeyOrStoreAccess(): void foreach ([ Limit::perMinute(1)->cost(2), + CalendarWindow::perMinute(1)->cost(2), SlidingWindow::perMinute(1)->cost(2), LeakyBucket::perSecond(1)->cost(2), ] as $policy) { diff --git a/tests/Saloon/Cache/CacheTest.php b/tests/Saloon/Cache/CacheTest.php deleted file mode 100644 index c854e95ece..0000000000 --- a/tests/Saloon/Cache/CacheTest.php +++ /dev/null @@ -1,530 +0,0 @@ -http(); - $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]); - $manager = $this->manager($http); - $connector = new CacheConnectorStub; - - $first = $manager->send($connector, new CacheRequestStub); - $second = $manager->send($connector, new CacheRequestStub); - $refreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache()); - - $this->assertFalse($first->isCached()); - $this->assertSame(['version' => 1], $first->json()); - $this->assertTrue($second->isCached()); - $this->assertTrue($second->isFaked()); - $this->assertFalse($second->isMocked()); - $this->assertSame(['version' => 1], $second->json()); - $this->assertFalse($refreshed->isCached()); - $this->assertSame(['version' => 2], $refreshed->json()); - $http->assertSentCount(2); - } - - public function testSendingAndSentEventsArePairedForCacheHits(): void - { - $http = $this->http(); - $http->fake(['*' => Factory::response(['cached' => true])]); - $events = new Dispatcher; - $dispatched = []; - $events->listen(SendingSaloonRequest::class, function () use (&$dispatched): void { - $dispatched[] = 'sending'; - }); - $events->listen(SentSaloonRequest::class, function () use (&$dispatched): void { - $dispatched[] = 'sent'; - }); - $manager = $this->manager($http, events: $events); - - $manager->send(new CacheConnectorStub, new CacheRequestStub); - $cached = $manager->send(new CacheConnectorStub, new CacheRequestStub); - - $this->assertTrue($cached->isCached()); - $this->assertSame(['sending', 'sent', 'sending', 'sent'], $dispatched); - $http->assertSentCount(1); - } - - public function testSendingListenerMutationsAreIncludedInTheCacheIdentity(): void - { - $http = $this->http(); - $requests = []; - $http->fake(function (HttpRequest $request) use (&$requests) { - $requests[] = [$request->url(), $request->header('X-Variant'), $request->body()]; - - return Factory::response(['version' => count($requests)]); - }); - $events = new Dispatcher; - $variant = 'a'; - $events->listen(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use (&$variant): void { - $event->pendingRequest - ->withHeader('X-Variant', $variant) - ->withQueryParameters(['variant' => $variant]) - ->withData(['variant' => $variant]); - }); - $manager = $this->manager($http, events: $events); - $connector = new CacheConnectorStub; - - $first = $manager->send($connector, new CacheRequestStub); - $variant = 'b'; - $second = $manager->send($connector, new CacheRequestStub); - $variant = 'a'; - $cached = $manager->send($connector, new CacheRequestStub); - - $this->assertSame(1, $first->json('version')); - $this->assertSame(2, $second->json('version')); - $this->assertSame(1, $cached->json('version')); - $this->assertTrue($cached->isCached()); - $this->assertSame([ - ['https://api.example.com/users?variant=a', ['a'], '{"variant":"a"}'], - ['https://api.example.com/users?variant=b', ['b'], '{"variant":"b"}'], - ], $requests); - $http->assertSentCount(2); - } - - public function testCacheMissResolvesTransportConfigurationOnce(): void - { - $http = new Factory; - $http->registerConnection('saloon'); - $http->fake(['*' => Factory::response(['ok' => true])]); - $senderConfig = m::mock(ConfigRepository::class); - $senderConfig->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon'); - $sender = new CountingCacheSender($http, $senderConfig); - - $this->manager($http, sender: $sender)->send(new CacheConnectorStub, new CacheRequestStub); - - $this->assertSame(1, $sender->transportResolutions); - } - - public function testCacheScopeAppliesToReadsWritesAndInvalidation(): void - { - $http = $this->http(); - $http->fake(['*' => $http->sequence() - ->push(['version' => 'tenant-a-1']) - ->push(['version' => 'tenant-b-1']) - ->push(['version' => 'tenant-a-2'])]); - $manager = $this->manager($http); - $scope = 'tenant-a'; - $manager->resolveCacheScopeUsing(static function () use (&$scope): string { - return $scope; - }); - $connector = new CacheConnectorStub; - - $tenantA = $manager->send($connector, new CacheRequestStub); - $tenantACached = $manager->send($connector, new CacheRequestStub); - $scope = 'tenant-b'; - $tenantB = $manager->send($connector, new CacheRequestStub); - $tenantBCached = $manager->send($connector, new CacheRequestStub); - $scope = 'tenant-a'; - $tenantARefreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache()); - $scope = 'tenant-b'; - $tenantBStillCached = $manager->send($connector, new CacheRequestStub); - - $this->assertSame('tenant-a-1', $tenantA->json('version')); - $this->assertTrue($tenantACached->isCached()); - $this->assertSame('tenant-b-1', $tenantB->json('version')); - $this->assertTrue($tenantBCached->isCached()); - $this->assertSame('tenant-a-2', $tenantARefreshed->json('version')); - $this->assertFalse($tenantARefreshed->isCached()); - $this->assertSame('tenant-b-1', $tenantBStillCached->json('version')); - $this->assertTrue($tenantBStillCached->isCached()); - $http->assertSentCount(3); - } - - public function testCacheScopeResolverIsSkippedWhenCachingIsDisabledAndNullAllowsSharing(): void - { - $http = $this->http(); - $http->fake(['*' => $http->sequence() - ->push(['version' => 1]) - ->push(['version' => 2]) - ->push(['version' => 3])]); - $manager = $this->manager($http); - $scope = null; - $resolutions = 0; - $manager->resolveCacheScopeUsing(static function () use (&$scope, &$resolutions): ?string { - ++$resolutions; - - return $scope; - }); - $connector = new CacheConnectorStub; - - $first = $manager->send($connector, new CacheRequestStub); - $shared = $manager->send($connector, new CacheRequestStub); - $manager->send($connector, (new CacheRequestStub)->disableCaching()); - $manager->send($connector, (new CacheRequestStub)->disableCaching()); - - $this->assertSame(1, $first->json('version')); - $this->assertSame(1, $shared->json('version')); - $this->assertTrue($shared->isCached()); - $this->assertSame(2, $resolutions); - $http->assertSentCount(3); - } - - public function testRequestTypeCanOptOutOfConnectorCachingWithoutCacheControls(): void - { - $http = $this->http(); - $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]); - $manager = $this->manager($http); - $connector = new CacheableConnectorStub; - $request = new class extends Request { - protected Method $method = Method::GET; - - /** - * Resolve the uncached endpoint. - */ - public function resolveEndpoint(): string - { - return '/live'; - } - - /** - * Keep this request type out of connector caching. - */ - public function cachingEnabled(): bool - { - return false; - } - }; - - $first = $manager->send($connector, $request); - $second = $manager->send($connector, $request); - - $this->assertSame(1, $first->json('version')); - $this->assertSame(2, $second->json('version')); - $this->assertFalse($first->isCached()); - $this->assertFalse($second->isCached()); - $http->assertSentCount(2); - } - - public function testSaloonFakesDoNotReadOrPopulateTheCache(): void - { - $http = $this->http(); - $http->fake(['*' => Factory::response(['network' => true])]); - $manager = $this->manager($http); - $connector = new CacheConnectorStub; - - $manager->fake([MockResponse::make(['fake' => true])]); - $fake = $manager->send($connector, new CacheRequestStub); - $manager->clearFake(); - $network = $manager->send($connector, new CacheRequestStub); - $cached = $manager->send($connector, new CacheRequestStub); - - $this->assertSame(['fake' => true], $fake->json()); - $this->assertTrue($fake->isMocked()); - $this->assertSame(['network' => true], $network->json()); - $this->assertFalse($network->isCached()); - $this->assertSame(['network' => true], $cached->json()); - $this->assertTrue($cached->isCached()); - $http->assertSentCount(1); - } - - public function testFailuresAndDisabledOrUnsupportedMethodsAreNotCached(): void - { - $http = $this->http(); - $http->fake(['*' => $http->sequence() - ->push(['attempt' => 1], 500) - ->push(['attempt' => 2], 500) - ->push(['attempt' => 3]) - ->push(['attempt' => 4]) - ->push(['attempt' => 5]) - ->push(['attempt' => 6])]); - $manager = $this->manager($http); - $connector = new CacheConnectorStub; - - $this->assertSame(1, $manager->send($connector, new CacheRequestStub)->json('attempt')); - $this->assertSame(2, $manager->send($connector, new CacheRequestStub)->json('attempt')); - $this->assertSame(3, $manager->send($connector, (new CacheRequestStub)->disableCaching())->json('attempt')); - $this->assertSame(4, $manager->send($connector, (new CacheRequestStub)->disableCaching())->json('attempt')); - $this->assertSame(5, $manager->send($connector, new CachePostRequestStub)->json('attempt')); - $this->assertSame(6, $manager->send($connector, new CachePostRequestStub)->json('attempt')); - $http->assertSentCount(6); - } - - public function testRequestCacheSettingsOverrideConnectorSettings(): void - { - $defaultRepository = new Repository(new ArrayStore); - $requestRepository = new Repository(new ArrayStore); - $cache = m::mock(CacheFactory::class); - $cache->shouldReceive('store')->with('request-store')->andReturn($requestRepository); - $cache->shouldReceive('store')->with('connector-store')->andReturn($defaultRepository); - $http = $this->http(); - $http->fake(['*' => Factory::response(['ok' => true])]); - $manager = $this->manager($http, $cache); - $connector = new CacheableConnectorStub; - - $first = $manager->send($connector, new CacheStoreRequestStub); - $second = $manager->send($connector, new CacheStoreRequestStub); - - $this->assertFalse($first->isCached()); - $this->assertTrue($second->isCached()); - $http->assertSentCount(1); - } - - public function testCacheControlsRequireACacheableRequestOrConnector(): void - { - $this->expectException(CachingException::class); - - $this->manager($this->http())->send(new CacheConnectorStub, new CacheControlsOnlyRequestStub); - } - - public function testDefaultKeyCanonicalizesMapsAndSeparatesResponseIdentity(): void - { - $key = new CacheKey; - $connector = new CacheConnectorStub; - $first = $this->pending($connector, (new CacheRequestStub) - ->withHeader('X-Order', ['a', 'b'])); - $same = $this->pending($connector, (new CacheRequestStub) - ->withHeader('x-order', ['a', 'b'])); - $different = $this->pending($connector, (new CacheRequestStub) - ->withHeader('X-Order', ['b', 'a'])); - - $firstKey = $key->make($first, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]); - - $this->assertSame($firstKey, $key->make($same, ['curl' => [1 => 'a', 2 => 'b'], 'verify' => true])); - $this->assertNotSame($firstKey, $key->make($different, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']])); - $this->assertNotSame($firstKey, $key->make($first, ['verify' => false, 'curl' => [2 => 'b', 1 => 'a']])); - $this->assertMatchesRegularExpression('/^saloon:[a-f0-9]{64}$/', $firstKey); - - $firstCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([ - 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/', - ]))); - $differentCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([ - 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/admin', - ]))); - - $this->assertNotSame($key->make($firstCookie, []), $key->make($differentCookie, [])); - } - - public function testCustomKeysRemainBoundedAndCacheScopesStayDistinct(): void - { - $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub); - $key = new CacheKey; - $custom = str_repeat('secret-account-', 100); - - $tenantA = $key->make($pendingRequest, [], $custom, 'tenant-a'); - $tenantB = $key->make($pendingRequest, [], $custom, 'tenant-b'); - - $this->assertNotSame($tenantA, $tenantB); - $this->assertSame(71, strlen($tenantA)); - $this->assertStringNotContainsString('secret-account', $tenantA); - $this->assertStringNotContainsString('tenant-a', $tenantA); - } - - public function testDefaultBodyIdentityRestoresSeekableStreamsAndRejectsUnsafeInputs(): void - { - $request = new CacheStreamRequestStub(Utils::streamFor('payload')); - $pendingRequest = $this->pending(new CacheConnectorStub, $request); - $pendingRequest->preparedBody()?->seek(2); - - (new CacheKey)->make($pendingRequest, []); - - $this->assertSame(2, $pendingRequest->preparedBody()?->tell()); - - $nonSeekable = m::mock(StreamInterface::class); - $nonSeekable->shouldReceive('isSeekable')->andReturn(false); - $unsafe = $this->pending(new CacheConnectorStub, new CacheStreamRequestStub($nonSeekable)); - - try { - (new CacheKey)->make($unsafe, []); - $this->fail('A non-seekable default cache body was accepted.'); - } catch (BodyException) { - $this->addToAssertionCount(1); - } - - $this->assertMatchesRegularExpression( - '/^saloon:[a-f0-9]{64}$/', - (new CacheKey)->make($unsafe, [], 'custom-key'), - ); - } - - public function testSinkAndUnrepresentableIdentityOptionsAreRejected(): void - { - $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub); - - foreach ([['sink' => '/tmp/result'], ['curl' => [1 => new stdClass]]] as $options) { - try { - (new CacheKey)->make($pendingRequest, $options); - $this->fail('An unsafe cache identity option was accepted.'); - } catch (CachingException) { - $this->addToAssertionCount(1); - } - } - - try { - (new CacheKey)->make($pendingRequest, ['sink' => '/tmp/result'], 'custom-key'); - $this->fail('A cached sink was accepted with a custom cache key.'); - } catch (CachingException) { - $this->addToAssertionCount(1); - } - } - - /** - * Create an isolated HTTP factory. - */ - protected function http(): Factory - { - $http = new Factory; - $http->registerConnection('saloon'); - - return $http; - } - - /** - * Create a Saloon manager with an array cache store. - */ - protected function manager( - Factory $http, - ?CacheFactory $cache = null, - ?Dispatcher $events = null, - ?Sender $sender = null, - ): SaloonManager { - $cache ??= m::mock(CacheFactory::class); - - $repository = new Repository(new ArrayStore); - $cache->shouldReceive('store')->with(null)->andReturn($repository)->byDefault(); - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon'); - $config->shouldReceive('get')->with('saloon.cache.store')->andReturn(null); - - return new SaloonManager( - $sender ?? new Sender($http, $config), - $cache, - m::mock(RateLimiter::class), - $config, - $events ?? new Dispatcher, - ); - } - - /** - * Create a finalized pending request for cache-key tests. - */ - protected function pending(Connector $connector, Request $request): PendingRequest - { - return (new PendingRequest( - $connector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ))->applyAuthentication()->finalizeUri()->prepareBody(); - } -} - -class CacheConnectorStub extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } -} - -class CacheableConnectorStub extends CacheConnectorStub implements Cacheable -{ - public function cacheFor(): int - { - return 60; - } - - public function cacheStore(): string - { - return 'connector-store'; - } -} - -class CacheRequestStub extends Request implements Cacheable -{ - use HasCaching; - - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/users'; - } - - public function cacheFor(): DateInterval|DateTimeInterface|int - { - return 60; - } -} - -class CacheStoreRequestStub extends CacheRequestStub -{ - public function cacheStore(): UnitEnum|string|null - { - return 'request-store'; - } -} - -class CachePostRequestStub extends CacheRequestStub -{ - protected Method $method = Method::POST; -} - -class CacheControlsOnlyRequestStub extends Request -{ - use HasCaching; - - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/users'; - } -} - -class CacheStreamRequestStub extends CacheRequestStub -{ - public function __construct(protected StreamInterface $stream) - { - $this->withBody($stream); - } -} - -class CountingCacheSender extends Sender -{ - public int $transportResolutions = 0; - - public function resolveTransport(PendingRequest $pendingRequest): array - { - ++$this->transportResolutions; - - return parent::resolveTransport($pendingRequest); - } -} diff --git a/tests/Saloon/Cache/Feature/CacheTest.php b/tests/Saloon/Cache/Feature/CacheTest.php new file mode 100644 index 0000000000..cc3a84f9c3 --- /dev/null +++ b/tests/Saloon/Cache/Feature/CacheTest.php @@ -0,0 +1,925 @@ +make('config')->set('cache.stores.custom', ['driver' => 'array']); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + Http::preventStrayRequests(); + } + + // REMOVED: "a request with the HasCaching trait will cache the response with a real request" - moved to + // Integration/Saloon/Cache/Feature/CacheTest, which sends it to the engine test server. + + public function testARequestWithTheHasCachingTraitWillCacheTheResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 201, ['X-Howdy' => 'Yeehaw']), + ]); + + $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertTrue($responseA->isMocked()); + $this->assertSame(201, $responseA->status()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame('Yeehaw', $responseA->header('X-Howdy')); + + // Now send a response without the mock middleware, and it should be cached! + // We'll also pass the mock client in here to ensure it's being ignored + + $responseB = TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $this->assertTrue($responseB->isFaked()); + $this->assertTrue($responseB->isCached()); + $this->assertFalse($responseB->isMocked()); + $this->assertSame(201, $responseB->status()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + $this->assertSame('Yeehaw', $responseB->header('X-Howdy')); + $this->assertCount(1, Cache::store()->getStore()->all()); + } + + public function testARequestWithTheHasCachingTraitWillCacheTheResponseWithStringBody(): void + { + $mockClient = new MockClient([ + MockResponse::make('

Hi

', 201, ['X-Howdy' => 'Yeehaw']), + ]); + + $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(201, $responseA->status()); + $this->assertSame('

Hi

', $responseA->body()); + $this->assertSame('Yeehaw', $responseA->header('X-Howdy')); + + // Now send a response without the mock middleware, and it should be cached! + + $responseB = TestConnector::make()->send(new CachedUserRequest); + + $this->assertTrue($responseB->isFaked()); + $this->assertTrue($responseB->isCached()); + $this->assertSame(201, $responseB->status()); + $this->assertSame('

Hi

', $responseB->body()); + $this->assertSame('Yeehaw', $responseB->header('X-Howdy')); + } + + // Upstream caches only GET and OPTIONS by default; HEAD and QUERY are also cacheable here. + public function testItWontCacheOnAnythingOtherThanGetHeadOptionsAndQuery(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Gareth']), + ]); + + $responseA = TestConnector::make()->send(new CachedPostRequest, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $responseB = TestConnector::make()->send(new CachedPostRequest, $mockClient); + + $this->assertFalse($responseB->isCached()); + $this->assertSame(['name' => 'Gareth'], $responseB->json()); + } + + public function testItWillCachePostRequestsIfYouCustomiseTheCacheableMethodsMethod(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $responseA = TestConnector::make()->send(new AllowedCachedPostRequest, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $responseB = TestConnector::make()->send(new AllowedCachedPostRequest, $mockClient); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + } + + // Upstream's "a response will not be cached if the response was not 2xx". Redirects are cached there too, so this is + // named for the statuses it covers. + public function testAResponseWillNotBeCachedIfTheResponseWasAClientOrServerError(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 422), + MockResponse::make(['name' => 'Gareth'], 500), + ]); + + $responseA = TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame(422, $responseA->status()); + + $responseB = TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $this->assertFalse($responseB->isCached()); + $this->assertSame(['name' => 'Gareth'], $responseB->json()); + $this->assertSame(500, $responseB->status()); + } + + public function testARedirectResponseIsCached(): void + { + $mockClient = new MockClient([ + MockResponse::make('', 302, ['Location' => 'https://example.com/moved']), + ]); + + TestConnector::make()->send(new CachedUserRequest, $mockClient); + + $response = TestConnector::make()->send(new CachedUserRequest); + + $this->assertTrue($response->isCached()); + $this->assertSame(302, $response->status()); + $this->assertSame('https://example.com/moved', $response->header('Location')); + } + + // Upstream asserts the hashed file name. Cache keys are hashed with the cache scope here, so this checks that the + // custom key replaces the request identity: a different query string still reaches the cached response. + public function testACustomCacheKeyCanBeProvidedOnTheResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + TestConnector::make()->send(new CustomKeyCachedUserRequest, $mockClient); + + $response = TestConnector::make()->send((new CustomKeyCachedUserRequest)->withQueryParameters(['page' => 2])); + + $this->assertTrue($response->isCached()); + $this->assertSame(['name' => 'Sam'], $response->json()); + } + + public function testQueryParametersAreUsedInTheCacheKey(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Sam']), + ]); + + $requestA = (new CachedUserRequest)->withQueryParameters(['name' => 'Sam']); + $responseA = TestConnector::make()->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $requestB = (new CachedUserRequest)->withQueryParameters(['name' => 'Sam']); + $responseB = TestConnector::make()->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $requestC = new CachedUserRequest; + $responseC = TestConnector::make()->send($requestC, $mockClient); + + $this->assertFalse($responseC->isCached()); + } + + public function testBodyCanBeUsedInTheCacheKey(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Gareth']), + ]); + + $requestA = (new BodyCacheKeyRequest)->withData([ + 'name' => 'Sam', + 'expiry' => '10 hours', + ]); + + $responseA = TestConnector::make()->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $requestB = (new BodyCacheKeyRequest)->withData([ + 'name' => 'Sam', + 'expiry' => '10 hours', + ]); + + $responseB = TestConnector::make()->send($requestB, $mockClient); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + } + + public function testYouWillNotReceiveACachedResponseIfTheResponseHasExpired(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Michael']), + ]); + + $connector = new TestConnector; + + $requestA = new ShortLivedCachedUserRequest; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new ShortLivedCachedUserRequest; + $responseB = $connector->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + + $this->travel(3)->seconds(); + + $requestC = new ShortLivedCachedUserRequest; + $responseC = $connector->send($requestC, $mockClient); + + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Michael'], $responseC->json()); + } + + public function testYouCanDefineACacheOnTheConnectorAndItReturnsACachedResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new CachedConnector; + + $requestA = new CachedConnectorRequest; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new CachedConnectorRequest; + $responseB = $connector->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + } + + public function testIfARequestHasCacheConfigurationThenItWillTakePriorityOverTheConnectors(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new CachedConnector; + + $requestA = new CachedUserRequestOnCachedConnector; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new CachedUserRequestOnCachedConnector; + $responseB = $connector->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + + $this->assertCount(1, Cache::store('custom')->getStore()->all()); + $this->assertCount(0, Cache::store()->getStore()->all()); + } + + public function testYouCanDisableTheCache(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Michael']), + ]); + + $connector = new TestConnector; + + $requestA = new CachedUserRequest; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new CachedUserRequest; + $responseB = $connector->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + + $requestC = new CachedUserRequest; + $requestC->disableCaching(); + + $responseC = $connector->send($requestC, $mockClient); + + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Michael'], $responseC->json()); + } + + public function testCacheCanBeInvalidated(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Teo']), + ]); + + $connector = new TestConnector; + + $requestA = new CachedUserRequest; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new CachedUserRequest; + $responseB = $connector->send($requestB); + + // The response should now be cached... + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + + $requestC = new CachedUserRequest; + $requestC->invalidateCache(); + $responseC = $connector->send($requestC, $mockClient); + + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Teo'], $responseC->json()); + + // Now just make sure that the new response is cached... + + $requestD = new CachedUserRequest; + $responseD = $connector->send($requestD); + + $this->assertTrue($responseD->isCached()); + $this->assertSame(['name' => 'Teo'], $responseD->json()); + } + + public function testItThrowsAnExceptionIfYouUseTheHasCachingTraitWithoutTheCacheableInterface(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + $request = new CachedUserRequestWithoutCacheable; + + $this->expectException(CachingException::class); + $this->expectExceptionMessageIs('The request or connector must implement [Hypervel\Saloon\Cache\Contracts\Cacheable] when using request cache controls.'); + + $connector->send($request, $mockClient); + } + + public function testAResponseRejectedByAFailureHookIsNotCachedForRetries(): void + { + $mockClient = new MockClient([ + MockResponse::make(['status' => 'pending']), + MockResponse::make(['status' => 'complete']), + ]); + + $response = TestConnector::make()->send((new PendingStatusCachedRequest)->retry(2), $mockClient); + + $this->assertFalse($response->isCached()); + $this->assertSame(['status' => 'complete'], $response->json()); + $this->assertTrue(TestConnector::make()->send(new PendingStatusCachedRequest)->isCached()); + } + + public function testAFakeResponseFromRequestMiddlewareBypassesTheCache(): void + { + $connector = new TestConnector; + $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])])); + + $request = new CachedUserRequest; + $request->middleware()->onRequest(fn (): MockResponse => MockResponse::make(['name' => 'Middleware'])); + + $response = $connector->send($request); + + $this->assertFalse($response->isCached()); + $this->assertSame(['name' => 'Middleware'], $response->json()); + $this->assertSame(['name' => 'Sam'], $connector->send(new CachedUserRequest)->json()); + } + + public function testAMockClientWithoutCacheBypassesReadsWritesAndInvalidation(): void + { + $connector = new TestConnector; + $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])])); + $mockClient = (new MockClient([ + MockResponse::make(['name' => 'Alex']), + MockResponse::make(['name' => 'Taylor']), + ]))->withoutCache(); + + $uncached = $connector->send(new CachedUserRequest, $mockClient); + $notInvalidated = $connector->send((new CachedUserRequest)->invalidateCache(), $mockClient); + $cached = $connector->send(new CachedUserRequest); + + $this->assertFalse($uncached->isCached()); + $this->assertSame(['name' => 'Alex'], $uncached->json()); + $this->assertSame(['name' => 'Taylor'], $notInvalidated->json()); + $this->assertTrue($cached->isCached()); + $this->assertSame(['name' => 'Sam'], $cached->json()); + } + + public function testCacheHitsAreNotRecordedAsSentRequests(): void + { + $mockClient = new MockClient([MockResponse::make(['name' => 'Sam'])]); + $connector = new TestConnector; + + $mocked = $connector->send(new CachedUserRequest, $mockClient); + $cached = $connector->send(new CachedUserRequest, $mockClient); + + $this->assertTrue($cached->isCached()); + $mockClient->assertSentCount(1); + $this->assertSame([$mocked], $mockClient->recorded()->all()); + } + + public function testSuccessfulNetworkResponsesAreCachedWithoutReplayingTransport(): void + { + $http = $this->http(); + $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]); + $manager = $this->manager($http); + $connector = new CacheConnectorStub; + + $first = $manager->send($connector, new CacheRequestStub); + $second = $manager->send($connector, new CacheRequestStub); + $refreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache()); + + $this->assertFalse($first->isCached()); + $this->assertSame(['version' => 1], $first->json()); + $this->assertTrue($second->isCached()); + $this->assertTrue($second->isFaked()); + $this->assertFalse($second->isMocked()); + $this->assertSame(['version' => 1], $second->json()); + $this->assertFalse($refreshed->isCached()); + $this->assertSame(['version' => 2], $refreshed->json()); + $http->assertSentCount(2); + } + + public function testSendingAndSentEventsArePairedForCacheHits(): void + { + $http = $this->http(); + $http->fake(['*' => Factory::response(['cached' => true])]); + $events = new Dispatcher; + $dispatched = []; + $events->listen(SendingSaloonRequest::class, function () use (&$dispatched): void { + $dispatched[] = 'sending'; + }); + $events->listen(SentSaloonRequest::class, function () use (&$dispatched): void { + $dispatched[] = 'sent'; + }); + $manager = $this->manager($http, events: $events); + + $manager->send(new CacheConnectorStub, new CacheRequestStub); + $cached = $manager->send(new CacheConnectorStub, new CacheRequestStub); + + $this->assertTrue($cached->isCached()); + $this->assertSame(['sending', 'sent', 'sending', 'sent'], $dispatched); + $http->assertSentCount(1); + } + + public function testSendingListenerMutationsAreIncludedInTheCacheIdentity(): void + { + $http = $this->http(); + $requests = []; + $http->fake(function (HttpRequest $request) use (&$requests): PromiseInterface { + $requests[] = [$request->url(), $request->header('X-Variant'), $request->body()]; + + return Factory::response(['version' => count($requests)]); + }); + $events = new Dispatcher; + $variant = 'a'; + $events->listen(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use (&$variant): void { + $event->pendingRequest + ->withHeader('X-Variant', $variant) + ->withQueryParameters(['variant' => $variant]) + ->withData(['variant' => $variant]); + }); + $manager = $this->manager($http, events: $events); + $connector = new CacheConnectorStub; + + $first = $manager->send($connector, new CacheRequestStub); + $variant = 'b'; + $second = $manager->send($connector, new CacheRequestStub); + $variant = 'a'; + $cached = $manager->send($connector, new CacheRequestStub); + + $this->assertSame(1, $first->json('version')); + $this->assertSame(2, $second->json('version')); + $this->assertSame(1, $cached->json('version')); + $this->assertTrue($cached->isCached()); + $this->assertSame([ + ['https://api.example.com/users?variant=a', ['a'], '{"variant":"a"}'], + ['https://api.example.com/users?variant=b', ['b'], '{"variant":"b"}'], + ], $requests); + $http->assertSentCount(2); + } + + public function testCacheMissResolvesTransportConfigurationOnce(): void + { + $http = new Factory; + $http->registerConnection('saloon'); + $http->fake(['*' => Factory::response(['ok' => true])]); + $senderConfig = m::mock(ConfigRepository::class); + $senderConfig->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon'); + $sender = new CountingCacheSender($http, $senderConfig); + + $this->manager($http, sender: $sender)->send(new CacheConnectorStub, new CacheRequestStub); + + $this->assertSame(1, $sender->transportResolutions); + } + + public function testCacheScopeAppliesToReadsWritesAndInvalidation(): void + { + $http = $this->http(); + $http->fake(['*' => $http->sequence() + ->push(['version' => 'tenant-a-1']) + ->push(['version' => 'tenant-b-1']) + ->push(['version' => 'tenant-a-2'])]); + $manager = $this->manager($http); + $scope = 'tenant-a'; + $manager->resolveCacheScopeUsing(static function () use (&$scope): string { + return $scope; + }); + $connector = new CacheConnectorStub; + + $tenantA = $manager->send($connector, new CacheRequestStub); + $tenantACached = $manager->send($connector, new CacheRequestStub); + $scope = 'tenant-b'; + $tenantB = $manager->send($connector, new CacheRequestStub); + $tenantBCached = $manager->send($connector, new CacheRequestStub); + $scope = 'tenant-a'; + $tenantARefreshed = $manager->send($connector, (new CacheRequestStub)->invalidateCache()); + $scope = 'tenant-b'; + $tenantBStillCached = $manager->send($connector, new CacheRequestStub); + + $this->assertSame('tenant-a-1', $tenantA->json('version')); + $this->assertTrue($tenantACached->isCached()); + $this->assertSame('tenant-b-1', $tenantB->json('version')); + $this->assertTrue($tenantBCached->isCached()); + $this->assertSame('tenant-a-2', $tenantARefreshed->json('version')); + $this->assertFalse($tenantARefreshed->isCached()); + $this->assertSame('tenant-b-1', $tenantBStillCached->json('version')); + $this->assertTrue($tenantBStillCached->isCached()); + $http->assertSentCount(3); + } + + public function testCacheScopeResolverIsSkippedWhenCachingIsDisabledAndNullAllowsSharing(): void + { + $http = $this->http(); + $http->fake(['*' => $http->sequence() + ->push(['version' => 1]) + ->push(['version' => 2]) + ->push(['version' => 3])]); + $manager = $this->manager($http); + $scope = null; + $resolutions = 0; + $manager->resolveCacheScopeUsing(static function () use (&$scope, &$resolutions): ?string { + ++$resolutions; + + return $scope; + }); + $connector = new CacheConnectorStub; + + $first = $manager->send($connector, new CacheRequestStub); + $shared = $manager->send($connector, new CacheRequestStub); + $manager->send($connector, (new CacheRequestStub)->disableCaching()); + $manager->send($connector, (new CacheRequestStub)->disableCaching()); + + $this->assertSame(1, $first->json('version')); + $this->assertSame(1, $shared->json('version')); + $this->assertTrue($shared->isCached()); + $this->assertSame(2, $resolutions); + $http->assertSentCount(3); + } + + public function testRequestTypeCanOptOutOfConnectorCachingWithoutCacheControls(): void + { + $http = $this->http(); + $http->fake(['*' => $http->sequence()->push(['version' => 1])->push(['version' => 2])]); + $manager = $this->manager($http); + $connector = new CacheableConnectorStub; + $request = new class extends Request { + protected Method $method = Method::GET; + + /** + * Resolve the uncached endpoint. + */ + public function resolveEndpoint(): string + { + return '/live'; + } + + /** + * Keep this request type out of connector caching. + */ + public function cachingEnabled(): bool + { + return false; + } + }; + + $first = $manager->send($connector, $request); + $second = $manager->send($connector, $request); + + $this->assertSame(1, $first->json('version')); + $this->assertSame(2, $second->json('version')); + $this->assertFalse($first->isCached()); + $this->assertFalse($second->isCached()); + $http->assertSentCount(2); + } + + public function testDefaultKeyCanonicalizesMapsAndSeparatesResponseIdentity(): void + { + $key = new CacheKey; + $connector = new CacheConnectorStub; + $first = $this->pending($connector, (new CacheRequestStub) + ->withHeader('X-Order', ['a', 'b'])); + $same = $this->pending($connector, (new CacheRequestStub) + ->withHeader('x-order', ['a', 'b'])); + $different = $this->pending($connector, (new CacheRequestStub) + ->withHeader('X-Order', ['b', 'a'])); + + $firstKey = $key->make($first, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']]); + + $this->assertSame($firstKey, $key->make($same, ['curl' => [1 => 'a', 2 => 'b'], 'verify' => true])); + $this->assertNotSame($firstKey, $key->make($different, ['verify' => true, 'curl' => [2 => 'b', 1 => 'a']])); + $this->assertNotSame($firstKey, $key->make($first, ['verify' => false, 'curl' => [2 => 'b', 1 => 'a']])); + $this->assertMatchesRegularExpression('/^saloon:[a-f0-9]{64}$/', $firstKey); + + $firstCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([ + 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/', + ]))); + $differentCookie = $this->pending($connector, (new CacheRequestStub)->withCookie(new SetCookie([ + 'Name' => 'session', 'Value' => 'secret', 'Domain' => 'api.example.com', 'Path' => '/admin', + ]))); + + $this->assertNotSame($key->make($firstCookie, []), $key->make($differentCookie, [])); + } + + public function testCustomKeysRemainBoundedAndCacheScopesStayDistinct(): void + { + $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub); + $key = new CacheKey; + $custom = str_repeat('secret-account-', 100); + + $tenantA = $key->make($pendingRequest, [], $custom, 'tenant-a'); + $tenantB = $key->make($pendingRequest, [], $custom, 'tenant-b'); + + $this->assertNotSame($tenantA, $tenantB); + $this->assertSame(71, strlen($tenantA)); + $this->assertStringNotContainsString('secret-account', $tenantA); + $this->assertStringNotContainsString('tenant-a', $tenantA); + } + + public function testDefaultBodyIdentityRestoresSeekableStreamsAndRejectsUnsafeInputs(): void + { + $request = new CacheStreamRequestStub(Utils::streamFor('payload')); + $pendingRequest = $this->pending(new CacheConnectorStub, $request); + $pendingRequest->preparedBody()?->seek(2); + + (new CacheKey)->make($pendingRequest, []); + + $this->assertSame(2, $pendingRequest->preparedBody()?->tell()); + + $nonSeekable = m::mock(StreamInterface::class); + $nonSeekable->shouldReceive('isSeekable')->andReturn(false); + $unsafe = $this->pending(new CacheConnectorStub, new CacheStreamRequestStub($nonSeekable)); + + try { + (new CacheKey)->make($unsafe, []); + $this->fail('A non-seekable default cache body was accepted.'); + } catch (BodyException) { + $this->addToAssertionCount(1); + } + + $this->assertMatchesRegularExpression( + '/^saloon:[a-f0-9]{64}$/', + (new CacheKey)->make($unsafe, [], 'custom-key'), + ); + } + + public function testSinkAndUnrepresentableIdentityOptionsAreRejected(): void + { + $pendingRequest = $this->pending(new CacheConnectorStub, new CacheRequestStub); + + foreach ([['sink' => '/tmp/result'], ['curl' => [1 => new stdClass]]] as $options) { + try { + (new CacheKey)->make($pendingRequest, $options); + $this->fail('An unsafe cache identity option was accepted.'); + } catch (CachingException) { + $this->addToAssertionCount(1); + } + } + + try { + (new CacheKey)->make($pendingRequest, ['sink' => '/tmp/result'], 'custom-key'); + $this->fail('A cached sink was accepted with a custom cache key.'); + } catch (CachingException) { + $this->addToAssertionCount(1); + } + } + + /** + * Create an isolated HTTP factory. + */ + protected function http(): Factory + { + $http = new Factory; + $http->registerConnection('saloon'); + + return $http; + } + + /** + * Create a Saloon manager with an array cache store. + */ + protected function manager(Factory $http, ?Dispatcher $events = null, ?Sender $sender = null): SaloonManager + { + $cache = m::mock(CacheFactory::class); + $cache->shouldReceive('store')->with(null)->andReturn(new Repository(new ArrayStore)); + $config = m::mock(ConfigRepository::class); + $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon'); + $config->shouldReceive('get')->with('saloon.cache.store')->andReturn(null); + + return new SaloonManager( + $sender ?? new Sender($http, $config), + $cache, + m::mock(RateLimiter::class), + $config, + $events ?? new Dispatcher, + ); + } + + /** + * Create a finalized pending request for cache-key tests. + */ + protected function pending(Connector $connector, Request $request): PendingRequest + { + return (new PendingRequest($connector, $request))->applyAuthentication()->finalizeUri()->prepareBody(); + } +} + +class PendingStatusCachedRequest extends CachedUserRequest +{ + /** + * Treat a pending status as a failed request. + */ + public function hasRequestFailed(Response $response): ?bool + { + return $response->json('status') === 'pending' ? true : null; + } +} + +class CacheConnectorStub extends Connector +{ + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } +} + +class CacheableConnectorStub extends CacheConnectorStub implements Cacheable +{ + /** + * Get the cache duration. + */ + public function cacheFor(): int + { + return 60; + } + + /** + * Get the cache store name. + */ + public function cacheStore(): string + { + return 'connector-store'; + } +} + +class CacheRequestStub extends Request implements Cacheable +{ + use HasCaching; + + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } + + /** + * Get the cache duration. + */ + public function cacheFor(): DateInterval|DateTimeInterface|int + { + return 60; + } +} + +class CacheStreamRequestStub extends CacheRequestStub +{ + /** + * Create a request with a stream body. + */ + public function __construct(protected StreamInterface $stream) + { + $this->withBody($stream); + } +} + +class CountingCacheSender extends Sender +{ + public int $transportResolutions = 0; + + /** + * Resolve the transport and count the resolution. + */ + public function resolveTransport(PendingRequest $pendingRequest): array + { + ++$this->transportResolutions; + + return parent::resolveTransport($pendingRequest); + } +} diff --git a/tests/Saloon/Cache/Feature/ClearCacheTest.php b/tests/Saloon/Cache/Feature/ClearCacheTest.php new file mode 100644 index 0000000000..f13486592e --- /dev/null +++ b/tests/Saloon/Cache/Feature/ClearCacheTest.php @@ -0,0 +1,218 @@ + 'Sam']), + ]); + + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Send and cache the response + $responseA = $connector->send($request, $mockClient); + $this->assertFalse($responseA->isCached()); + + // Verify it is cached + $responseB = $connector->send(new CachedUserRequest); + $this->assertTrue($responseB->isCached()); + + // Delete the cache without sending a request + $sending = 0; + Event::listen(SendingSaloonRequest::class, function () use (&$sending): void { + ++$sending; + }); + + $request = new CachedUserRequest; + $request->clearCache($connector); + + $this->assertSame(0, $sending); + Http::assertNothingSent(); + + // Now sending should result in a cache miss + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Michael'], $responseC->json()); + } + + public function testClearCacheOnAnUncachedRequestDoesNotThrow(): void + { + $connector = new TestConnector; + $request = new CachedUserRequest; + + $request->clearCache($connector); + + $this->assertCount(0, Cache::store()->getStore()->all()); + } + + public function testClearCacheUsesACustomCacheKeyOverride(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache with the custom key + $connector->send(new CustomKeyCachedUserRequest, $mockClient); + + $this->assertCount(1, Cache::store()->getStore()->all()); + + // Delete using the custom key, which ignores the request's query string + $request = (new CustomKeyCachedUserRequest)->withQueryParameters(['page' => 2]); + $request->clearCache($connector); + + $this->assertCount(0, Cache::store()->getStore()->all()); + } + + public function testAfterClearCacheTheNextSendFetchesFreshAndRepopulatesCache(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache + $connector->send(new CachedUserRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedUserRequest); + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + + // Delete cache + $request = new CachedUserRequest; + $request->clearCache($connector); + + // Send again - should be a fresh response + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Teo']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Teo'], $responseC->json()); + + // Verify the new response is cached + $responseD = $connector->send(new CachedUserRequest); + $this->assertTrue($responseD->isCached()); + $this->assertSame(['name' => 'Teo'], $responseD->json()); + } + + public function testClearCacheWorksWhenCalledFromTheConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new CachedConnector; + + // Send and cache + $connector->send(new CachedConnectorRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedConnectorRequest); + $this->assertTrue($responseB->isCached()); + + // Delete cache from the connector side + $connector->clearCache(new CachedConnectorRequest); + + // Should be a cache miss now + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedConnectorRequest, $mockClient); + $this->assertFalse($responseC->isCached()); + $this->assertSame(['name' => 'Michael'], $responseC->json()); + } + + // REMOVED: "clearCache throws when called on a connector with another connector" and "clearCache throws when + // called on a request with another request" - the request and connector methods type their counterparts natively. + + public function testClearCacheOnlyClearsTheCurrentCacheScope(): void + { + $scope = 'tenant-a'; + Saloon::resolveCacheScopeUsing(static function () use (&$scope): string { + return $scope; + }); + $mockClient = new MockClient([ + MockResponse::make(['tenant' => 'a']), + MockResponse::make(['tenant' => 'b']), + MockResponse::make(['tenant' => 'a-fresh']), + ]); + $connector = new TestConnector; + + $connector->send(new CachedUserRequest, $mockClient); + $scope = 'tenant-b'; + $connector->send(new CachedUserRequest, $mockClient); + $scope = 'tenant-a'; + (new CachedUserRequest)->clearCache($connector); + + $tenantA = $connector->send(new CachedUserRequest, $mockClient); + $scope = 'tenant-b'; + $tenantB = $connector->send(new CachedUserRequest); + + $this->assertFalse($tenantA->isCached()); + $this->assertSame(['tenant' => 'a-fresh'], $tenantA->json()); + $this->assertTrue($tenantB->isCached()); + $this->assertSame(['tenant' => 'b'], $tenantB->json()); + } + + public function testClearCacheIgnoresDisabledCaching(): void + { + $connector = new TestConnector; + $connector->send(new CachedUserRequest, new MockClient([MockResponse::make(['name' => 'Sam'])])); + + (new CachedUserRequest)->disableCaching()->clearCache($connector); + + $this->assertCount(0, Cache::store()->getStore()->all()); + } +} diff --git a/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php b/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php new file mode 100644 index 0000000000..615688f56f --- /dev/null +++ b/tests/Saloon/Cache/Feature/Hypervel/HypervelCacheDriverTest.php @@ -0,0 +1,92 @@ +make('config')->set('cache.stores.file', [ + 'driver' => 'file', + 'path' => $this->cachePath(), + 'lock_path' => $this->cachePath(), + ]); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + (new Filesystem)->deleteDirectory($this->cachePath()); + + Http::preventStrayRequests(); + } + + /** + * Tear down the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->cachePath()); + + parent::tearDown(); + } + + public function testItWillReturnACachedResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + $requestA = new HypervelCachedUserRequest; + $responseA = $connector->send($requestA, $mockClient); + + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + + $requestB = new HypervelCachedUserRequest; + $responseB = $connector->send($requestB); + + $this->assertTrue($responseB->isCached()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + } + + /** + * Get the temporary file cache directory. + */ + protected function cachePath(): string + { + return ParallelTesting::tempDir('SaloonHypervelCacheDriverTest'); + } +} diff --git a/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php b/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php new file mode 100644 index 0000000000..7e018444bf --- /dev/null +++ b/tests/Saloon/Cache/Feature/ResponseCacheMockFixtureTest.php @@ -0,0 +1,118 @@ +files = new Filesystem; + $this->fixturePath = ParallelTesting::tempDir('SaloonResponseCacheMockFixtureTest'); + $this->files->deleteDirectory($this->fixturePath); + $this->files->ensureDirectoryExists($this->fixturePath); + + Saloon::fixturePath($this->fixturePath); + Http::preventStrayRequests(); + } + + /** + * Tear down the test environment. + */ + protected function tearDown(): void + { + $this->files->deleteDirectory($this->fixturePath); + + parent::tearDown(); + } + + public function testResponseCacheCanReturnAStaleBodyWhenMocksUseFixturesAndWithoutCacheIsNotUsed(): void + { + $this->writeCachedUserRequestFixture(1); + + $mockClient = new MockClient([ + CachedUserRequest::class => MockResponse::fixture('cached-user-request'), + ]); + + $connector = TestConnector::make(); + + $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version')); + + $this->writeCachedUserRequestFixture(2); + + $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version')); + } + + public function testWithoutCacheOnTheMockClientSkipsResponseCacheSoUpdatedFixtureFilesAreUsed(): void + { + $this->writeCachedUserRequestFixture(1); + + $mockClient = (new MockClient([ + CachedUserRequest::class => MockResponse::fixture('cached-user-request'), + ]))->withoutCache(); + + $connector = TestConnector::make(); + + $this->assertSame(1, $connector->send(new CachedUserRequest, $mockClient)->json('version')); + + $this->writeCachedUserRequestFixture(2); + + $this->assertSame(2, $connector->send(new CachedUserRequest, $mockClient)->json('version')); + } + + /** + * Write the cached user request fixture. + */ + protected function writeCachedUserRequestFixture(int $version): void + { + $payload = [ + 'statusCode' => 200, + 'headers' => ['Content-Type' => ['application/json']], + 'data' => json_encode(['version' => $version]), + 'context' => [], + ]; + + $this->files->put( + $this->fixturePath . '/cached-user-request.json', + json_encode($payload, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), + ); + } +} diff --git a/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php b/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php new file mode 100644 index 0000000000..516e7524d4 --- /dev/null +++ b/tests/Saloon/Cache/Fixtures/Connectors/CachedConnector.php @@ -0,0 +1,37 @@ + + */ + protected function defaultBody(): array + { + return [ + 'name' => 'Sammy', + ]; + } + + /** + * Get the cache duration. + */ + public function cacheFor(): int + { + return 60; + } + + /** + * Define the cacheable HTTP methods. + * + * @return list + */ + protected function cacheableMethods(): array + { + return [Method::GET, Method::OPTIONS, Method::POST]; + } +} diff --git a/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php b/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php new file mode 100644 index 0000000000..ed8019185d --- /dev/null +++ b/tests/Saloon/Cache/Fixtures/Requests/BodyCacheKeyRequest.php @@ -0,0 +1,47 @@ +body(), JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php b/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php new file mode 100644 index 0000000000..1b1108d964 --- /dev/null +++ b/tests/Saloon/Cache/Fixtures/Requests/CachedConnectorRequest.php @@ -0,0 +1,24 @@ + + */ + protected function defaultBody(): array + { + return [ + 'name' => 'Sammy', + ]; + } + + /** + * Get the cache duration. + */ + public function cacheFor(): int + { + return 60; + } +} diff --git a/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php b/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php new file mode 100644 index 0000000000..af923aaf20 --- /dev/null +++ b/tests/Saloon/Cache/Fixtures/Requests/CachedUserRequest.php @@ -0,0 +1,36 @@ + + */ + protected array $generatedDirectories = []; + + /** + * Get the package providers. + */ protected function getPackageProviders(ApplicationContract $app): array { return [SaloonServiceProvider::class]; } + /** + * Clean up the test environment. + */ protected function tearDown(): void { $files = new Filesystem; @@ -33,6 +49,10 @@ protected function tearDown(): void $files->delete($generatedFile); } + foreach ($this->generatedDirectories as $generatedDirectory) { + $files->deleteDirectory($generatedDirectory); + } + parent::tearDown(); } @@ -98,6 +118,33 @@ public function testUnsupportedRequestMethodIsRejected(): void ]); } + public function testMissingArgumentsArePromptedWithExistingIntegrations(): void + { + $integrationsPath = ParallelTesting::tempDir('SaloonGeneratorCommandsTest'); + $files = new Filesystem; + $files->deleteDirectory($integrationsPath); + $files->ensureDirectoryExists($integrationsPath . '/GitHub'); + $files->ensureDirectoryExists($integrationsPath . '/Stripe'); + $this->generatedDirectories[] = $integrationsPath; + config()->set('saloon.integrations_path', $integrationsPath); + config()->set('saloon.integrations_namespace', 'Domain\Integrations'); + + $this->artisan('saloon:request') + ->expectsChoice('What is the related integration?', 'Stripe', ['GitHub', 'Stripe']) + ->expectsQuestion('What should the Saloon request be named?', 'CreatePayment') + ->expectsChoice( + 'What method should the Saloon request send?', + 'POST', + ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE', 'QUERY'], + ) + ->assertSuccessful(); + + $contents = $this->generatedFile($integrationsPath . '/Stripe/Requests/CreatePayment.php'); + + $this->assertStringContainsString('namespace Domain\Integrations\Stripe\Requests;', $contents); + $this->assertStringContainsString('protected Method $method = Method::POST;', $contents); + } + public function testAllSupportingTypesCanBeGenerated(): void { $commands = [ @@ -136,6 +183,74 @@ public function testConfiguredPathAndNamespaceAreIndependentDefaults(): void $this->assertStringContainsString('namespace Domain\Integrations\Stripe;', $contents); } + #[DataProvider('integrationsPathsInsideTheAppDirectory')] + public function testAnIntegrationsPathInsideTheAppDirectoryDeterminesTheNamespace(string $relativePath, string $namespace): void + { + $integrationsPath = app_path($relativePath); + config()->set('saloon.integrations_path', $integrationsPath); + $this->generatedDirectories[] = $integrationsPath . '/Stripe'; + + $this->artisan('saloon:connector', [ + 'integration' => 'Stripe', + 'name' => 'StripeConnector', + '--no-interaction' => true, + ])->assertSuccessful(); + + $contents = $this->generatedFile($integrationsPath . '/Stripe/StripeConnector.php'); + + $this->assertStringContainsString("namespace {$namespace};", $contents); + } + + /** + * Get integrations paths inside the app directory and their generated namespaces. + * + * @return array + */ + public static function integrationsPathsInsideTheAppDirectory(): array + { + return [ + 'application directory' => ['', 'App\Stripe'], + 'subdirectory' => ['Integrations', 'App\Integrations\Stripe'], + 'path with dot segments' => ['Http/../Integrations', 'App\Integrations\Stripe'], + ]; + } + + #[DataProvider('integrationsPathsOutsideTheAppDirectory')] + public function testAnIntegrationsPathOutsideTheAppDirectoryRequiresANamespace(string $relativePath): void + { + config()->set('saloon.integrations_path', base_path($relativePath)); + $this->generatedDirectories[] = base_path('app-other'); + + try { + $this->artisan('saloon:connector', [ + 'integration' => 'Stripe', + 'name' => 'StripeConnector', + '--no-interaction' => true, + ])->run(); + $this->fail('The missing integrations namespace was not rejected.'); + } catch (LogicException $exception) { + $this->assertSame( + 'The [saloon.integrations_namespace] configuration value is required when the integrations path is outside the application directory.', + $exception->getMessage(), + ); + } + + $this->assertDirectoryDoesNotExist(base_path('app-other')); + } + + /** + * Get integrations paths, relative to the base path, that are outside the app directory. + * + * @return array + */ + public static function integrationsPathsOutsideTheAppDirectory(): array + { + return [ + 'sibling whose name starts with the app directory name' => ['app-other/Integrations'], + 'dot segments leaving the app directory' => ['app/../app-other/Integrations'], + ]; + } + public function testCommandOptionsOverrideConfiguredPathAndNamespace(): void { config()->set('saloon.integrations_path', base_path('ignored/integrations')); diff --git a/tests/Saloon/Console/ListCommandTest.php b/tests/Saloon/Console/ListCommandTest.php index 1d4758d203..78fac2f936 100644 --- a/tests/Saloon/Console/ListCommandTest.php +++ b/tests/Saloon/Console/ListCommandTest.php @@ -15,11 +15,17 @@ class ListCommandTest extends TestCase { protected string $integrationsPath; + /** + * Get the package providers. + */ protected function getPackageProviders(ApplicationContract $app): array { return [SaloonServiceProvider::class]; } + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -28,6 +34,9 @@ protected function setUp(): void (new Filesystem)->deleteDirectory($this->integrationsPath); } + /** + * Clean up the test environment. + */ protected function tearDown(): void { (new Filesystem)->deleteDirectory($this->integrationsPath); @@ -60,7 +69,7 @@ class GitHubConnector { public function resolveBaseUrl(): string { - return 'https://api.github.com'; + return "https://api.github.com"; } } PHP); @@ -75,7 +84,46 @@ class GetUser public function resolveEndpoint(): string { - return '/users/{$this->user}'; + return "/users/{$this->user}"; + } +} +PHP); + $files->put($this->integrationsPath . '/GitHub/Requests/Users/StarRepository.php', <<<'PHP' +user . '/stars.json'; + } +} +PHP); + $files->put($this->integrationsPath . '/GitHub/Requests/Users/GetStarred.php', <<<'PHP' +user/starred'; + } +} +PHP); + $files->put($this->integrationsPath . '/GitHub/Requests/GetListItems.php', <<<'PHP' +assertStringContainsString('GitHub', $output); $this->assertStringContainsString('Authenticators: 1', $output); $this->assertStringContainsString('Connectors: 1', $output); - $this->assertStringContainsString('Requests: 1', $output); + $this->assertStringContainsString('Requests: 4', $output); $this->assertStringContainsString('Plugins: 1', $output); $this->assertStringContainsString('Responses: 1', $output); - $this->assertStringContainsString('api.github.com', $output); - $this->assertStringContainsString('/users/{user}', $output); - $this->assertStringContainsString('GET', $output); + $this->assertStringContainsString(' api.github.com', $output); + $this->assertStringContainsString(' /users/{user} GET', $output); + $this->assertStringContainsString(' /v1.0/users/{user}/stars.json PUT', $output); + $this->assertStringContainsString(' /users/$this->user/starred GET', $output); + $this->assertStringContainsString(" /_api/web/lists/getbytitle(\\'Docs.v2\\')/items GET", $output); } } diff --git a/tests/Saloon/Data/OAuthConfigTest.php b/tests/Saloon/Data/OAuthConfigTest.php deleted file mode 100644 index 603b2d4863..0000000000 --- a/tests/Saloon/Data/OAuthConfigTest.php +++ /dev/null @@ -1,77 +0,0 @@ - $request->withHeader('X-OAuth', 'configured'), - ); - $request = new OAuthConfigRequestStub; - - $this->assertSame($request, $config->modify($request)); - $this->assertSame(['X-OAuth' => 'configured'], $request->headers()); - $config->validate(); - } - - #[DataProvider('invalidConfigurations')] - public function testItValidatesRequiredConfiguration(OAuthConfig $config, bool $withRedirectUri, string $message): void - { - $this->expectException(OAuthConfigValidationException::class); - $this->expectExceptionMessageIsOrContains($message); - - $config->validate($withRedirectUri); - } - - public static function invalidConfigurations(): array - { - return [ - 'client id' => [new OAuthConfig('', 'secret', 'https://app.example.com/callback'), true, 'client ID'], - 'client secret' => [new OAuthConfig('client', '', 'https://app.example.com/callback'), true, 'client secret'], - 'redirect uri' => [new OAuthConfig('client', 'secret'), true, 'redirect URI'], - ]; - } - - public function testRedirectUriIsOptionalForClientCredentials(): void - { - $config = new OAuthConfig('client', 'secret'); - - $config->validate(false); - - $this->addToAssertionCount(1); - } - - public function testAuthorizationUrlKeepsTheUrlAndStateTogether(): void - { - $authorization = new AuthorizationUrl('https://provider.example.com/authorize', 'state'); - - $this->assertSame('https://provider.example.com/authorize', (string) $authorization); - $this->assertSame('state', $authorization->state); - } -} - -class OAuthConfigRequestStub extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/oauth'; - } -} diff --git a/tests/Saloon/Data/RecordedResponseTest.php b/tests/Saloon/Data/RecordedResponseTest.php deleted file mode 100644 index 41083b6a80..0000000000 --- a/tests/Saloon/Data/RecordedResponseTest.php +++ /dev/null @@ -1,48 +0,0 @@ - ['abc']], - '{"name":"Taylor"}', - ['provider' => 'example'], - ); - - $restored = RecordedResponse::fromFile($recorded->toFile()); - - $this->assertSame(201, $restored->statusCode); - $this->assertSame(['X-Trace' => ['abc']], $restored->headers); - $this->assertSame('{"name":"Taylor"}', $restored->data); - $this->assertSame(['provider' => 'example'], $restored->context); - $this->assertSame('{"name":"Taylor"}', (string) $restored->toMockResponse()->createPsrResponse()->getBody()); - } - - public function testItRoundTripsBinaryResponseData(): void - { - $recorded = new RecordedResponse(200, data: "\xB1\x31"); - - $contents = $recorded->toFile(); - $restored = RecordedResponse::fromFile($contents); - - $this->assertStringContainsString('"encoding": "base64"', $contents); - $this->assertSame("\xB1\x31", $restored->data); - } - - public function testItRejectsInvalidBase64Data(): void - { - $this->expectException(FixtureException::class); - - RecordedResponse::fromFile('{"statusCode":200,"headers":[],"data":"%","encoding":"base64"}'); - } -} diff --git a/tests/Saloon/Feature/AuthenticatesRequestsTest.php b/tests/Saloon/Feature/AuthenticatesRequestsTest.php new file mode 100644 index 0000000000..d541521dd6 --- /dev/null +++ b/tests/Saloon/Feature/AuthenticatesRequestsTest.php @@ -0,0 +1,54 @@ +withDigestAuth('Sammyjo20', 'Cowboy1'); + + $authorization = []; + + Http::fake(function (HttpRequest $request) use (&$authorization): PromiseInterface { + $authorization[] = $request->header('Authorization'); + + return count($authorization) === 1 + ? Factory::response('', 401, ['WWW-Authenticate' => 'Digest realm="saloon", nonce="nonce", qop="auth"']) + : Factory::response(); + }); + + $response = $connector->send($request); + + $this->assertSame(200, $response->status()); + $this->assertCount(2, $authorization); + $this->assertSame([], $authorization[0]); + $this->assertStringStartsWith('Digest username="Sammyjo20", realm="saloon", nonce="nonce"', $authorization[1][0]); + } +} diff --git a/tests/Saloon/Feature/BaseResourceTest.php b/tests/Saloon/Feature/BaseResourceTest.php new file mode 100644 index 0000000000..d3008c8505 --- /dev/null +++ b/tests/Saloon/Feature/BaseResourceTest.php @@ -0,0 +1,40 @@ +assertSame([ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ], $connector->user()->get()); + } +} diff --git a/tests/Saloon/Feature/Body/HasFormBodyTest.php b/tests/Saloon/Feature/Body/HasFormBodyTest.php new file mode 100644 index 0000000000..e770cd1caa --- /dev/null +++ b/tests/Saloon/Feature/Body/HasFormBodyTest.php @@ -0,0 +1,69 @@ +assertSame([ + 'name' => 'Sam', + 'catchphrase' => 'Yeehaw!', + ], $request->body()); + + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasFormBodyRequest; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + $this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']); + }); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $this->assertSame(['application/x-www-form-urlencoded'], $httpRequest->header('Content-Type')); + $this->assertSame('name=Sam&catchphrase=Yeehaw%21', $httpRequest->body()); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } +} diff --git a/tests/Saloon/Feature/Body/HasJsonBodyTest.php b/tests/Saloon/Feature/Body/HasJsonBodyTest.php new file mode 100644 index 0000000000..5af4e80d3d --- /dev/null +++ b/tests/Saloon/Feature/Body/HasJsonBodyTest.php @@ -0,0 +1,209 @@ +assertSame([ + 'name' => 'Sam', + 'catchphrase' => 'Yeehaw!', + ], $request->body()); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + + $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']); + } + + public function testTheContentTypeHeaderIsSetInThePendingRequest(): void + { + $pendingRequest = $this->pendingRequest(TestConnector::make(), new HasJsonBodyRequest); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], $pendingRequest->headers()); + } + + public function testAnExplicitContentTypeOverridesTheBodyTraitDefault(): void + { + $request = (new HasJsonBodyRequest)->withHeader('Content-Type', 'application/vnd.api+json'); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + + $this->assertSame('application/vnd.api+json', $pendingRequest->headers()['Content-Type']); + } + + public function testWhenJustTheConnectorHasBodyTheBodyWillBeSent(): void + { + // Connectors are read-only and expose no body, so their default body is checked on the pending request. + $pendingRequest = $this->pendingRequest(new HasJsonBodyConnector, new UserRequest); + + $this->assertSame([ + 'name' => 'Gareth', + 'drink' => 'Moonshine', + ], $pendingRequest->body()); + $this->assertSame('{"name":"Gareth","drink":"Moonshine"}', (string) $pendingRequest->preparedBody()); + } + + public function testWhenBothTheConnectorAndTheRequestHaveTheSameRequestBodiesTheyWillBeMerged(): void + { + $request = new HasJsonBodyRequest; + + $this->assertSame([ + 'name' => 'Sam', + 'catchphrase' => 'Yeehaw!', + ], $request->body()); + + // Name should be overwritten to "Sam" and "catchphrase" should be merged in + $pendingRequest = $this->pendingRequest(new HasJsonBodyConnector, $request); + + $this->assertSame([ + 'name' => 'Sam', + 'drink' => 'Moonshine', + 'catchphrase' => 'Yeehaw!', + ], $pendingRequest->body()); + $this->assertSame('{"name":"Sam","drink":"Moonshine","catchphrase":"Yeehaw!"}', (string) $pendingRequest->preparedBody()); + } + + public function testIfTheConnectorAndRequestImplementDifferentBodyRepositoriesThenAnExceptionIsThrown(): void + { + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIs('Connector and request body types must be the same.'); + + $this->pendingRequest(new HasJsonBodyConnector, new HasMultipartBodyRequest); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasJsonBodyRequest; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']); + }); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $this->assertSame(['application/json'], $httpRequest->header('Content-Type')); + $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!"}', $httpRequest->body()); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } + + public function testYouCanSpecifyDifferentJsonFlagsThatTheBodyRepositoryShouldUse(): void + { + // Request bodies are not exposed as repositories, so the flags are tested on the repository itself and then + // supplied through a request's defaultBodyRepository(). + $body = new JsonBodyRepository((new HasJsonBodyRequest)->body()); + + // We'll add a property with slashes + $body->add('url', 'https://docs.saloon.dev'); + + // By default, PHP will escape slashes + $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!","url":"https:\/\/docs.saloon.dev"}', (string) $body); + + // Now we'll customise the flags + $body->setJsonFlags(JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!","url":"https://docs.saloon.dev"}', (string) $body); + $this->assertSame(JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, $body->getJsonFlags()); + + $pendingRequest = $this->pendingRequest(new TestConnector, new UnescapedSlashesJsonRequestStub); + + $this->assertSame('{"url":"https://docs.saloon.dev"}', (string) $pendingRequest->preparedBody()); + } + + public function testTheJsonBodyRepositoryUsesTheJsonThrowOnErrorDefaultFlag(): void + { + $this->assertSame(JSON_THROW_ON_ERROR, (new JsonBodyRepository)->getJsonFlags()); + } + + /** + * Send the request with a mock response and return its pending request. + */ + protected function pendingRequest(Connector $connector, Request $request): PendingRequest + { + return $connector->send($request, new MockClient([MockResponse::make()]))->pendingRequest(); + } +} + +class UnescapedSlashesJsonRequestStub extends Request +{ + use HasJsonBody; + + /** + * Define the method that the request will use. + */ + protected Method $method = Method::POST; + + /** + * Define the endpoint for the request. + */ + public function resolveEndpoint(): string + { + return '/user'; + } + + /** + * Define the default body. + * + * @return array + */ + protected function defaultBody(): array + { + return ['url' => 'https://docs.saloon.dev']; + } + + /** + * Resolve the default body repository. + */ + protected function defaultBodyRepository(): BodyRepository + { + return (new JsonBodyRepository($this->defaultBody())) + ->setJsonFlags(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } +} diff --git a/tests/Saloon/Feature/Body/HasMultipartBodyTest.php b/tests/Saloon/Feature/Body/HasMultipartBodyTest.php new file mode 100644 index 0000000000..e51fea0518 --- /dev/null +++ b/tests/Saloon/Feature/Body/HasMultipartBodyTest.php @@ -0,0 +1,318 @@ +assertEquals([ + new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + ], $request->body()); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + $contentType = $pendingRequest->headers()['Content-Type']; + $boundary = substr($contentType, strlen('multipart/form-data; boundary=')); + + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertStringStartsWith('--' . $boundary . "\r\n", (string) $pendingRequest->preparedBody()); + } + + public function testWhenBothTheConnectorAndTheRequestHaveTheSameRequestBodiesTheyWillBeMerged(): void + { + $request = new HasMultipartBodyRequest; + + $this->assertEquals([ + new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + ], $request->body()); + + // Connectors are read-only and expose no body, so their default body is checked on the pending request. + $pendingRequest = $this->pendingRequest(new HasMultipartBodyConnector, $request); + + $this->assertEquals([ + new MultipartValue('nickname', 'Gareth', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + new MultipartValue('drink', 'Moonshine', 'moonshine.txt', ['X-My-Head' => 'Spinning!']), + new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + ], $pendingRequest->body()); + + $contentType = $pendingRequest->headers()['Content-Type']; + $body = (string) $pendingRequest->preparedBody(); + + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertStringStartsWith('--' . substr($contentType, strlen('multipart/form-data; boundary=')) . "\r\n", $body); + $this->assertStringContainsString("\r\n\r\nGareth\r\n", $body); + $this->assertStringContainsString("\r\n\r\nMoonshine\r\n", $body); + $this->assertStringContainsString("\r\n\r\nSam\r\n", $body); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasMultipartBodyRequest; + + $asserted = false; + + // The boundary content type is added when the body is prepared, after request middleware runs, so it always + // matches the final body. The upstream middleware assertion is therefore made on the sent request. + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $contentType = $httpRequest->header('Content-Type')[0]; + $body = $httpRequest->body(); + + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertStringStartsWith('--' . substr($contentType, strlen('multipart/form-data; boundary=')), $body); + $this->assertStringContainsString('X-Saloon: Yee-haw!', $body); + $this->assertStringContainsString('Content-Disposition: form-data; name="nickname"; filename="user.txt"', $body); + $this->assertStringContainsString('Sam', $body); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } + + // The real-server cases "can send a real multipart request and files are sent" and "can send an empty string as + // the contents" are in tests/Integration/Saloon/Feature/Body/HasMultipartBodyTest.php. + + public function testCanSendMultipleMultipartFilesWithTheSameKeyName(): void + { + $connector = new TestConnector; + $request = new HasMultipartBodyRequest; + + $request->attach('nickname', 'Alfie', 'user.txt'); + $request->attach('nickname', 'Tom', 'user.txt'); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $body = $httpRequest->body(); + + $this->assertStringContainsString('X-Saloon: Yee-haw!', $body); + $this->assertStringContainsString('Content-Disposition: form-data; name="nickname"; filename="user.txt"', $body); + $this->assertStringContainsString('Sam', $body); + $this->assertStringContainsString('Alfie', $body); + $this->assertStringContainsString('Tom', $body); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } + + public function testAMultipartBodyReplacesAContentTypeWithoutABoundary(): void + { + $request = (new UserRequest) + ->withHeader('content-type', 'application/json') + ->attach('nickname', 'Sam'); + $snapshotContentType = null; + + // Request middleware runs before the body is prepared, so its PSR snapshot resolves the content type itself. + $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotContentType): void { + $snapshotContentType = $pendingRequest->toPsrRequest()->getHeader('Content-Type'); + }); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + $contentType = $pendingRequest->headers()['Content-Type']; + + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertSame([$contentType], $snapshotContentType); + $this->assertStringStartsWith( + '--' . substr($contentType, strlen('multipart/form-data; boundary=')) . "\r\n", + (string) $pendingRequest->preparedBody(), + ); + } + + #[DataProvider('contentTypesWithABoundary')] + public function testAContentTypeThatDeclaresABoundaryIsKept(mixed $contentType): void + { + $request = (new ExplicitBoundaryMultipartRequestStub)->withHeader('Content-Type', $contentType); + $snapshotContentType = null; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotContentType): void { + $snapshotContentType = $pendingRequest->toPsrRequest()->getHeaderLine('Content-Type'); + }); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + + $this->assertSame('multipart/related; boundary=saloon-boundary', $snapshotContentType); + $this->assertSame('multipart/related; boundary=saloon-boundary', $pendingRequest->toPsrRequest()->getHeaderLine('Content-Type')); + } + + /** + * Get the supported representations of a content type that declares a boundary. + * + * @return iterable + */ + public static function contentTypesWithABoundary(): iterable + { + yield 'string' => ['multipart/related; boundary=saloon-boundary']; + yield 'list' => [['multipart/related; boundary=saloon-boundary']]; + yield 'stringable' => [new Stringable('multipart/related; boundary=saloon-boundary')]; + } + + public function testAsMultipartKeepsTheExistingMultipartBody(): void + { + $request = (new ExplicitBoundaryMultipartRequestStub) + ->asMultipart() + ->attach('drink', 'Moonshine') + ->asMultipart(); + + $this->assertEquals([ + new MultipartValue('nickname', 'Sam'), + new MultipartValue('drink', 'Moonshine'), + ], $request->body()); + $this->assertSame( + 'multipart/form-data; boundary=saloon-boundary', + $this->pendingRequest(new TestConnector, $request)->headers()['Content-Type'], + ); + } + + public function testWithDataAppendsFieldsToAMultipartBody(): void + { + $request = (new ExplicitBoundaryMultipartRequestStub) + ->attach('file', 'Howdy', 'howdy.txt') + ->withData([ + 'nickname' => 'Alex', + 'active' => true, + 'inactive' => false, + 'nothing' => null, + 'roles' => ['admin', 'editor'], + 'profile' => ['city' => new Stringable('London')], + ]); + + $this->assertEquals([ + new MultipartValue('nickname', 'Sam'), + new MultipartValue('file', 'Howdy', 'howdy.txt'), + new MultipartValue('nickname', 'Alex'), + new MultipartValue('active', true), + new MultipartValue('inactive', false), + new MultipartValue('nothing', null), + new MultipartValue('roles', ['admin', 'editor']), + new MultipartValue('profile', ['city' => 'London']), + ], $request->body()); + + $pendingRequest = $this->pendingRequest(new TestConnector, $request); + $body = (string) $pendingRequest->preparedBody(); + + $this->assertSame('multipart/form-data; boundary=saloon-boundary', $pendingRequest->headers()['Content-Type']); + $this->assertStringStartsWith("--saloon-boundary\r\n", $body); + + $fields = [ + ['nickname', 'Sam'], + ['file', 'Howdy'], + ['nickname', 'Alex'], + ['active', '1'], + ['inactive', ''], + ['nothing', ''], + ['roles[0]', 'admin'], + ['roles[1]', 'editor'], + ['profile[city]', 'London'], + ]; + + foreach ($fields as [$name, $value]) { + $this->assertMatchesRegularExpression( + '/name="' . preg_quote($name, '/') . '"[^\r\n]*\r\n(?:[^\r\n]+\r\n)*\r\n' . preg_quote($value, '/') . '\r\n/', + $body, + ); + } + } + + public function testAPsrRequestSnapshotBeforeSendingKeepsAnAttachedResourceOpen(): void + { + $resource = fopen('php://memory', 'rw+'); + + try { + fwrite($resource, 'Howdy, Partner'); + rewind($resource); + + $request = (new UserRequest)->attach('file', $resource, 'howdy.txt'); + $snapshotBody = null; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotBody): void { + $snapshotBody = (string) $pendingRequest->toPsrRequest()->getBody(); + }); + + $body = (string) $this->pendingRequest(new TestConnector, $request)->preparedBody(); + + $this->assertStringContainsString("\r\n\r\nHowdy, Partner\r\n", $snapshotBody); + $this->assertStringContainsString("\r\n\r\nHowdy, Partner\r\n", $body); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + /** + * Send the request with a mock response and return its pending request. + */ + protected function pendingRequest(Connector $connector, Request $request): PendingRequest + { + return $connector->send($request, new MockClient([MockResponse::make()]))->pendingRequest(); + } +} + +class ExplicitBoundaryMultipartRequestStub extends Request +{ + /** + * Define the method that the request will use. + */ + protected Method $method = Method::POST; + + /** + * Define the endpoint for the request. + */ + public function resolveEndpoint(): string + { + return '/user'; + } + + /** + * Resolve the default body repository. + */ + protected function defaultBodyRepository(): BodyRepository + { + return new MultipartBodyRepository([new MultipartValue('nickname', 'Sam')], 'saloon-boundary'); + } +} diff --git a/tests/Saloon/Feature/Body/HasStreamBodyTest.php b/tests/Saloon/Feature/Body/HasStreamBodyTest.php new file mode 100644 index 0000000000..061fdb5256 --- /dev/null +++ b/tests/Saloon/Feature/Body/HasStreamBodyTest.php @@ -0,0 +1,82 @@ +assertIsResource($request->body()); + + // Stream bodies do not assume a content type. + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $this->assertFalse($pendingRequest->hasHeader('Content-Type')); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasStreamBodyRequest; + + $request->withHeader('Content-Type', 'application/custom'); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $this->assertSame(['application/custom'], $httpRequest->header('Content-Type')); + $this->assertSame('Howdy, Partner', $httpRequest->body()); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } + + public function testAPsrRequestSnapshotBeforeSendingKeepsAResourceBodyOpen(): void + { + $request = new HasStreamBodyRequest; + $snapshotBody = null; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$snapshotBody): void { + $snapshotBody = (string) $pendingRequest->toPsrRequest()->getBody(); + }); + + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $this->assertSame('Howdy, Partner', $snapshotBody); + $this->assertSame('Howdy, Partner', (string) $pendingRequest->preparedBody()); + } +} diff --git a/tests/Saloon/Feature/Body/HasStringBodyTest.php b/tests/Saloon/Feature/Body/HasStringBodyTest.php new file mode 100644 index 0000000000..a9b8c923b1 --- /dev/null +++ b/tests/Saloon/Feature/Body/HasStringBodyTest.php @@ -0,0 +1,64 @@ +assertSame('name: Sam', $request->body()); + + // String bodies do not assume a content type. + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $this->assertFalse($pendingRequest->hasHeader('Content-Type')); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasStringBodyRequest; + + $request->withHeader('Content-Type', 'application/custom'); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $this->assertSame(['application/custom'], $httpRequest->header('Content-Type')); + $this->assertSame('name: Sam', $httpRequest->body()); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } +} diff --git a/tests/Saloon/Feature/Body/HasXmlBodyTest.php b/tests/Saloon/Feature/Body/HasXmlBodyTest.php new file mode 100644 index 0000000000..57f74ffb97 --- /dev/null +++ b/tests/Saloon/Feature/Body/HasXmlBodyTest.php @@ -0,0 +1,66 @@ +assertSame('

Howdy

', $request->body()); + + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $this->assertSame('application/xml', $pendingRequest->headers()['Content-Type']); + } + + public function testTheGuzzleSenderProperlySendsIt(): void + { + $connector = new TestConnector; + $request = new HasXmlBodyRequest; + + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + $this->assertSame('application/xml', $pendingRequest->headers()['Content-Type']); + }); + + $asserted = false; + + Http::fake(function (HttpRequest $httpRequest) use (&$asserted): PromiseInterface { + $this->assertSame(['application/xml'], $httpRequest->header('Content-Type')); + $this->assertSame('

Howdy

', $httpRequest->body()); + + $asserted = true; + + return Http::response(); + }); + + $connector->send($request); + + $this->assertTrue($asserted); + } +} diff --git a/tests/Saloon/Feature/ConfigTest.php b/tests/Saloon/Feature/ConfigTest.php new file mode 100644 index 0000000000..05225caa8e --- /dev/null +++ b/tests/Saloon/Feature/ConfigTest.php @@ -0,0 +1,100 @@ +captureOptions(); + + (new TestConnector)->send(new UserRequest); + + // The timeouts come from the Saloon HTTP connection. Saloon builds its own request exceptions, so it always + // sends `http_errors` as false where upstream's Guzzle sender defaults it to true. + $this->assertFalse($options->value['http_errors']); + $this->assertSame(10, $options->value['connect_timeout']); + $this->assertSame(30, $options->value['timeout']); + } + + public function testYouCanPassAdditionalGuzzleConfigOptionsAndTheyAreMergedFromTheConnectorAndRequest(): void + { + $options = $this->captureOptions(); + + // Connectors are read-only, so connector options come from defaultOptions() instead of runtime changes. + $connector = new DebugOptionsConnectorStub; + + $request = new UserRequest; + + $request->withOptions(['verify' => false]); + + $connector->send($request); + + $this->assertFalse($options->value['http_errors']); + $this->assertSame(10, $options->value['connect_timeout']); + $this->assertSame(30, $options->value['timeout']); + $this->assertTrue($options->value['debug']); + $this->assertFalse($options->value['verify']); + } + + public function testRemovingARequestOptionRestoresTheHttpConnectionValue(): void + { + $options = $this->captureOptions(); + + (new TestConnector)->send((new UserRequest)->timeout(5)->withoutOptions('timeout')); + + $this->assertSame(30, $options->value['timeout']); + } + + /** + * Capture the transport options of the next faked request. + * + * @return object{value: array} + */ + protected function captureOptions(): object + { + $captured = new class { + public array $value = []; + }; + + Http::fake(function (HttpRequest $request, array $options) use ($captured): PromiseInterface { + $captured->value = $options; + + return Http::response(); + }); + + return $captured; + } +} + +class DebugOptionsConnectorStub extends TestConnector +{ + /** + * Resolve the default request options. + * + * @return array + */ + protected function defaultOptions(): array + { + return ['debug' => true]; + } +} diff --git a/tests/Saloon/Feature/DataObjectWrapperTest.php b/tests/Saloon/Feature/DataObjectWrapperTest.php new file mode 100644 index 0000000000..6b22ca35ef --- /dev/null +++ b/tests/Saloon/Feature/DataObjectWrapperTest.php @@ -0,0 +1,57 @@ + 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam']), + ]); + + $response = (new TestConnector)->send(new DTORequest, $mockClient); + $dto = $response->dto(); + + $this->assertInstanceOf(User::class, $dto); + $this->assertNotInstanceOf(WithResponse::class, $dto); + } + + public function testIfADtoImplementsTheWithResponseInterfaceAndHasResponseTraitSaloonWillAddTheOriginalResponse(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam']), + ]); + + $request = new DTOWithResponseRequest; + $response = (new TestConnector)->send($request, $mockClient); + + $dto = $response->dto(); + + $this->assertInstanceOf(UserWithResponse::class, $dto); + $this->assertInstanceOf(WithResponse::class, $dto); + $this->assertSame($response, $dto->getResponse()); + } +} diff --git a/tests/Saloon/Feature/DebugTest.php b/tests/Saloon/Feature/DebugTest.php new file mode 100644 index 0000000000..5e0c164d1a --- /dev/null +++ b/tests/Saloon/Feature/DebugTest.php @@ -0,0 +1,410 @@ + 'Sam']), + ]); + + $connectorRequestDebuggerValid = false; + $connectorResponseDebuggerValid = false; + + $requestClassRequestDebuggerValid = false; + $requestClassResponseDebuggerValid = false; + + // The connector can register callbacks to debug the request and the response + + $connector = new DebuggingConnectorStub( + onRequest: function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$connectorRequestDebuggerValid): void { + $connectorRequestDebuggerValid = true; + }, + onResponse: function (Response $response, ResponseInterface $psrResponse) use (&$connectorResponseDebuggerValid): void { + $connectorResponseDebuggerValid = true; + }, + ); + + $request = new UserRequest; + + // The request can register a callback to debug the request + + $request->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$requestClassRequestDebuggerValid): void { + $requestClassRequestDebuggerValid = true; + }); + + // The request can register a callback to debug the response + + $request->debugResponse(function (Response $response, ResponseInterface $psrResponse) use (&$requestClassResponseDebuggerValid): void { + $requestClassResponseDebuggerValid = true; + }); + + $connector->send($request, $mockClient); + + // Check these are all true + + $this->assertTrue($connectorRequestDebuggerValid); + $this->assertTrue($connectorResponseDebuggerValid); + $this->assertTrue($requestClassRequestDebuggerValid); + $this->assertTrue($requestClassResponseDebuggerValid); + } + + public function testTheResponseDebuggerIsAlwaysExecutedBeforeUserMiddleware(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam']), + ]); + + $middlewareOrder = []; + + $connector = new DebuggingConnectorStub( + onResponse: function () use (&$middlewareOrder): void { + $middlewareOrder[] = 'C'; + }, + responseMiddleware: function () use (&$middlewareOrder): void { + $middlewareOrder[] = 'A'; + }, + ); + $request = new UserRequest; + + $request->middleware()->onResponse(function () use (&$middlewareOrder): void { + $middlewareOrder[] = 'B'; + }); + + $request->debugResponse(function () use (&$middlewareOrder): void { + $middlewareOrder[] = 'D'; + }); + + $connector->send($request, $mockClient); + + // Even though the user has registered response middleware, the debugger should always come first. + $this->assertSame(['C', 'D', 'A', 'B'], $middlewareOrder); + } + + public function testTheResponseDebuggerIsAlwaysExecutedBeforeTheAlwaysThrowOnErrorsTrait(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sam'], 500), + ]); + + $middlewareCount = 0; + + $connector = new DebuggingConnectorStub(onResponse: function () use (&$middlewareCount): void { + ++$middlewareCount; + }); + $request = new AlwaysThrowRequest; + + $request->debugResponse(function () use (&$middlewareCount): void { + ++$middlewareCount; + }); + + try { + $connector->send($request, $mockClient); + + $this->fail('The request did not throw.'); + } catch (RequestException) { + $this->assertSame(2, $middlewareCount); + } + } + + public function testTheDefaultDebugRequestDriverWillDumpAnOutputUsingSymfonyVarDumper(): void + { + $output = $this->dump(fn (): Response => (new TestConnector)->send( + (new UserRequest)->debugRequest(), + new MockClient([new MockResponse(['name' => 'Sam'], 500)]), + )); + + $expected = <<<'END' + Saloon Request (UserRequest) -> array:6 [ + "connector" => "Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector" + "request" => "Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest" + "method" => "GET" + "uri" => "https://tests.saloon.dev/api/user" + "headers" => array:2 [ + "Host" => "tests.saloon.dev" + "Accept" => "application/json" + ] + "body" => "" + ] + END; + + $this->assertSame($expected . "\n", $output); + } + + public function testTheDefaultDebugResponseDriverWillDumpAnOutputUsingSymfonyVarDumper(): void + { + $output = $this->dump(fn (): Response => (new TestConnector)->send( + (new UserRequest)->debugResponse(), + new MockClient([new MockResponse(['name' => 'Sam'], 500)]), + )); + + $expected = <<<'END' + Saloon Response (UserRequest) -> array:3 [ + "status" => 500 + "headers" => [] + "body" => "{"name":"Sam"}" + ] + END; + + $this->assertSame($expected . "\n", $output); + } + + public function testTheDebugMethodWillOutputBothRequestAndResponseAtTheSameTime(): void + { + $output = $this->dump(fn (): Response => (new TestConnector)->send( + (new UserRequest)->debug(), + new MockClient([new MockResponse(['name' => 'Sam'], 500)]), + )); + + $expected = <<<'END' + Saloon Request (UserRequest) -> array:6 [ + "connector" => "Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector" + "request" => "Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest" + "method" => "GET" + "uri" => "https://tests.saloon.dev/api/user" + "headers" => array:2 [ + "Host" => "tests.saloon.dev" + "Accept" => "application/json" + ] + "body" => "" + ] + Saloon Response (UserRequest) -> array:3 [ + "status" => 500 + "headers" => [] + "body" => "{"name":"Sam"}" + ] + END; + + $this->assertSame($expected . "\n", $output); + } + + // Upstream replaces its die handler. Saloon calls exit, which throws Swoole's ExitException inside a coroutine. + public function testTheDebugMethodCanKillTheApplication(): void + { + try { + $this->dump(fn (): Response => (new TestConnector)->send( + (new UserRequest)->debug(die: true), + new MockClient([new MockResponse(['name' => 'Sam'], 500)]), + )); + + $this->fail('The application was not killed.'); + } catch (ExitException $exception) { + $this->assertSame(1, $exception->getStatus()); + } + } + + public function testTheResponseDebuggerReceivesAResponseWithFullBodyWhenTheStreamIsUnseekable(): void + { + $expectedBody = '{"name":"Jon"}'; + $mockClient = new MockClient([ + new UnseekableBodyMockResponse(['name' => 'Jon'], 200), + ]); + + $debuggerReceivedBody = null; + $debuggerBodyIsSeekable = null; + + $connector = new DebuggingConnectorStub( + onResponse: function (Response $response, ResponseInterface $psrResponse) use (&$debuggerReceivedBody, &$debuggerBodyIsSeekable): void { + $debuggerReceivedBody = $response->body(); + $debuggerBodyIsSeekable = $psrResponse->getBody()->isSeekable(); + }, + ); + + $response = $connector->send(new UserRequest, $mockClient); + + $this->assertSame($expectedBody, $debuggerReceivedBody); + $this->assertTrue($debuggerBodyIsSeekable); + $this->assertSame($expectedBody, $response->body()); + } + + public function testRequestDebuggerObservesTheFinalPsrRequestExactlyOnce(): void + { + $capturedRequest = null; + $request = (new DebugRequestStub) + ->withBody('request-body', null) + ->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$capturedRequest): void { + $capturedRequest = $psrRequest; + }); + $pendingRequest = (new DebugConnectorStub)->createPendingRequest($request) + ->finalizeUri() + ->prepareBody(); + + $pendingRequest->createPsrRequest(); + + $this->assertSame(1, DebugRequestStub::$psrHookCalls); + $this->assertInstanceOf(RequestInterface::class, $capturedRequest); + $this->assertSame('handled', $capturedRequest->getHeaderLine('X-Psr-Hook')); + $this->assertSame('request-body', (string) $capturedRequest->getBody()); + } + + public function testDefaultRequestDebuggerRestoresTheSeekableBodyPosition(): void + { + $body = Utils::streamFor('request-body'); + $body->seek(4); + + $this->assertSame('request-body', DebuggerReaderStub::readBody($body)); + + $this->assertSame(4, $body->tell()); + $this->assertSame('est-body', $body->getContents()); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + DebugRequestStub::$psrHookCalls = 0; + } + + /** + * Run the callback and return what it dumps. + */ + protected function dump(Closure $callback): string + { + $output = fopen('php://memory', 'rwb+'); + + VarDumper::setHandler(static function (mixed $var, ?string $label = null) use ($output): void { + $var = (new VarCloner)->cloneVar($var); + + if ($label !== null) { + $var = $var->withContext(['label' => $label]); + } + + (new CliDumper)->dump($var, $output); + }); + + try { + $callback(); + } finally { + VarDumper::setHandler(null); + } + + rewind($output); + + return stream_get_contents($output); + } + + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return [SaloonServiceProvider::class]; + } +} + +class DebuggingConnectorStub extends TestConnector +{ + /** + * Create a connector that registers debuggers for each operation. + */ + public function __construct( + protected readonly ?Closure $onRequest = null, + protected readonly ?Closure $onResponse = null, + protected readonly ?Closure $responseMiddleware = null, + ) { + parent::__construct(); + } + + /** + * Configure a pending request for this resource. + */ + public function boot(PendingRequest $pendingRequest): void + { + if ($this->onRequest !== null) { + $pendingRequest->debugRequest($this->onRequest); + } + + if ($this->onResponse !== null) { + $pendingRequest->debugResponse($this->onResponse); + } + + if ($this->responseMiddleware !== null) { + $pendingRequest->middleware()->onResponse($this->responseMiddleware); + } + } +} + +class DebugConnectorStub extends Connector +{ + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } +} + +class DebugRequestStub extends Request +{ + use HasStringBody; + + public static int $psrHookCalls = 0; + + protected Method $method = Method::POST; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } + + /** + * Modify the final PSR request. + */ + public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface + { + ++static::$psrHookCalls; + + return $request->withHeader('X-Psr-Hook', 'handled'); + } +} + +class DebuggerReaderStub extends Debugger +{ + /** + * Read a request body through the debugger. + */ + public static function readBody(StreamInterface $body): string + { + return static::requestBody($body); + } +} diff --git a/tests/Saloon/Feature/DelayRequestTest.php b/tests/Saloon/Feature/DelayRequestTest.php new file mode 100644 index 0000000000..6b2f12804c --- /dev/null +++ b/tests/Saloon/Feature/DelayRequestTest.php @@ -0,0 +1,264 @@ +delay(100); + + $this->assertSame(100, $request->delayMilliseconds()); + + (new TestConnector)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])])); + (new DelayConnectorStub)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])])); + + Sleep::assertSequence([ + Sleep::for(100)->milliseconds(), + Sleep::for(100)->milliseconds(), + ]); + } + + // Upstream's custom sleep handler case is the same assertion: Sleep::fake() records the delay instead of waiting. + public function testRequestDelayWorks(): void + { + Sleep::fake(); + Http::fake(['*' => Factory::response(['name' => 'Sam'])]); + + $request = (new UserRequest)->delay(1000); + + $this->assertSame(1000, $request->delayMilliseconds()); + + (new TestConnector)->send($request); + (new TestConnector)->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])])); + + Sleep::assertSequence([ + Sleep::for(1000)->milliseconds(), + Sleep::for(1000)->milliseconds(), + ]); + } + + public function testConnectorDelayWorks(): void + { + Sleep::fake(); + Http::fake(['*' => Factory::response(['name' => 'Sam'])]); + + $request = new UserRequest; + $connector = new DelayConnectorStub; + + $this->assertNull($request->delayMilliseconds()); + $this->assertSame(500, $connector->delayMilliseconds()); + + $connector->send($request, new MockClient([MockResponse::make(['name' => 'Sam'])])); + $connector->send($request); + + Sleep::assertSequence([ + Sleep::for(500)->milliseconds(), + Sleep::for(500)->milliseconds(), + ]); + } + + public function testTheDelayRunsBeforeAFakeResponseThatThrows(): void + { + Sleep::fake(); + $exception = new Exception('The fake failed.'); + + try { + (new TestConnector)->send( + (new UserRequest)->delay(100), + new MockClient([MockResponse::make()->throw($exception)]), + ); + + $this->fail('The request did not throw.'); + } catch (Exception $caught) { + $this->assertSame($exception, $caught); + } + + Sleep::assertSequence([Sleep::for(100)->milliseconds()]); + } + + public function testTheDelayRunsBeforeEveryAttemptSeparatelyFromTheRetryInterval(): void + { + Sleep::fake(); + + (new TestConnector)->send((new UserRequest)->delay(100)->retry(2, 300), new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth']), + ])); + + Sleep::assertSequence([ + Sleep::for(100)->milliseconds(), + Sleep::for(300)->milliseconds(), + Sleep::for(100)->milliseconds(), + ]); + } + + public function testCacheHitsAreNotDelayed(): void + { + Sleep::fake(); + Http::fake(['*' => Factory::response(['name' => 'Sam'])]); + + $first = (new TestConnector)->send((new CachedDelayRequestStub)->delay(100)); + $second = (new TestConnector)->send((new CachedDelayRequestStub)->delay(100)); + + $this->assertFalse($first->isCached()); + $this->assertTrue($second->isCached()); + Sleep::assertSequence([Sleep::for(100)->milliseconds()]); + } + + public function testTheDelayIsEmptyByDefault(): void + { + $this->assertNull($this->request()->delayMilliseconds()); + } + + public function testItStoresARepresentableDelay(): void + { + $request = $this->request()->delay(250); + + $this->assertSame(250, $request->delayMilliseconds()); + } + + public function testTheDefaultDelayIsUsedUntilADelayIsSet(): void + { + $request = new DefaultDelayRequestStub; + + $this->assertSame(100, $request->delayMilliseconds()); + $this->assertSame(0, $request->delay(0)->delayMilliseconds()); + } + + public function testAnExplicitZeroDelayOverridesTheConnectorDelay(): void + { + $this->assertSame(500, $this->pendingRequest(new DelayRequestStub)->delayMilliseconds()); + $this->assertSame(0, $this->pendingRequest((new DelayRequestStub)->delay(0))->delayMilliseconds()); + } + + public function testItRejectsNegativeDelays(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('representable non-negative'); + + $this->request()->delay(-1); + } + + public function testItRejectsDelaysThatOverflowMicroseconds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('representable non-negative'); + + $this->request()->delay(intdiv(PHP_INT_MAX, 1000) + 1); + } + + /** + * Create an object with an operation-owned request delay. + */ + protected function request(): object + { + return new class { + use HasDelay; + }; + } + + /** + * Create a pending request through a connector with a default delay. + */ + protected function pendingRequest(Request $request): PendingRequest + { + return new PendingRequest(new DelayConnectorStub, $request); + } +} + +class DelayConnectorStub extends Connector +{ + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } + + /** + * Resolve the default request delay in milliseconds. + */ + protected function defaultDelay(): ?int + { + return 500; + } +} + +class DelayRequestStub extends Request +{ + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } +} + +class DefaultDelayRequestStub extends DelayRequestStub +{ + /** + * Resolve the default request delay in milliseconds. + */ + protected function defaultDelay(): ?int + { + return 100; + } +} + +class CachedDelayRequestStub extends UserRequest implements Cacheable +{ + use HasCaching; + + /** + * Get the cache lifetime. + */ + public function cacheFor(): DateInterval|DateTimeInterface|int + { + return 60; + } +} diff --git a/tests/Saloon/Feature/FixturePathTraversalTest.php b/tests/Saloon/Feature/FixturePathTraversalTest.php new file mode 100644 index 0000000000..3e6630ddba --- /dev/null +++ b/tests/Saloon/Feature/FixturePathTraversalTest.php @@ -0,0 +1,102 @@ +files = new Filesystem; + $this->directory = ParallelTesting::tempDir('SaloonFixturePathTraversalTest'); + $this->fixtureBaseDir = $this->directory . '/fixtures'; + $this->files->deleteDirectory($this->directory); + $this->files->ensureDirectoryExists($this->fixtureBaseDir); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->files->deleteDirectory($this->directory); + + parent::tearDown(); + } + + public function testFixtureNameWithPathTraversalThrowsWhenGettingMockResponseAndDoesNotReadOutsideBase(): void + { + Saloon::fixturePath($this->fixtureBaseDir); + + $externalPath = $this->directory . '/traversal_read_target.json'; + $secretContent = 'read_from_outside'; + file_put_contents($externalPath, json_encode([ + 'statusCode' => 200, + 'headers' => [], + 'data' => '{"secret":"' . $secretContent . '"}', + 'context' => [], + ])); + + $fixture = new Fixture('../traversal_read_target', $this->files); + + try { + $fixture->getMockResponse(); + $this->fail('A traversing fixture name was accepted.'); + } catch (FixtureException $exception) { + $this->assertSame('Fixture names must contain only portable path segments separated by forward slashes.', $exception->getMessage()); + } + + $this->assertStringContainsString($secretContent, file_get_contents($externalPath)); + } + + public function testFixtureNameWithPathTraversalThrowsWhenStoringAndDoesNotWriteOutsideBase(): void + { + Saloon::fixturePath($this->fixtureBaseDir); + + $fixture = new Fixture('../traversal_write_test', $this->files); + + $recordedResponse = new RecordedResponse(200, [], '{"pwned":true}'); + + try { + $fixture->store($recordedResponse); + $this->fail('A traversing fixture name was accepted.'); + } catch (FixtureException $exception) { + $this->assertSame('Fixture names must contain only portable path segments separated by forward slashes.', $exception->getMessage()); + } + + $this->assertFileDoesNotExist($this->directory . '/traversal_write_test.json'); + } +} diff --git a/tests/Saloon/Feature/GuzzleSenderTest.php b/tests/Saloon/Feature/GuzzleSenderTest.php new file mode 100644 index 0000000000..c3e82db9c2 --- /dev/null +++ b/tests/Saloon/Feature/GuzzleSenderTest.php @@ -0,0 +1,115 @@ +createPendingRequest($request); + + $sent = null; + Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface { + $sent = $request->toPsrRequest(); + + return Http::response(); + }); + + $connector->send($request); + + $this->assertSame($pendingRequest->method()->value, $sent->getMethod()); + $this->assertSame((string) $pendingRequest->uri(), (string) $sent->getUri()); + } + + public function testTheGuzzleSenderWillSendAllHeadersQueryParametersAndConfig(): void + { + $request = (new UserRequest) + ->withOptions(['timeout' => 120, 'debug' => true]) + ->withQueryParameters(['shanty' => 'yes', 'sing' => 'yes']) + ->withHeader('X-Bound-For', 'South-Australia') + ->withHeader('X-Fancy', ['keyOne' => 'valOne', 'keyTwo' => 'valTwo']); + + $sent = null; + $sentOptions = null; + Http::fake(function (HttpRequest $request, array $options) use (&$sent, &$sentOptions): PromiseInterface { + $sent = $request->toPsrRequest(); + $sentOptions = $options; + + return Http::response(); + }); + + (new TestConnector)->send($request); + + $this->assertSame(120, $sentOptions['timeout']); + $this->assertTrue($sentOptions['debug']); + $this->assertSame('shanty=yes&sing=yes', $sent->getUri()->getQuery()); + $this->assertSame('South-Australia', $sent->getHeaderLine('X-Bound-For')); + $this->assertSame('valOne, valTwo', $sent->getHeaderLine('X-Fancy')); + } + + // REMOVED: the default handler stack cases. Registered HTTP connections own the transport handler; Guzzle-level + // hooks are Http::globalMiddleware(), which the next case covers, and Saloon's PSR request hooks and middleware. + + public function testHttpGlobalMiddlewareRunsOnSaloonRequests(): void + { + Http::globalRequestMiddleware( + fn (RequestInterface $request): RequestInterface => $request->withHeader('X-Global', 'applied'), + ); + + $sent = null; + Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface { + $sent = $request->toPsrRequest(); + + return Http::response(); + }); + + (new TestConnector)->send(new UserRequest); + + $this->assertSame('applied', $sent->getHeaderLine('X-Global')); + } + + // Saloon decides when a response has failed, so the HTTP client never throws for an error status. + public function testTheGuzzleSenderHasDefaultOptionsConfigured(): void + { + $sentOptions = null; + Http::fake(function (HttpRequest $request, array $options) use (&$sentOptions): PromiseInterface { + $sentOptions = $options; + + return Http::response(); + }); + + (new TestConnector)->send(new UserRequest); + + $this->assertSame(10, $sentOptions['connect_timeout']); + $this->assertSame(30, $sentOptions['timeout']); + $this->assertFalse($sentOptions['http_errors']); + $this->assertSame(STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, $sentOptions['crypto_method']); + } + + // REMOVED: the custom handler stack case; see above. +} diff --git a/tests/Saloon/Feature/MockRequestTest.php b/tests/Saloon/Feature/MockRequestTest.php new file mode 100644 index 0000000000..44a1a420da --- /dev/null +++ b/tests/Saloon/Feature/MockRequestTest.php @@ -0,0 +1,855 @@ +files = new Filesystem; + $this->fixturePath = ParallelTesting::tempDir('SaloonMockRequestTest'); + $this->files->deleteDirectory($this->fixturePath); + $this->files->ensureDirectoryExists($this->fixturePath); + + Saloon::fixturePath($this->fixturePath); + + Http::fake([ + 'tests.saloon.dev/api/user' => Http::response( + ['name' => 'Sammyjo20', 'actual_name' => 'Sam', 'twitter' => '@carre_sam'], + 200, + ['Server' => 'cloudflare', 'Cache-Control' => 'no-cache, private'], + ), + 'tests.saloon.dev/api/error' => Http::response(['message' => 'Fake Error'], 500), + 'tests.saloon.dev/api/download-test-pdf' => Http::response(self::FILE_CONTENTS, 200, ['Content-Type' => 'application/pdf']), + 'tests.saloon.dev/api/superheroes/per-page' => Http::response([ + 'data' => [ + ['superhero' => 'Batman', 'publisher' => 'DC Comics'], + ['superhero' => 'Superman', 'publisher' => 'DC Comics'], + ], + ]), + ]); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->files->deleteDirectory($this->fixturePath); + + parent::tearDown(); + } + + public function testARequestCanBeMockedWithASequence(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam'], 200, ['X-Foo' => 'Bar']), + MockResponse::make(['name' => 'Alex']), + MockResponse::make(['error' => 'Server Unavailable'], 500), + ]); + + $connector = new TestConnector; + + $responseA = $connector->send(new UserRequest); + + $this->assertInstanceOf(Response::class, $responseA); + $this->assertTrue($responseA->isMocked()); + $this->assertTrue($responseA->isFaked()); + $this->assertFalse($responseA->isCached()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + $this->assertInstanceOf(MockResponse::class, $responseA->fakeResponse()); + $this->assertSame(['X-Foo' => ['Bar']], $responseA->headers()); + + $responseB = $connector->send(new UserRequest); + + $this->assertInstanceOf(Response::class, $responseB); + $this->assertTrue($responseB->isMocked()); + $this->assertTrue($responseB->isFaked()); + $this->assertFalse($responseB->isCached()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + $this->assertInstanceOf(MockResponse::class, $responseB->fakeResponse()); + + $responseC = $connector->send(new UserRequest); + + $this->assertInstanceOf(Response::class, $responseC); + $this->assertTrue($responseC->isMocked()); + $this->assertTrue($responseC->isFaked()); + $this->assertFalse($responseC->isCached()); + $this->assertSame(['error' => 'Server Unavailable'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + $this->assertInstanceOf(MockResponse::class, $responseC->fakeResponse()); + + $this->expectException(NoMockResponseFoundException::class); + $this->expectExceptionMessageIs('Saloon was unable to guess a mock response for your request [https://tests.saloon.dev/api/user], consider using a wildcard url mock or a connector mock.'); + + $connector->send(new UserRequest); + } + + public function testARequestCanBeMockedWithAConnectorDefined(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $connectorARequest = new UserRequest; + $connectorBRequest = new QueryParameterConnectorRequest; + + $mockClient = new MockClient([ + TestConnector::class => $responseA, + QueryParameterConnector::class => $responseB, + ]); + + $responseA = $connectorA->send($connectorARequest, $mockClient); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorB->send($connectorBRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + } + + public function testARequestCanBeMockedWithARequestDefined(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $requestA = new UserRequest; + $requestB = new QueryParameterConnectorRequest; + + $mockClient = new MockClient([ + UserRequest::class => $responseA, + QueryParameterConnectorRequest::class => $responseB, + ]); + + $responseA = $connectorA->send($requestA, $mockClient); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorB->send($requestB, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + } + + public function testARequestCanBeMockedWithAUrlDefined(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + $responseC = MockResponse::make(['error' => 'Server Broken'], 500); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + 'google.com/*' => $responseC, // Test Different Route, + ]); + + $responseA = $connectorA->send($requestA, $mockClient); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorA->send($requestB, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $responseC = $connectorB->send($requestC, $mockClient); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(['error' => 'Server Broken'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + } + + public function testYouCanCreateWildcardUrlMocks(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + $responseC = MockResponse::make(['error' => 'Server Broken'], 500); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + '*' => $responseC, + ]); + + $responseA = $connectorA->send($requestA, $mockClient); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorA->send($requestB, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $responseC = $connectorB->send($requestC, $mockClient); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(['error' => 'Server Broken'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + } + + public function testYouCanUseAClosureForTheMockResponse(): void + { + $sequenceMock = new MockClient([ + function (PendingRequest $pendingRequest): MockResponse { + return new MockResponse(['request' => (string) $pendingRequest->uri()]); + }, + ]); + + $sequenceResponse = (new TestConnector)->send(new UserRequest, $sequenceMock); + + $this->assertTrue($sequenceResponse->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $sequenceResponse->json()); + + // Connector mock + + $connectorMock = new MockClient([ + TestConnector::class => function (PendingRequest $pendingRequest): MockResponse { + return new MockResponse(['request' => (string) $pendingRequest->uri()]); + }, + ]); + + $connectorResponse = (new TestConnector)->send(new UserRequest, $connectorMock); + + $this->assertTrue($connectorResponse->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $connectorResponse->json()); + + // Request mock + + $requestMock = new MockClient([ + UserRequest::class => function (PendingRequest $pendingRequest): MockResponse { + return new MockResponse(['request' => (string) $pendingRequest->uri()]); + }, + ]); + + $requestResponse = (new TestConnector)->send(new UserRequest, $requestMock); + + $this->assertTrue($requestResponse->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $requestResponse->json()); + + // URL mock + + $urlMock = new MockClient([ + 'tests.saloon.dev/*' => function (PendingRequest $pendingRequest): MockResponse { + return new MockResponse(['request' => (string) $pendingRequest->uri()]); + }, + ]); + + $urlResponse = (new TestConnector)->send(new UserRequest, $urlMock); + + $this->assertTrue($urlResponse->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $urlResponse->json()); + } + + public function testYouCanUseACallableClassAsTheMockResponse(): void + { + $mockClient = new MockClient([ + UserRequest::class => new CallableMockResponse, + ]); + + $sequenceResponse = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($sequenceResponse->isMocked()); + $this->assertSame(['request_class' => UserRequest::class], $sequenceResponse->json()); + } + + public function testAFixtureCanBeUsedWithAMockSequence(): void + { + $mockClient = new MockClient([ + MockResponse::fixture('user'), + MockResponse::fixture('user'), + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + } + + public function testAFixtureCanBeUsedWithAConnectorMock(): void + { + $mockClient = new MockClient([ + TestConnector::class => MockResponse::fixture('connector'), + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + + // Even though it's a different request, it should use the same fixture + + $responseC = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(200, $responseC->status()); + $this->assertSame($this->user(), $responseC->json()); + } + + public function testAFixtureCanBeUsedWithARequestMock(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::fixture('user'), + ]); + + $this->assertFalse($this->files->exists($this->fixturePath . '/user.json')); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $this->assertTrue($this->files->exists($this->fixturePath . '/user.json')); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + + Http::assertSentCount(1); + $mockClient->assertSentCount(2); + } + + public function testAFixtureCanBeUsedWithAUrlMock(): void + { + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => MockResponse::fixture('user'), // Test Exact Route + 'tests.saloon.dev/*' => MockResponse::fixture('other'), // Test Wildcard Routes + ]); + + $this->assertFalse($this->files->exists($this->fixturePath . '/user.json')); + $this->assertFalse($this->files->exists($this->fixturePath . '/other.json')); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($this->files->exists($this->fixturePath . '/user.json')); + $this->assertFalse($this->files->exists($this->fixturePath . '/other.json')); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertTrue($this->files->exists($this->fixturePath . '/user.json')); + $this->assertTrue($this->files->exists($this->fixturePath . '/other.json')); + + $this->assertFalse($responseB->isMocked()); + $this->assertSame(500, $responseB->status()); + $this->assertSame(['message' => 'Fake Error'], $responseB->json()); + + // This should use the first mock + + $responseC = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(200, $responseC->status()); + $this->assertSame($this->user(), $responseC->json()); + + // Another error request should use the "other" mock + + $responseD = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertTrue($responseD->isMocked()); + $this->assertSame(500, $responseD->status()); + $this->assertSame(['message' => 'Fake Error'], $responseD->json()); + } + + public function testAFixtureCanBeUsedWithAWildcardUrlMock(): void + { + $mockClient = new MockClient([ + '*' => MockResponse::fixture('user'), // Test Exact Route + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + } + + public function testAFixtureCanBeUsedWithinAClosureMock(): void + { + $mockClient = new MockClient([ + '*' => function (PendingRequest $pendingRequest): Fixture { + if ($pendingRequest->request() instanceof UserRequest) { + return MockResponse::fixture('user'); + } + + return MockResponse::fixture('other'); + }, + ]); + + $this->assertFalse($this->files->exists($this->fixturePath . '/user.json')); + $this->assertFalse($this->files->exists($this->fixturePath . '/other.json')); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + + // Now we'll test a different route + + $responseC = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertFalse($responseC->isMocked()); + $this->assertSame(500, $responseC->status()); + $this->assertSame(['message' => 'Fake Error'], $responseC->json()); + + // Another error request should use the "other" mock + + $responseD = (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->assertTrue($responseD->isMocked()); + $this->assertSame(500, $responseD->status()); + $this->assertSame(['message' => 'Fake Error'], $responseD->json()); + } + + public function testWhenUsingTheAlwaysThrowRequestTraitTheResponseRecorderWillStillRecordTheResponse(): void + { + $mockClient = new MockClient([ + AlwaysThrowRequest::class => MockResponse::fixture('error'), + ]); + + $exception = null; + + try { + (new TestConnector)->send(new AlwaysThrowRequest, $mockClient); + } catch (Exception $exception) { + } + + $this->assertInstanceOf(RequestException::class, $exception); + + $fixture = MockResponse::fixture('error')->getMockResponse(); + + $this->assertInstanceOf(MockResponse::class, $fixture); + } + + public function testAFixtureCanRecordTheFileDataFromARequestThatReturnsAFileDownload(): void + { + $mockClient = new MockClient([ + FileDownloadRequest::class => MockResponse::fixture('file'), + ]); + + $requestA = new FileDownloadRequest; + $responseA = (new TestConnector)->send($requestA, $mockClient); + + $this->assertSame(self::FILE_CONTENTS, $responseA->body()); + + $requestB = new FileDownloadRequest; + $responseB = (new TestConnector)->send($requestB, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(self::FILE_CONTENTS, $responseB->body()); + } + + public function testYouCanCreateACustomFixtureClass(): void + { + $mockClient = new MockClient([ + new UserFixture, + new UserFixture, + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->isMocked()); + $this->assertSame(200, $responseA->status()); + $this->assertSame($this->user(), $responseA->json()); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(200, $responseB->status()); + $this->assertSame($this->user(), $responseB->json()); + } + + public function testItWillThrowAnExceptionIfTheCustomFixtureClassIsMissingAName(): void + { + $mockClient = new MockClient([ + new MissingNameFixture, + ]); + + $this->expectException(FixtureException::class); + $this->expectExceptionMessageIs('The fixture must have a name.'); + + (new TestConnector)->send(new UserRequest, $mockClient); + } + + public function testYouCanHideSensitiveJsonBodyParametersAndHeadersBeforeTheFixtureIsStored(): void + { + $mockClient = new MockClient([ + new SafeUserFixture, + new SafeUserFixture, + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame($this->user(), $responseA->json()); + + $this->assertSame('cloudflare', $responseA->header('Server')); + $this->assertSame('no-cache, private', $responseA->header('Cache-Control')); + + $this->assertFalse($responseA->isFaked()); + $this->assertTrue($responseB->isFaked()); + + $this->assertSame([ + 'name' => 'Sxxx', + 'actual_name' => 'REDACTED', + 'twitter' => '@saloonphp', + ], $responseB->json()); + + $this->assertSame('secret', $responseB->header('Server')); + $this->assertSame('no-cache, private, yeehaw', $responseB->header('Cache-Control')); + + $fixtureData = json_decode($this->files->get($this->fixturePath . '/user.json'), true, 512, JSON_THROW_ON_ERROR); + + // Recorded fixtures list every header value. + $this->assertSame(['secret'], $fixtureData['headers']['Server']); + $this->assertSame(['no-cache, private, yeehaw'], $fixtureData['headers']['Cache-Control']); + $this->assertSame(json_encode([ + 'name' => 'Sxxx', + 'actual_name' => 'REDACTED', + 'twitter' => '@saloonphp', + ], JSON_THROW_ON_ERROR), $fixtureData['data']); + } + + public function testTheFixtureSwapToolWorksOnMultipleAttemptsAndRecursively(): void + { + $mockClient = new MockClient([ + new SuperheroFixture, + new SuperheroFixture, + ]); + + $responseA = (new TestConnector)->send(new PagedSuperheroRequest, $mockClient); + $responseB = (new TestConnector)->send(new PagedSuperheroRequest, $mockClient); + + foreach ($responseA->json()['data'] as $superhero) { + $this->assertSame('DC Comics', $superhero['publisher']); + } + + foreach ($responseB->json()['data'] as $superhero) { + $this->assertSame('REDACTED', $superhero['publisher']); + } + } + + public function testYouCanDefineACustomRedactionMethodForNonJsonBodyFixtures(): void + { + $mockClient = new MockClient([ + new BeforeSaveUserFixture, + new BeforeSaveUserFixture, + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(200, $responseA->status()); + $this->assertSame(222, $responseB->status()); + } + + public function testYouCanDefineRegexPatternsThatShouldBeUsedToReplaceTheBodyInFixtures(): void + { + $mockClient = new MockClient([ + new RegexUserFixture, + new RegexUserFixture, + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame($this->user(), $responseA->json()); + + $this->assertFalse($responseA->isFaked()); + $this->assertTrue($responseB->isFaked()); + + $this->assertSame([ + 'name' => 'Sxxxmyjo20', + 'actual_name' => 'Sxxx', + 'twitter' => '**REDACTED-TWITTER**', + ], $responseB->json()); + + $fixtureData = json_decode($this->files->get($this->fixturePath . '/user.json'), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame(json_encode([ + 'name' => 'Sxxxmyjo20', + 'actual_name' => 'Sxxx', + 'twitter' => '**REDACTED-TWITTER**', + ], JSON_THROW_ON_ERROR), $fixtureData['data']); + } + + public function testRequestAndResponseMiddlewareIsInvokedWhenUsingFakeResponses(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam'], 200, ['X-Foo' => 'Bar']), + MockResponse::make(['name' => 'Alex']), + MockResponse::make(['error' => 'Server Unavailable'], 500), + ]); + + $middlewareA = false; + $middlewareB = false; + $middlewareC = false; + $middlewareD = false; + + $connector = new MiddlewareTestConnector( + function () use (&$middlewareA): void { + $middlewareA = true; + }, + function () use (&$middlewareB): void { + $middlewareB = true; + }, + ); + + $request = new UserRequest; + + $request->middleware()->onRequest(function () use (&$middlewareC): void { + $middlewareC = true; + }); + + $request->middleware()->onResponse(function () use (&$middlewareD): void { + $middlewareD = true; + }); + + $connector->send($request); + + $this->assertTrue($middlewareA); + $this->assertTrue($middlewareB); + $this->assertTrue($middlewareC); + $this->assertTrue($middlewareD); + } + + public function testFixturesAreStillRecordedOnTheFirstRequest(): void + { + $mockClient = new MockClient([ + MockResponse::fixture('user'), // Test Exact Route + ]); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $mockClient->assertSent(UserRequest::class); + } + + public function testFixturesCanBeCreatedInSubDirectories(): void + { + $mockClient = new MockClient([ + MockResponse::fixture('my-integration/user'), // Test Exact Route + ]); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $mockClient->assertSent(UserRequest::class); + $this->assertTrue($this->files->exists($this->fixturePath . '/my-integration/user.json')); + } + + public function testWithoutCacheRecordsAMissingFixtureFromTheTransport(): void + { + $version = 0; + Http::fake(['tests.saloon.dev/api/cached-user' => function () use (&$version): PromiseInterface { + return Http::response(['version' => ++$version]); + }]); + + $this->assertSame(['version' => 1], (new TestConnector)->send(new WithoutCacheUserRequest)->json()); + + $mockClient = (new MockClient([ + WithoutCacheUserRequest::class => MockResponse::fixture('cached-user'), + ]))->withoutCache(); + + $recorded = (new TestConnector)->send(new WithoutCacheUserRequest, $mockClient); + + $this->assertFalse($recorded->isCached()); + $this->assertSame(['version' => 2], $recorded->json()); + $this->assertSame( + '{"version":2}', + RecordedResponse::fromFile($this->files->get($this->fixturePath . '/cached-user.json'))->data, + ); + + $cached = (new TestConnector)->send(new WithoutCacheUserRequest); + + $this->assertTrue($cached->isCached()); + $this->assertSame(['version' => 1], $cached->json()); + } + + /** + * Get the user returned by the test API. + * + * @return array + */ + protected function user(): array + { + return [ + 'name' => 'Sammyjo20', + 'actual_name' => 'Sam', + 'twitter' => '@carre_sam', + ]; + } +} + +class MiddlewareTestConnector extends TestConnector +{ + /** + * Create a connector that registers the given middleware. + */ + public function __construct( + protected Closure $onRequest, + protected Closure $onResponse, + ) { + parent::__construct(); + } + + /** + * Register the connector middleware. + */ + public function boot(PendingRequest $pendingRequest): void + { + $pendingRequest->middleware()->onRequest($this->onRequest); + $pendingRequest->middleware()->onResponse($this->onResponse); + } +} + +class WithoutCacheUserRequest extends UserRequest implements Cacheable +{ + use HasCaching; + + /** + * Define the endpoint for the request. + */ + public function resolveEndpoint(): string + { + return '/cached-user'; + } + + /** + * Get the cache lifetime. + */ + public function cacheFor(): int + { + return 60; + } +} diff --git a/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php b/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php new file mode 100644 index 0000000000..881f48f25e --- /dev/null +++ b/tests/Saloon/Feature/Oauth2/AbsoluteOAuthEndpointTest.php @@ -0,0 +1,99 @@ +oauthConfig()); + + $pending = $connector->createPendingRequest($request); + + $this->assertSame('https://auth.external.com/oauth/token', (string) $pending->uri()); + } + + public function testOAuthClientCredentialsThrowsWhenTokenEndpointIsAbsoluteAndOAuthConfigDoesNotAllowOverride(): void + { + $connector = new AbsoluteTokenOAuthConnectorWithoutAllow; + $request = new GetClientCredentialsTokenRequest($connector->oauthConfig()); + + $this->expectException(PendingRequestException::class); + + $connector->createPendingRequest($request)->uri(); + } + + public function testRequestAllowBaseUrlOverrideFalseOverridesOAuthConfigAllowForTokenUrl(): void + { + $connector = new AbsoluteTokenOAuthConnector; + $request = new class($connector->oauthConfig()) extends GetClientCredentialsTokenRequest { + /** + * Resolve whether the trusted OAuth endpoint may replace the connector base URL. + */ + public function allowsBaseUrlOverride(): bool + { + return false; + } + }; + + $this->expectException(PendingRequestException::class); + + $connector->createPendingRequest($request)->uri(); + } + + public function testGetAuthorizationUrlWorksWithAbsoluteAuthorizeEndpointWhenOAuthConfigAllowsOverride(): void + { + $connector = new AuthorizeUrlOAuthConnector; + $url = (string) $connector->authorizationUrl(); + + $this->assertStringStartsWith('https://login.provider.com/authorize', $url); + } + + public function testGetAuthorizationUrlWorksWithAbsoluteAuthorizeWhenConnectorAllowsOverrideOnly(): void + { + $connector = new AuthorizeUrlOAuthConnectorWithConnectorAllowOnly; + $url = (string) $connector->authorizationUrl(); + + $this->assertStringStartsWith('https://login.provider.com/authorize', $url); + } + + public function testTheUserRequestUsesAnAbsoluteUserUrlWhenOAuthConfigAllowsBaseOverride(): void + { + $config = new OAuthConfig( + 'client', + 'secret', + userEndpoint: 'https://oauth.example.net/user', + allowBaseUrlOverride: true, + ); + + $pending = (new TestConnector)->createPendingRequest(new GetUserRequest($config)); + + $this->assertSame('https://oauth.example.net/user', (string) $pending->uri()); + } +} diff --git a/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php new file mode 100644 index 0000000000..7da2cfb3a8 --- /dev/null +++ b/tests/Saloon/Feature/Oauth2/AuthCodeFlowConnectorTest.php @@ -0,0 +1,737 @@ +authorizationUrl(['scope-1', 'scope-2'], 'my-state'); + + $this->assertSame('my-state', $authorization->state); + + $this->assertSame( + 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state', + (string) $authorization, + ); + } + + public function testYouCanProvideDefaultScopesThatWillBeAppliedToEveryAuthorizationUrl(): void + { + $connector = new OAuth2Connector(['defaultScopes' => ['scope-3']]); + + $url = (string) $connector->authorizationUrl(['scope-1', 'scope-2'], 'my-state'); + + $this->assertSame( + 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-3%20scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state', + $url, + ); + } + + public function testYouCanGetAuthorizationUrlWithoutSettingValidScopes(): void + { + $connector = new OAuth2Connector(['defaultScopes' => ['', null]]); + + $url = (string) $connector->authorizationUrl([], 'my-state'); + + $this->assertSame( + 'https://oauth.saloon.dev/authorize?response_type=code&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state', + $url, + ); + } + + public function testDefaultStateIsGeneratedAutomaticallyWithEveryAuthorizationUrlIfStateIsNotDefined(): void + { + $connector = new OAuth2Connector(['defaultScopes' => ['scope-3']]); + + $authorization = $connector->authorizationUrl(['scope-1', 'scope-2']); + $state = $authorization->state; + + $this->assertNotSame('', $state); + + $this->assertStringEndsWith($state, (string) $authorization); + } + + public function testAdditionalQueryParametersCanBeAddedPassedToTheAuthorizationUrl(): void + { + $connector = new OAuth2Connector; + + $url = (string) $connector->authorizationUrl( + ['scope-1', 'scope-2'], + state: 'my-state', + additionalQueryParameters: ['another-param' => 'another-value', 'yee' => 'haw'] + ); + + $this->assertStringEndsWith('another-param=another-value&yee=haw', $url); + $this->assertSame( + 'https://oauth.saloon.dev/authorize?response_type=code&scope=scope-1%20scope-2&client_id=client-id&redirect_uri=https%3A%2F%2Fmy-app.saloon.dev%2Fauth%2Fcallback&state=my-state&another-param=another-value&yee=haw', + $url, + ); + } + + #[DataProvider('codeChallenges')] + public function testAdditionalQueryParametersMayReplaceEverythingExceptTheState(?string $codeChallenge): void + { + $authorization = (new OAuth2Connector)->authorizationUrl( + scopes: ['profile', '0'], + state: 'known-state', + additionalQueryParameters: [ + 'response_type' => 'code id_token', + 'client_id' => 'replaced', + 'state' => 'replaced-state', + 'prompt' => '', + 'include_granted_scopes' => false, + ], + codeChallenge: $codeChallenge, + ); + + parse_str(parse_url((string) $authorization, PHP_URL_QUERY) ?: '', $query); + + $this->assertSame('known-state', $authorization->state); + $this->assertSame('known-state', $query['state']); + $this->assertSame('code id_token', $query['response_type']); + $this->assertSame('replaced', $query['client_id']); + $this->assertSame('profile 0', $query['scope']); + $this->assertSame('', $query['prompt']); + $this->assertSame('0', $query['include_granted_scopes']); + $this->assertSame($codeChallenge, $query['code_challenge'] ?? null); + $this->assertSame($codeChallenge === null ? null : 'S256', $query['code_challenge_method'] ?? null); + } + + /** + * Get authorization URLs with and without a PKCE challenge. + * + * @return array + */ + public static function codeChallenges(): array + { + return [ + 'without PKCE' => [null], + 'with PKCE' => ['challenge'], + ]; + } + + public function testAuthorizationUrlPreservesEndpointQueryAndGeneratesState(): void + { + $authorization = (new OAuth2Connector(['authorizeEndpoint' => 'oauth/authorize?audience=users']))->authorizationUrl(); + + $this->assertNotSame('', $authorization->state); + $this->assertStringContainsString('audience=users&response_type=code', (string) $authorization); + $this->assertStringContainsString('state=' . $authorization->state, (string) $authorization); + } + + public function testAuthorizationUrlReplacesProtocolParametersFromBaseAndEndpointQueries(): void + { + $authorization = (new BaseQueryOAuth2Connector(['authorizeEndpoint' => 'oauth/authorize?client_id=endpoint&audience=users'])) + ->authorizationUrl(state: 'known-state'); + $queryString = parse_url((string) $authorization, PHP_URL_QUERY) ?: ''; + parse_str($queryString, $query); + + $this->assertSame(1, substr_count($queryString, 'client_id=')); + $this->assertSame('client-id', $query['client_id']); + $this->assertSame('value', $query['base']); + $this->assertSame('users', $query['audience']); + } + + public function testInvalidPkceConfigurationIsRejectedBeforeSending(): void + { + $connector = new OAuth2Connector; + + foreach ([['', 'S256'], ['challenge', 'unsupported']] as [$challenge, $method]) { + try { + $connector->authorizationUrl(codeChallenge: $challenge, codeChallengeMethod: $method); + $this->fail('An invalid PKCE configuration was accepted.'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } + + public function testYouCanRequestATokenFromAConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertSame('refresh', $authenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + } + + public function testOauthAccessTokensDeriveExpiryFromTheGlobalClock(): void + { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + Date::setTestNow($now); + + $connector = new OAuth2Connector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt()); + } + + public function testExpiriesStayImmutableWhenDateCreatesMutableDates(): void + { + Date::use(Carbon::class); + Date::setTestNow('2026-01-01T00:00:00+00:00'); + Saloon::fake([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600]), + ]); + + $expiresAt = (new OAuth2Connector)->getAccessToken('code')->getExpiresAt(); + + $this->assertInstanceOf(CarbonImmutable::class, $expiresAt); + $this->assertEquals(new DateTimeImmutable('2026-01-01T01:00:00+00:00'), $expiresAt); + $this->assertEquals(new DateTimeImmutable('2026-01-01T00:00:00+00:00'), Date::now()); + } + + public function testCustomOauthAuthenticatorsUseTheGlobalClockForExpirySemantics(): void + { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + Date::setTestNow($now); + + $connector = new CustomResponseOAuth2Connector('hello'); + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator); + $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt()); + $this->assertFalse($authenticator->hasExpired()); + } + + public function testYouCanTapIntoTheAccessTokenRequestAndModifyIt(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken('code', requestModifier: function (Request $request): void { + $request->withQueryParameters(['yee' => 'haw']); + }); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertSame('refresh', $authenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + + $mockClient->assertSentCount(1); + + $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters()); + } + + #[DataProvider('pkceConnectors')] + public function testTheCodeVerifierIsSentWithTheDefaultAndCustomTokenRequests(OAuth2Connector|CustomRequestOAuth2Connector $connector): void + { + $mockClient = Saloon::fake([ + MockResponse::make(['access_token' => 'access']), + ]); + + $connector->getAccessToken('code', codeVerifier: 'verifier'); + + $this->assertSame('verifier', $mockClient->lastPendingRequest()->body()['code_verifier']); + } + + /** + * Get connectors with the default and a custom access-token request resolver. + * + * @return array + */ + public static function pkceConnectors(): array + { + return [ + 'default resolver' => [new OAuth2Connector], + 'custom resolver' => [new CustomRequestOAuth2Connector], + ]; + } + + public function testYouCanRequestTheOriginalResponseInsteadOfTheAuthenticatorOnTheCreateTokensMethod(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $response = $connector->getAccessToken('code', null, null, true); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], $response->json()); + } + + public function testReturningTheRawTokenResponseSkipsAuthenticatorValidation(): void + { + Saloon::fake([MockResponse::make(['provider_specific' => true])]); + + $response = (new OAuth2Connector)->getAccessToken('code', returnResponse: true); + + $this->assertSame(['provider_specific' => true], $response->json()); + } + + public function testAnInvalidRefreshTokenIsRejected(): void + { + Saloon::fake([MockResponse::make(['access_token' => 'access', 'refresh_token' => []])]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessageIs('The OAuth token response contains an invalid refresh token.'); + + (new OAuth2Connector)->getAccessToken('code'); + } + + public function testItWillThrowAnExceptionIfStateIsInvalid(): void + { + $connector = new OAuth2Connector; + + $state = 'secret'; + $connector->authorizationUrl(['scope-1', 'scope-2'], $state); + + $this->expectException(InvalidStateException::class); + $this->expectExceptionMessageIs('Invalid state.'); + + $connector->getAccessToken('code', 'invalid', $state); + } + + public function testStateValidationRequiresACompleteMatchingPair(): void + { + $connector = new OAuth2Connector; + $mockClient = Saloon::fake([ + MockResponse::make(['access_token' => 'access']), + ]); + + foreach ([ + ['returned', null], + [null, 'expected'], + ['', 'expected'], + ['returned', ''], + ['returned', 'expected'], + ] as [$returnedState, $expectedState]) { + try { + $connector->getAccessToken('code', $returnedState, $expectedState); + $this->fail('Invalid state was accepted.'); + } catch (InvalidStateException) { + $this->addToAssertionCount(1); + } + } + + $this->assertSame('access', $connector->getAccessToken('code', 'state', 'state')->getAccessToken()); + $mockClient->assertSentCount(1); + } + + public function testYouCanRefreshATokenFromAConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $newAuthenticator = $connector->refreshAccessToken($authenticator); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator); + $this->assertSame('access-new', $newAuthenticator->getAccessToken()); + $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt()); + } + + public function testYouCanTapIntoTheRefreshTokenRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $newAuthenticator = $connector->refreshAccessToken($authenticator, requestModifier: function (Request $request): void { + $request->withQueryParameters(['yee' => 'haw']); + }); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator); + $this->assertSame('access-new', $newAuthenticator->getAccessToken()); + $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt()); + + $mockClient->assertSentCount(1); + + $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters()); + } + + public function testTheRefreshAccessTokenMethodThrowsAnExceptionIfYouProvideItAnAuthenticatorThatIsNotRefreshable(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = new AccessTokenAuthenticator('access', null, Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The provided OAuthAuthenticator does not contain a refresh token.'); + + $connector->refreshAccessToken($authenticator); + } + + public function testYouCanRequestTheOriginalResponseInsteadOfTheAuthenticatorOnTheRefreshTokensMethod(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $response = $connector->refreshAccessToken($authenticator, true); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600], $response->json()); + } + + #[DataProvider('refreshEndpoints')] + public function testRefreshUsesTheConfiguredEndpoint(?string $endpoint, bool $allowOverride, string $expected): void + { + $mockClient = Saloon::fake([ + GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest) use ($expected): MockResponse { + $this->assertSame($expected, (string) $pendingRequest->uri()); + $this->assertSame([ + 'grant_type' => 'refresh_token', + 'refresh_token' => 'refresh', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + ], $pendingRequest->body()); + $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']); + $this->assertSame('per-call', $pendingRequest->headers()['X-Request']); + + return MockResponse::make(['access_token' => 'renewed']); + }, + ]); + $connector = new OAuth2Connector([ + 'tokenEndpoint' => 'oauth/token', + 'refreshEndpoint' => $endpoint, + 'requestModifier' => fn (Request $request): Request => $request->withHeader('X-OAuth', 'configured'), + 'allowBaseUrlOverride' => $allowOverride, + ]); + + $authenticator = $connector->refreshAccessToken( + 'refresh', + requestModifier: fn (Request $request): Request => $request->withHeader('X-Request', 'per-call'), + ); + + $this->assertSame('renewed', $authenticator->getAccessToken()); + $this->assertSame('refresh', $authenticator->getRefreshToken()); + $mockClient->assertSentCount(1); + } + + /** + * Provide refresh endpoints and their resolved URLs. + * + * @return array + */ + public static function refreshEndpoints(): array + { + return [ + 'token endpoint default' => [null, false, 'https://oauth.saloon.dev/oauth/token'], + 'relative refresh endpoint' => ['oauth/refresh?audience=users', false, 'https://oauth.saloon.dev/oauth/refresh?audience=users'], + 'trusted absolute refresh endpoint' => ['https://oauth.example.net/refresh', true, 'https://oauth.example.net/refresh'], + ]; + } + + public function testAbsoluteRefreshEndpointRequiresOverridePermission(): void + { + $mockClient = Saloon::fake([MockResponse::make(['access_token' => 'renewed'])]); + $connector = new OAuth2Connector(['refreshEndpoint' => 'https://oauth.example.net/refresh']); + + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.'); + + try { + $connector->refreshAccessToken('refresh'); + } finally { + $mockClient->assertNothingSent(); + } + } + + // The user request has no body, so unlike upstream it sends no form Content-Type. + public function testYouCanGetTheUserFromAnOauthConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['user' => 'Sam']), + ]); + + $connector = new OAuth2Connector; + Saloon::fake($mockClient); + + $accessToken = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $response = $connector->getUser($accessToken); + + $this->assertInstanceOf(Response::class, $response); + + $pendingRequest = $response->pendingRequest(); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer access', + ], $pendingRequest->headers()); + } + + public function testYouCanTapIntoTheTheUserRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['user' => 'Sam']), + ]); + + $connector = new OAuth2Connector; + Saloon::fake($mockClient); + + $accessToken = new AccessTokenAuthenticator('access', 'refresh', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $response = $connector->getUser($accessToken, function (Request $request): void { + $request->withQueryParameters(['yee' => 'haw']); + }); + + $this->assertInstanceOf(Response::class, $response); + + $pendingRequest = $response->pendingRequest(); + + $this->assertSame(['yee' => 'haw'], $pendingRequest->queryParameters()); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer access', + ], $pendingRequest->headers()); + } + + public function testYouCanCustomizeTheOauthAuthenticator(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + ]); + + $customConnector = new CustomResponseOAuth2Connector('Howdy!'); + Saloon::fake($mockClient); + + $authenticator = $customConnector->getAccessToken('code'); + + $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator); + $this->assertSame('Howdy!', $authenticator->getGreeting()); + } + + public function testYouCanRegisterAGlobalRequestModifierThatIsCalledOnEveryStepOfTheOAuth2Process(): void + { + $mockClient = new MockClient([ + GetAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + GetRefreshTokenRequest::class => MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + GetUserRequest::class => MockResponse::make(['user' => 'Sam']), + ]); + + $requests = []; + + $connector = new OAuth2Connector(['requestModifier' => function (Request $request) use (&$requests): void { + $requests[] = $request::class; + + match ($request::class) { + GetAccessTokenRequest::class => $request->withQueryParameters(['request' => 'access']), + GetRefreshTokenRequest::class => $request->withQueryParameters(['request' => 'refresh']), + GetUserRequest::class => $request->withQueryParameters(['request' => 'user']), + }; + }]); + + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken('code'); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertSame('refresh', $authenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + $this->assertSame(['request' => 'access'], $mockClient->lastPendingRequest()->queryParameters()); + + $newAuthenticator = $connector->refreshAccessToken($authenticator); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $newAuthenticator); + $this->assertSame('access-new', $newAuthenticator->getAccessToken()); + $this->assertSame('refresh-new', $newAuthenticator->getRefreshToken()); + $this->assertInstanceOf(DateTimeImmutable::class, $newAuthenticator->getExpiresAt()); + $this->assertSame(['request' => 'refresh'], $mockClient->lastPendingRequest()->queryParameters()); + + $response = $connector->getUser($newAuthenticator); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(['request' => 'user'], $mockClient->lastPendingRequest()->queryParameters()); + + $pendingRequest = $response->pendingRequest(); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer access-new', + ], $pendingRequest->headers()); + + $this->assertSame([ + GetAccessTokenRequest::class, + GetRefreshTokenRequest::class, + GetUserRequest::class, + ], $requests); + } + + public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutAClientIdItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new NoConfigAuthCodeConnector; + Saloon::fake($mockClient); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $connector->getAccessToken('code'); + } + + public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutASecretItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new NoConfigAuthCodeConnector(['clientId' => 'hello']); + Saloon::fake($mockClient); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.'); + + $connector->getAccessToken('code'); + } + + public function testIfYouAttemptToUseTheAuthorizationCodeFlowWithoutARedirectUriItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new NoConfigAuthCodeConnector(['clientId' => 'hello', 'clientSecret' => 'secret']); + Saloon::fake($mockClient); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Redirect URI is empty or has not been provided.'); + + $connector->getAccessToken('code'); + } + + public function testOnTheConnectorYouCanOverwriteAllTheRequestClasses(): void + { + $mockClient = new MockClient([ + CustomAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'refresh_token' => 'refresh', 'expires_in' => 3600], 200), + CustomRefreshTokenRequest::class => MockResponse::make(['access_token' => 'access-new', 'refresh_token' => 'refresh-new', 'expires_in' => 3600]), + CustomOAuthUserRequest::class => MockResponse::make(['user' => 'Sam']), + ]); + + $connector = new CustomRequestOAuth2Connector; + Saloon::fake($mockClient); + + $accessTokenResponse = $connector->getAccessToken('code', returnResponse: true); + + $this->assertInstanceOf(CustomAccessTokenRequest::class, $accessTokenResponse->request()); + + $refreshTokenResponse = $connector->refreshAccessToken('howdy', returnResponse: true); + + $this->assertInstanceOf(CustomRefreshTokenRequest::class, $refreshTokenResponse->request()); + + $userResponse = $connector->getUser(new AccessTokenAuthenticator('howdy', 'partner')); + + $this->assertInstanceOf(CustomOAuthUserRequest::class, $userResponse->request()); + } +} + +class BaseQueryOAuth2Connector extends OAuth2Connector +{ + /** + * Define a base URL with its own query parameters. + */ + public function resolveBaseUrl(): string + { + return 'https://oauth.saloon.dev?client_id=base&base=value'; + } +} diff --git a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php new file mode 100644 index 0000000000..c2982f9625 --- /dev/null +++ b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php @@ -0,0 +1,380 @@ + 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertNull($authenticator->getRefreshToken()); + $this->assertFalse($authenticator->isRefreshable()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + + $mockClient->assertSentCount(1); + + $this->assertSame([ + 'grant_type' => 'client_credentials', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'scope' => '', + ], $mockClient->lastPendingRequest()->body()); + } + + public function testClientCredentialsTokensDeriveExpiryFromTheGlobalClock(): void + { + $now = new DateTimeImmutable('2026-01-01T00:00:00+00:00'); + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + Date::setTestNow($now); + + $connector = new ClientCredentialsConnector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(); + + $this->assertEquals($now->modify('+3600 seconds'), $authenticator->getExpiresAt()); + } + + public function testYouCanGetTheResponseInsteadOfTheAuthenticator(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector; + Saloon::fake($mockClient); + + $response = $connector->getAccessToken(returnResponse: true); + + $this->assertInstanceOf(Response::class, $response); + + $this->assertSame([ + 'access_token' => 'access', + 'expires_in' => 3600, + ], $response->json()); + } + + public function testYouCanTapIntoTheTokenRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(requestModifier: function (Request $request): void { + $request->withQueryParameters(['yee' => 'haw']); + }); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertNull($authenticator->getRefreshToken()); + $this->assertFalse($authenticator->isRefreshable()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + + $mockClient->assertSentCount(1); + + $this->assertSame(['yee' => 'haw'], $mockClient->lastPendingRequest()->queryParameters()); + } + + public function testYouCanSendScopesWithTheTokenRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing']); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + + $mockClient->assertSentCount(1); + + $this->assertSame([ + 'grant_type' => 'client_credentials', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'scope' => 'offline_access clients billing', + ], $mockClient->lastPendingRequest()->body()); + } + + public function testDefaultScopesOnTheOauthConfigWillBeMergedInWithTheScopesOnTheTokenRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector(['defaultScopes' => ['compliance']]); + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing']); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + + $mockClient->assertSentCount(1); + + $this->assertSame([ + 'grant_type' => 'client_credentials', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'scope' => 'compliance offline_access clients billing', + ], $mockClient->lastPendingRequest()->body()); + } + + public function testTheScopeSeparatorCanBeCustomised(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsConnector(['defaultScopes' => ['compliance']]); + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(['offline_access', 'clients', 'billing'], '+'); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + + $mockClient->assertSentCount(1); + + $this->assertSame([ + 'grant_type' => 'client_credentials', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'scope' => 'compliance+offline_access+clients+billing', + ], $mockClient->lastPendingRequest()->body()); + } + + #[DataProvider('scopeConnectors')] + public function testNullAndEmptyScopesAreIgnored(ClientCredentialsBasicAuthConnector|ClientCredentialsConnector $connector): void + { + $mockClient = Saloon::fake([ + MockResponse::make(['access_token' => 'access']), + ]); + + $connector->getAccessToken(['', 'clients', null, '0']); + + $this->assertSame('clients 0', $mockClient->lastPendingRequest()->body()['scope']); + } + + /** + * Get connectors with the default and Basic authentication token requests. + * + * @return array + */ + public static function scopeConnectors(): array + { + return [ + 'default request' => [new ClientCredentialsConnector], + 'basic auth request' => [new ClientCredentialsBasicAuthConnector], + ]; + } + + public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutAClientIdItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new NoConfigClientCredentialsConnector; + Saloon::fake($mockClient); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $connector->getAccessToken(); + } + + public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutASecretItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new NoConfigClientCredentialsConnector(['clientId' => 'hello']); + Saloon::fake($mockClient); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.'); + + $connector->getAccessToken(); + } + + public function testOnTheConnectorYouCanOverwriteTheGetAccessTokenRequest(): void + { + $mockClient = new MockClient([ + CustomClientCredentialsAccessTokenRequest::class => MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new CustomRequestClientCredentialsConnector; + Saloon::fake($mockClient); + + $accessTokenResponse = $connector->getAccessToken(returnResponse: true); + + $this->assertInstanceOf(CustomClientCredentialsAccessTokenRequest::class, $accessTokenResponse->request()); + } + + public function testTheClientCredentialsGrantCanUseBasicAuth(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), + ]); + + $connector = new ClientCredentialsBasicAuthConnector; + Saloon::fake($mockClient); + + $authenticator = $connector->getAccessToken(); + + $this->assertInstanceOf(AccessTokenAuthenticator::class, $authenticator); + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertNull($authenticator->getRefreshToken()); + $this->assertFalse($authenticator->isRefreshable()); + $this->assertInstanceOf(DateTimeImmutable::class, $authenticator->getExpiresAt()); + + $mockClient->assertSentCount(1); + + $this->assertSame([ + 'grant_type' => 'client_credentials', + 'scope' => '', + ], $mockClient->lastPendingRequest()->body()); + + $this->assertSame( + 'Basic ' . base64_encode('client-id:client-secret'), + $mockClient->lastPendingRequest()->headers()['Authorization'], + ); + } + + public function testAReturnedRefreshTokenIsIgnored(): void + { + Saloon::fake([MockResponse::make(['access_token' => 'access', 'refresh_token' => []])]); + + $authenticator = (new ClientCredentialsConnector)->getAccessToken(); + + $this->assertNull($authenticator->getRefreshToken()); + } + + public function testACustomAuthenticatorReceivesTheExpiry(): void + { + Date::setTestNow('2026-01-01T00:00:00+00:00'); + Saloon::fake([MockResponse::make(['access_token' => 'access', 'expires_in' => 3600])]); + + $authenticator = (new CustomAuthenticatorClientCredentialsConnector)->getAccessToken(); + + $this->assertInstanceOf(CustomOAuthAuthenticator::class, $authenticator); + $this->assertEquals(new DateTimeImmutable('2026-01-01T01:00:00+00:00'), $authenticator->getExpiresAt()); + } + + public function testTokenResponseValidationRejectsInvalidValues(): void + { + Date::setTestNow('2026-08-10 12:00:00'); + + foreach ([ + [], + ['access_token' => ''], + ['access_token' => 123], + ['access_token' => 'token', 'expires_in' => -1], + ['access_token' => 'token', 'expires_in' => '1.5'], + ['access_token' => 'token', 'expires_in' => 1.0E+30], + ['access_token' => 'token', 'expires_in' => PHP_INT_MAX], + ] as $body) { + Saloon::fake([MockResponse::make($body)]); + + try { + (new ClientCredentialsConnector)->getAccessToken(); + $this->fail('An invalid OAuth token response was accepted.'); + } catch (UnexpectedValueException) { + $this->addToAssertionCount(1); + } + } + } + + #[DataProvider('validExpiries')] + public function testTokenExpiryAcceptsWholeNumbersOfSecondsUpToTheRepresentableBoundary(int|float|string $expiresIn, int $seconds): void + { + Date::setTestNow('2026-08-10 12:00:00'); + Saloon::fake([MockResponse::make(['access_token' => 'token', 'expires_in' => $expiresIn])]); + + $authenticator = (new ClientCredentialsConnector)->getAccessToken(); + + $this->assertSame(Date::now()->getTimestamp() + $seconds, $authenticator->getExpiresAt()?->getTimestamp()); + $this->assertTrue($authenticator->hasNotExpired()); + } + + /** + * Get valid expiry durations and the seconds they represent. + * + * @return array + */ + public static function validExpiries(): array + { + // The test clock is fixed at this timestamp, so the boundary is the largest duration that does not overflow. + $boundary = PHP_INT_MAX - 1786363200; + + return [ + 'integer' => [3600, 3600], + 'integer string' => ['3600', 3600], + 'whole float' => [3600.0, 3600], + 'representable boundary' => [$boundary, $boundary], + ]; + } +} + +class CustomAuthenticatorClientCredentialsConnector extends ClientCredentialsConnector +{ + /** + * Create the OAuth authenticator with upstream's client-credentials signature. + */ + protected function createOAuthAuthenticator(string $accessToken, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator + { + return new CustomOAuthAuthenticator($accessToken, 'Howdy!', null, $expiresAt); + } +} diff --git a/tests/Saloon/Feature/PoolTest.php b/tests/Saloon/Feature/PoolTest.php new file mode 100644 index 0000000000..9f87977ed9 --- /dev/null +++ b/tests/Saloon/Feature/PoolTest.php @@ -0,0 +1,259 @@ +pool([ + new UserRequest, + new UserRequest, + new UserRequest, + new UserRequest, + new UserRequest, + ]); + + $pool->setConcurrency(5); + + $pool->withExceptionHandler(function (FatalRequestException $exception, int $key) use (&$exceptions): void { + $exceptions[$key] = $exception; + }); + + $this->assertSame([], $pool->send()); + $this->assertCount(5, $exceptions); + + foreach ($exceptions as $exception) { + $this->assertInstanceOf(ConnectionException::class, $exception->getPrevious()); + $this->assertInstanceOf(PendingRequest::class, $exception->pendingRequest()); + } + } + + public function testYouCanUsePoolWithAMockClientAddedAndItWontSendRealRequests(): void + { + Http::fake(); + + $mockResponses = [ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + MockResponse::make(['name' => 'Emily']), + MockResponse::make(['name' => 'Error'], 500), + ]; + + Saloon::fake($mockResponses); + + $connector = new TestConnector; + $handled = []; + $exceptions = []; + + $pool = $connector->pool([ + new UserRequest, + new UserRequest, + new UserRequest, + new UserRequest, + new ErrorRequest, + ]); + + $pool->setConcurrency(6); + + $pool->withResponseHandler(function (Response $response, int $key) use (&$handled): void { + $handled[$key] = $response; + }); + + $pool->withExceptionHandler(function (RequestException $exception, int $key) use (&$exceptions): void { + $exceptions[$key] = $exception; + }); + + $responses = $pool->send(); + + $this->assertSame([0, 1, 2, 3], array_keys($handled)); + + foreach ($handled as $key => $response) { + $this->assertSame($mockResponses[$key]->body()->all(), $response->json()); + } + + $this->assertSame([4], array_keys($exceptions)); + $this->assertSame(500, $exceptions[4]->response()->status()); + $this->assertSame(['name' => 'Error'], $exceptions[4]->response()->json()); + $this->assertEquals($exceptions[4]->response()->toException(), $exceptions[4]); + $this->assertSame($handled, $responses); + Http::assertNothingSent(); + } + + public function testPooledRequestsRunTheirMiddlewareWhenSentAndReturnCustomResponses(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['foo' => 'bar']), + ]); + + $request = new UserRequest; + $events = []; + $request->middleware() + ->onRequest(function () use (&$events): void { + $events[] = 'request'; + }) + ->onResponse(function () use (&$events): void { + $events[] = 'response'; + }); + + $pool = (new TestConnector)->pool([$request, new UserRequestWithCustomResponse]); + + $this->assertSame([], $events); + + $responses = $pool->send(); + + $this->assertSame(['request', 'response'], $events); + $this->assertSame(['name' => 'Sam'], $responses[0]->json()); + $this->assertSame(200, $responses[0]->status()); + $this->assertInstanceOf(UserResponse::class, $responses[1]); + $this->assertInstanceOf(UserData::class, $responses[1]->customCastMethod()); + $this->assertSame('bar', $responses[1]->foo()); + } + + public function testHandledRequestFailuresAreOmittedAfterEveryChildSettles(): void + { + Saloon::fake([ + ErrorRequest::class => MockResponse::make()->throw( + fn (PendingRequest $pendingRequest): TestResponseException => new TestResponseException('Unable to connect!', $pendingRequest), + ), + UserRequest::class => MockResponse::make(['ok' => true]), + ]); + $handled = []; + + $responses = (new TestConnector)->pool([ + 'failed' => new ErrorRequest, + 'successful' => new UserRequest, + ])->withExceptionHandler(function (TestResponseException $exception, string $key) use (&$handled): void { + $handled[$key] = $exception; + })->send(); + + $this->assertSame(['failed'], array_keys($handled)); + $this->assertSame('Unable to connect!', $handled['failed']->getMessage()); + $this->assertInstanceOf(ErrorRequest::class, $handled['failed']->getPendingRequest()->request()); + $this->assertSame(['successful'], array_keys($responses)); + } + + public function testPoolExceptionPreservesRequestCallbackAndPartialResults(): void + { + $sendFailure = new RuntimeException('send failed'); + $callbackFailure = new RuntimeException('callback failed'); + Saloon::fake([ + ErrorRequest::class => MockResponse::make()->throw($sendFailure), + UserRequest::class => MockResponse::make(['ok' => true]), + ]); + + try { + (new TestConnector)->pool([ + 'failed' => new ErrorRequest, + 'successful' => new UserRequest, + ])->withResponseHandler(function () use ($callbackFailure): never { + throw $callbackFailure; + })->send(); + $this->fail('The pool exception was not thrown.'); + } catch (PoolException $exception) { + $this->assertSame(['failed' => $sendFailure], $exception->failures()); + $this->assertSame(['successful' => $callbackFailure], $exception->callbackFailures()); + $this->assertSame(['successful'], array_keys($exception->responses())); + $this->assertNull($exception->orchestrationFailure()); + $this->assertSame($sendFailure, $exception->getPrevious()); + } + } + + public function testThrowableResponsesWithoutAnExceptionHandlerArePoolFailures(): void + { + $mockClient = Saloon::fake([ + ErrorRequest::class => MockResponse::make(['message' => 'Server Error'], 500), + CustomFailHandlerRequest::class => MockResponse::make('Yee-naw: Something went wrong'), + UserRequest::class => MockResponse::make(['ok' => true]), + ]); + + try { + (new TestConnector)->pool([ + 'server-error' => new ErrorRequest, + 'custom-failure' => new CustomFailHandlerRequest, + 'successful' => new UserRequest, + ])->send(); + $this->fail('The pool exception was not thrown.'); + } catch (PoolException $exception) { + $failures = $exception->failures(); + $recorded = $mockClient->recorded()->keyBy(fn (Response $response): string => $response->request()::class); + + $this->assertSame(['server-error', 'custom-failure'], array_keys($failures)); + $this->assertInstanceOf(RequestException::class, $failures['server-error']); + $this->assertSame($recorded[ErrorRequest::class], $failures['server-error']->response()); + $this->assertSame(500, $failures['server-error']->status()); + $this->assertInstanceOf(RequestException::class, $failures['custom-failure']); + $this->assertSame($recorded[CustomFailHandlerRequest::class], $failures['custom-failure']->response()); + $this->assertSame(200, $failures['custom-failure']->status()); + $this->assertSame([], $exception->callbackFailures()); + $this->assertSame(['successful'], array_keys($exception->responses())); + $this->assertSame($failures['server-error'], $exception->getPrevious()); + } + } + + public function testACallbackFailureIsThePoolExceptionCauseWhenNoRequestFails(): void + { + $callbackFailure = new RuntimeException('callback failed'); + Saloon::fake([MockResponse::make(['ok' => true])]); + + try { + (new TestConnector)->pool(['successful' => new UserRequest]) + ->withResponseHandler(function () use ($callbackFailure): never { + throw $callbackFailure; + }) + ->send(); + $this->fail('The pool exception was not thrown.'); + } catch (PoolException $exception) { + $this->assertSame([], $exception->failures()); + $this->assertSame(['successful' => $callbackFailure], $exception->callbackFailures()); + $this->assertSame(['successful'], array_keys($exception->responses())); + $this->assertSame($callbackFailure, $exception->getPrevious()); + } + } +} diff --git a/tests/Saloon/Feature/PsrTest.php b/tests/Saloon/Feature/PsrTest.php new file mode 100644 index 0000000000..faec161a56 --- /dev/null +++ b/tests/Saloon/Feature/PsrTest.php @@ -0,0 +1,64 @@ + 'Sam']), + ]); + + $connector = new ModifiedPsrRequestConnector; + + $response = $connector->send(new ModifiedPsrUserRequest, $mockClient); + + // The connector will change the URI to https://google.com + + $this->assertSame('https://google.com', (string) $response->toPsrRequest()->getUri()); + + // The request will add the X-Howdy header + + $this->assertSame(['Yeehaw'], $response->toPsrRequest()->getHeader('X-Howdy')); + } + + public function testThePsrRequestHeadersMustBeConvertedToEmptyString(): void + { + $mockClient = new MockClient([ + MockResponse::make(headers: ['X-Null-Header' => null]), + ]); + + $connector = new TestConnector; + + $response = $connector->send(new NullHeaderRequest, $mockClient); + + // The request will convert null to empty string + $this->assertSame('', $response->toPsrRequest()->getHeader('X-Null-Header')[0]); + + // The response will convert null header to empty string + $this->assertSame('', $response->header('X-Null-Header')); + $this->assertSame([''], $response->headers()['X-Null-Header']); + } +} diff --git a/tests/Saloon/Feature/RequestExceptionTest.php b/tests/Saloon/Feature/RequestExceptionTest.php new file mode 100644 index 0000000000..e230913179 --- /dev/null +++ b/tests/Saloon/Feature/RequestExceptionTest.php @@ -0,0 +1,349 @@ + 'Server Error'], 500), + ]); + + $response = TestConnector::make()->send(new UserRequest, $mockClient); + + $this->assertInstanceOf(Response::class, $response); + + $exception = $response->toException(); + + $this->assertInstanceOf(InternalServerErrorException::class, $exception); + $this->assertInstanceOf(ServerException::class, $exception); + $this->assertSame(static::message($response), $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + + $this->expectExceptionObject($exception); + + $response->throw(); + } + + // REMOVED: the two sendAsync() promise cases. Coroutine pools replace promises. + + public function testYouCanCustomiseTheExceptionHandlerOnAConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Server Error'], 500), + ]); + + $response = CustomExceptionConnector::make()->send(new UserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertInstanceOf(ConnectorRequestException::class, $exception); + $this->assertSame('Oh yee-naw.', $exception->getMessage()); + } + + public function testYouCanCustomiseTheExceptionHandlerOnARequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Server Error'], 500), + ]); + + $response = TestConnector::make()->send(new CustomExceptionUserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertInstanceOf(CustomRequestException::class, $exception); + $this->assertSame('Oh yee-naw.', $exception->getMessage()); + } + + public function testTheRequestExceptionHandlerWillAlwaysTakePriority(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Server Error'], 500), + ]); + + $response = CustomExceptionConnector::make()->send(new CustomExceptionUserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertInstanceOf(CustomRequestException::class, $exception); + $this->assertSame('Oh yee-naw.', $exception->getMessage()); + } + + public function testYouCanCustomiseIfSaloonShouldThrowAnExceptionOnAConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Success']), + MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']), + ]); + + $responseA = BadResponseConnector::make()->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->shouldThrowRequestException()); + $this->assertNull($responseA->toException()); + + $responseB = BadResponseConnector::make()->send(new UserRequest, $mockClient); + $this->assertTrue($responseB->shouldThrowRequestException()); + $exceptionB = $responseB->toException(); + + $this->assertInstanceOf(RequestException::class, $exceptionB); + $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest()); + $this->assertInstanceOf(Response::class, $exceptionB->response()); + $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage()); + $this->assertNull($exceptionB->getPrevious()); + } + + public function testYouCanCustomiseIfSaloonShouldThrowAnExceptionOnARequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Success']), + MockResponse::make(['message' => 'Yee-naw: Horse Not Found']), + ]); + + $responseA = TestConnector::make()->send(new BadResponseRequest, $mockClient); + + $this->assertFalse($responseA->shouldThrowRequestException()); + $this->assertNull($responseA->toException()); + + $responseB = TestConnector::make()->send(new BadResponseRequest, $mockClient); + $this->assertTrue($responseB->shouldThrowRequestException()); + $exceptionB = $responseB->toException(); + + $this->assertInstanceOf(RequestException::class, $exceptionB); + $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest()); + $this->assertInstanceOf(Response::class, $exceptionB->response()); + $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage()); + $this->assertNull($exceptionB->getPrevious()); + } + + public function testWhenBothTheConnectorAndRequestHaveCustomLogicToDetermineDifferentFailuresTheyWorkTogether(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Success']), + MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']), + MockResponse::make(['message' => 'Yee-naw: Horse Not Found']), + ]); + + $responseA = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient); + + $this->assertFalse($responseA->shouldThrowRequestException()); + $this->assertNull($responseA->toException()); + + $responseB = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient); + $this->assertTrue($responseB->shouldThrowRequestException()); + $exceptionB = $responseB->toException(); + + $this->assertInstanceOf(RequestException::class, $exceptionB); + $this->assertInstanceOf(PendingRequest::class, $exceptionB->pendingRequest()); + $this->assertInstanceOf(Response::class, $exceptionB->response()); + $this->assertSame(static::message($exceptionB->response()), $exceptionB->getMessage()); + $this->assertNull($exceptionB->getPrevious()); + + $responseC = BadResponseConnector::make()->send(new BadResponseRequest, $mockClient); + $this->assertTrue($responseC->shouldThrowRequestException()); + $exceptionC = $responseC->toException(); + + $this->assertInstanceOf(RequestException::class, $exceptionC); + $this->assertInstanceOf(Response::class, $exceptionC->response()); + $this->assertSame(static::message($exceptionC->response()), $exceptionC->getMessage()); + $this->assertNull($exceptionC->getPrevious()); + } + + public function testYouCanCustomiseIfSaloonDeterminesIfARequestHasFailedOnAConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Success']), + MockResponse::make(['message' => 'Error: Invalid Cowboy Hat']), + ]); + + $responseA = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient); + + $this->assertFalse($responseA->failed()); + + $responseB = CustomFailHandlerConnector::make()->send(new UserRequest, $mockClient); + + $this->assertTrue($responseB->failed()); + } + + public function testYouCanCustomiseIfSaloonDeterminesIfARequestHasFailedOnARequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Success']), + MockResponse::make(['message' => 'Yee-naw: Horse Not Found']), + ]); + + $responseA = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient); + + $this->assertFalse($responseA->failed()); + + $responseB = TestConnector::make()->send(new CustomFailHandlerRequest, $mockClient); + + $this->assertTrue($responseB->failed()); + } + + // Upstream calls its live not-found endpoint; a mocked 404 response proves the same behavior. + public function testARequestCanMarkARequestAsNotFailed(): void + { + $response = TestConnector::make()->send(new NotFoundFailedRequest, new MockClient([ + MockResponse::make(['message' => 'Not Found'], 404), + ])); + + $this->assertFalse($response->failed()); + } + + // REMOVED: the sendAsync() case. Coroutine pools replace promises; see the pool case below. + + public function testARequestCanMarkARequestAsNotFailedWithPools(): void + { + $responseCount = 0; + $exceptionCount = 0; + + $request = (new NotFoundFailedRequest)->withMockClient(new MockClient([ + MockResponse::make(['message' => 'Not Found'], 404), + ])); + + $pool = TestConnector::make()->pool([$request]); + + $pool->withResponseHandler(function (Response $response) use (&$responseCount): void { + $this->assertSame(404, $response->status()); + + ++$responseCount; + })->withExceptionHandler(function (Throwable $exception) use (&$exceptionCount): void { + ++$exceptionCount; + }); + + $pool->send(); + + $this->assertSame(1, $responseCount); + $this->assertSame(0, $exceptionCount); + } + + // Upstream connects to hostnames that do not resolve; a refused loopback port fails the same way without DNS. + public function testTheSenderWillThrowAFatalRequestExceptionIfItCannotConnectToASite(): void + { + $connector = new TestConnector('http://127.0.0.1:1'); + $request = new UserRequest; + + $this->expectException(FatalRequestException::class); + + $connector->send($request); + } + + // REMOVED: the sendAsync() case. Coroutine pools replace promises. + + #[DataProvider('incompleteTransfers')] + public function testATransferThatFailsWhileReceivingTheResponseIsFatal(int $status, string $transportException): void + { + Http::fake(fn (HttpRequest $request): PromiseInterface => Create::rejectionFor(static::incompleteTransfer($request, $status))); + + $request = new UserRequest; + $fatalExceptions = []; + $request->middleware()->onFatalException(function (FatalRequestException $exception) use (&$fatalExceptions): void { + $fatalExceptions[] = $exception; + }); + + try { + TestConnector::make()->send($request); + + $this->fail('The incomplete transfer did not throw.'); + } catch (FatalRequestException $exception) { + $this->assertInstanceOf(ConnectionException::class, $exception->getPrevious()); + $this->assertInstanceOf($transportException, $exception->getPrevious()->getPrevious()); + $this->assertSame([$exception], $fatalExceptions); + } + } + + /** + * Get the statuses of incomplete transfers and the exceptions the HTTP client reports for them. + * + * @return array}> + */ + public static function incompleteTransfers(): array + { + return [ + 'successful status' => [200, GuzzleRequestException::class], + 'error status' => [502, HttpRequestException::class], + ]; + } + + public function testAnIncompleteTransferIsRetried(): void + { + $attempts = 0; + Http::fake(function (HttpRequest $request) use (&$attempts): PromiseInterface { + return ++$attempts === 1 + ? Create::rejectionFor(static::incompleteTransfer($request, 502)) + : Http::response(['name' => 'Sam']); + }); + + $response = TestConnector::make()->send((new UserRequest)->retry(2)); + + $this->assertSame(2, $attempts); + $this->assertSame(['name' => 'Sam'], $response->json()); + } + + /** + * Get the message the HTTP client's request exception gives a response. + */ + protected static function message(Response $response): string + { + return sprintf("HTTP request returned status code %d:\n%s\n", $response->status(), $response->body()); + } + + /** + * Create the exception cURL reports when a transfer fails after the response headers arrive. + */ + protected static function incompleteTransfer(HttpRequest $request, int $status): GuzzleRequestException + { + $response = new PsrResponse($status, [], 'partial'); + + return class_exists(ResponseException::class) + ? new ResponseException('cURL error 56: Recv failure', $request->toPsrRequest(), $response) + : new GuzzleRequestException('cURL error 56: Recv failure', $request->toPsrRequest(), $response); + } +} diff --git a/tests/Saloon/Feature/RetryConnectorTest.php b/tests/Saloon/Feature/RetryConnectorTest.php new file mode 100644 index 0000000000..9ba2a99fcd --- /dev/null +++ b/tests/Saloon/Feature/RetryConnectorTest.php @@ -0,0 +1,297 @@ + 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $response = (new RetryConnector(3))->send(new UserRequest, $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Teodor'], $response->json()); + + $mockClient->assertSentCount(3); + } + + public function testIfTheAttemptsAreExhaustedItWillThrowAnExceptionFromTheLastRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500), + ]); + + try { + (new RetryConnector(3))->send(new UserRequest, $mockClient); + + $this->fail('The request did not throw.'); + } catch (InternalServerErrorException $exception) { + $this->assertSame(['name' => 'Teodor'], $exception->response()->json()); + } + + $mockClient->assertSentCount(3); + } + + public function testIfTheAttemptsAreExhaustedItWillReturnTheLastResponseIfThrowingIsDisabled(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500), + ]); + + $response = (new RetryConnector(3, throw: false))->send(new UserRequest, $mockClient); + + $this->assertSame(['name' => 'Teodor'], $response->json()); + + $mockClient->assertSentCount(3); + } + + public function testIfAFatalRequestExceptionHappensEvenWithThrowDisabledItWillThrowTheFatalRequestException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500)->throw( + fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest), + ), + ]); + + $this->expectException(FatalRequestException::class); + + (new RetryConnector(3, throw: false))->send(new UserRequest, $mockClient); + } + + public function testAFailedRequestCanHaveAnIntervalBetweenEachAttempt(): void + { + Sleep::fake(); + + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + (new RetryConnector(3, 1000))->send(new UserRequest, $mockClient); + + // Upstream waits in real time; the two intervals follow the first and second attempts. + Sleep::assertSequence([ + Sleep::for(1000)->milliseconds(), + Sleep::for(1000)->milliseconds(), + ]); + } + + public function testAnExceptionOtherThanARequestExceptionWillNotBeRetried(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + $request = new UserRequest; + $request->middleware()->onResponse(fn (): never => throw new Exception('Yee-naw!')); + + try { + (new RetryConnector(3))->send($request, $mockClient); + + $this->fail('The request did not throw.'); + } catch (Exception $exception) { + $this->assertSame('Yee-naw!', $exception->getMessage()); + } + + $mockClient->assertSentCount(1); + } + + public function testYouCanCustomiseIfTheMethodShouldRetry(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + $connector = new RetryConnector(3, when: function (RequestException $exception): bool { + return $exception->response()->json() !== ['name' => 'Gareth']; + }); + + $this->expectException(InternalServerErrorException::class); + $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n"); + + $connector->send(new UserRequest, $mockClient); + } + + public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $this->expectException(InternalServerErrorException::class); + $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n"); + + (new RetryConnector(3, when: fn (): bool => false))->send(new UserRequest, $mockClient); + } + + public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledItWillReturnAResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $response = (new RetryConnector(5, when: fn (): bool => false, throw: false))->send(new UserRequest, $mockClient); + + $this->assertSame(500, $response->status()); + $this->assertSame(['name' => 'Sam'], $response->json()); + } + + public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledButAFatalRequestExceptionHappensItWillStillThrow(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500)->throw( + fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest), + ), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $this->expectException(FatalRequestException::class); + + (new RetryConnector(5, when: fn (): bool => false, throw: false))->send(new UserRequest, $mockClient); + } + + public function testYouCanModifyTheRequestInsideTheRetryHandler(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + $index = 0; + $connector = new RetryConnector(5, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ) use (&$index): bool { + ++$index; + + $pendingRequest->request()->replaceHeaders(['X-Test-Index' => (string) $index]); + + return true; + }); + + $response = $connector->send(new UserRequest, $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Teodor'], $response->json()); + $this->assertSame('2', $response->pendingRequest()->headers()['X-Test-Index']); + } + + public function testYouCanAuthenticateTheRequestInsideTheRetryHandler(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 401), + MockResponse::make(['name' => 'Gareth'], 200), + ]); + $connector = new RetryConnector(2, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ): bool { + $pendingRequest->request()->authenticate(new TokenAuthenticator('newToken')); + + return true; + }); + + $response = $connector->send(new UserRequest, $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Gareth'], $response->json()); + $this->assertSame('Bearer newToken', $response->pendingRequest()->headers()['Authorization']); + } + + public function testTheResponsePipelineIsOnlyExecutedOnceWhenRetrying(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + ]); + $counter = 0; + $request = new UserRequest; + $request->middleware()->onResponse(function () use (&$counter): void { + ++$counter; + }); + + $response = (new RetryConnector(2, throw: false))->send($request, $mockClient); + + $this->assertSame(500, $response->status()); + $this->assertSame(['name' => 'Gareth'], $response->json()); + + // Counter should be 2 as we have sent two requests. + $this->assertSame(2, $counter); + } + + public function testTheRequestsDefaultRetryPolicyReplacesTheConnectors(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + ]); + $connector = new RetryConnector(3, when: fn (): bool => false); + + $response = $connector->send(new RetryUserRequest(2, throw: false), $mockClient); + + // The connector's condition would stop the retry; the request's policy replaces it entirely. + $this->assertSame(['name' => 'Gareth'], $response->json()); + $mockClient->assertSentCount(2); + } + + public function testAnExplicitSingleAttemptDisablesTheConnectorsRetries(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 200), + ]); + + $response = (new RetryConnector(3))->send((new UserRequest)->retry(1), $mockClient); + + $this->assertSame(500, $response->status()); + $mockClient->assertSentCount(1); + } +} diff --git a/tests/Saloon/Feature/RetryRequestTest.php b/tests/Saloon/Feature/RetryRequestTest.php new file mode 100644 index 0000000000..284a93a3b3 --- /dev/null +++ b/tests/Saloon/Feature/RetryRequestTest.php @@ -0,0 +1,434 @@ + 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $response = (new TestConnector)->send(new RetryUserRequest(3), $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Teodor'], $response->json()); + + $mockClient->assertSentCount(3); + } + + public function testIfTheAttemptsAreExhaustedItWillThrowAnExceptionFromTheLastRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500), + ]); + + try { + (new TestConnector)->send(new RetryUserRequest(3), $mockClient); + + $this->fail('The request did not throw.'); + } catch (InternalServerErrorException $exception) { + $this->assertSame(['name' => 'Teodor'], $exception->response()->json()); + } + + $mockClient->assertSentCount(3); + } + + public function testIfTheAttemptsAreExhaustedItWillReturnTheLastResponseIfThrowingIsDisabled(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500), + ]); + + $response = (new TestConnector)->send(new RetryUserRequest(3, throw: false), $mockClient); + + $this->assertSame(['name' => 'Teodor'], $response->json()); + + $mockClient->assertSentCount(3); + } + + public function testIfAFatalRequestExceptionHappensEvenWithThrowDisabledItWillThrowTheFatalRequestException(): void + { + $thrown = null; + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 500)->throw( + function (PendingRequest $pendingRequest) use (&$thrown): FatalRequestException { + return $thrown = new FatalRequestException(new Exception, $pendingRequest); + }, + ), + ]); + + try { + (new TestConnector)->send(new RetryUserRequest(3, throw: false), $mockClient); + + $this->fail('The request did not throw.'); + } catch (FatalRequestException $exception) { + $this->assertSame($thrown, $exception); + } + } + + public function testAFatalRequestExceptionFromAFakeResponseRunsTheFatalMiddlewareAndIsRetried(): void + { + $thrown = null; + $fatalExceptions = []; + $mockClient = new MockClient([ + MockResponse::make()->throw( + function (PendingRequest $pendingRequest) use (&$thrown): FatalRequestException { + return $thrown = new FatalRequestException(new Exception('Connection refused.'), $pendingRequest); + }, + ), + MockResponse::make(['name' => 'Sam']), + ]); + $request = new RetryUserRequest(2); + $request->middleware()->onFatalException(function (FatalRequestException $exception) use (&$fatalExceptions): void { + $fatalExceptions[] = $exception; + }); + + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertSame(['name' => 'Sam'], $response->json()); + $this->assertSame([$thrown], $fatalExceptions); + } + + // Upstream waits in real time and separately asserts its custom sleep handler; Sleep::fake() covers both. + public function testAFailedRequestCanHaveAnIntervalBetweenEachAttempt(): void + { + Sleep::fake(); + + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + (new TestConnector)->send(new RetryUserRequest(3, 200), $mockClient); + + Sleep::assertSequence([ + Sleep::for(200)->milliseconds(), + Sleep::for(200)->milliseconds(), + ]); + $mockClient->assertSentCount(3); + } + + // REMOVED: SendAndRetryTest. Connector::sendAndRetry() is deprecated upstream and its cases repeat this file's. + // Its exponential backoff case is kept here using a delay closure instead of $useExponentialBackoff. + public function testAFailedRequestCanHaveAnIntervalWithExponentialBackoffBetweenEachAttempt(): void + { + Sleep::fake(); + + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Michael'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + (new TestConnector)->send( + new RetryUserRequest(4, static fn (int $attempt): int => 1000 * 2 ** ($attempt - 1)), + $mockClient, + ); + + Sleep::assertSequence([ + Sleep::for(1000)->milliseconds(), + Sleep::for(2000)->milliseconds(), + Sleep::for(4000)->milliseconds(), + ]); + } + + public function testAnExceptionOtherThanARequestExceptionWillNotBeRetried(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + $request = new RetryUserRequest(3); + $request->middleware()->onResponse(fn (): never => throw new Exception('Yee-naw!')); + + try { + (new TestConnector)->send($request, $mockClient); + + $this->fail('The request did not throw.'); + } catch (Exception $exception) { + $this->assertSame('Yee-naw!', $exception->getMessage()); + } + + $mockClient->assertSentCount(1); + } + + public function testYouCanCustomiseIfTheMethodShouldRetry(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $this->expectException(InternalServerErrorException::class); + $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Gareth\"}\n"); + + (new TestConnector)->send(new RetryUserRequest(3, when: function (RequestException $exception): bool { + return $exception->response()->json() !== ['name' => 'Gareth']; + }), $mockClient); + } + + public function testIfTheHandleRetryReturnsFalseItWillThrowAnException(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $this->expectException(InternalServerErrorException::class); + $this->expectExceptionMessageIs("HTTP request returned status code 500:\n{\"name\":\"Sam\"}\n"); + + (new TestConnector)->send(new RetryUserRequest(3, when: fn (): bool => false), $mockClient); + } + + public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledItWillReturnAResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $response = (new TestConnector)->send( + new RetryUserRequest(3, when: fn (): bool => false, throw: false), + $mockClient, + ); + + $this->assertSame(500, $response->status()); + $this->assertSame(['name' => 'Sam'], $response->json()); + } + + public function testIfTheHandleRetryReturnsFalseAndThrowOptionIsDisabledButAFatalRequestExceptionHappensItWillStillThrow(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500)->throw( + fn (PendingRequest $pendingRequest): FatalRequestException => new FatalRequestException(new Exception, $pendingRequest), + ), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + + $this->expectException(FatalRequestException::class); + + (new TestConnector)->send(new RetryUserRequest(3, when: fn (): bool => false, throw: false), $mockClient); + } + + public function testYouCanModifyTheRequestInsideTheRetryHandler(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + MockResponse::make(['name' => 'Teodor'], 200), + ]); + $index = 0; + + $response = (new TestConnector)->send(new RetryUserRequest(5, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ) use (&$index): bool { + ++$index; + + $pendingRequest->request()->replaceHeaders(['X-Test-Index' => (string) $index]); + + return true; + }), $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Teodor'], $response->json()); + $this->assertSame('2', $response->pendingRequest()->headers()['X-Test-Index']); + } + + public function testYouCanAuthenticateTheRequestInsideTheRetryHandler(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 401), + MockResponse::make(['name' => 'Gareth'], 200), + ]); + + $response = (new TestConnector)->send(new RetryUserRequest(2, when: function ( + RequestException $exception, + PendingRequest $pendingRequest, + ): bool { + $pendingRequest->request()->authenticate(new TokenAuthenticator('newToken')); + + return true; + }), $mockClient); + + $this->assertSame(200, $response->status()); + $this->assertSame(['name' => 'Gareth'], $response->json()); + $this->assertSame('Bearer newToken', $response->pendingRequest()->headers()['Authorization']); + } + + public function testTheResponsePipelineIsOnlyExecutedOnceWhenRetrying(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 500), + ]); + $counter = 0; + $request = new RetryUserRequest(2, throw: false); + $request->middleware()->onResponse(function () use (&$counter): void { + ++$counter; + }); + + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertSame(500, $response->status()); + $this->assertSame(['name' => 'Gareth'], $response->json()); + + // Counter should be 2 as we have sent two requests. + $this->assertSame(2, $counter); + } + + public function testAnExplicitRetryReplacesTheRequestsDefaultRetryPolicy(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam'], 500), + MockResponse::make(['name' => 'Gareth'], 200), + ]); + + $response = (new TestConnector)->send((new RetryUserRequest(2))->retry(1), $mockClient); + + $this->assertSame(500, $response->status()); + $mockClient->assertSentCount(1); + } + + public function testARetrySendsAResourceBodyAgainFromWhereTheFirstAttemptStarted(): void + { + $resource = fopen('php://memory', 'rw+'); + fwrite($resource, 'skipped:payload'); + fseek($resource, 8); + $sent = $this->fakeOneFailedUpload(); + + $response = (new TestConnector)->send((new UserRequest)->withBody($resource, 'text/plain')->retry(2)); + + $this->assertSame(200, $response->status()); + $this->assertSame(['payload', 'payload'], $sent->getArrayCopy()); + } + + public function testARetrySendsAttachedResourcesAgain(): void + { + $resource = fopen('php://memory', 'rw+'); + fwrite($resource, 'payload'); + rewind($resource); + $sent = $this->fakeOneFailedUpload(); + + $response = (new TestConnector)->send((new UserRequest)->attach('file', $resource, 'file.txt')->retry(2)); + + $this->assertSame(200, $response->status()); + $this->assertCount(2, $sent); + $this->assertStringContainsString('name="file"; filename="file.txt"', $sent[0]); + $this->assertStringContainsString("\r\n\r\npayload\r\n", $sent[0]); + $this->assertSame($sent[0], $sent[1]); + } + + public function testANonSeekableBodyIsNotRetried(): void + { + $sent = $this->fakeOneFailedUpload(); + $request = (new UserRequest)->withBody(new NoSeekStream(Utils::streamFor('payload')), 'text/plain')->retry(2); + + try { + (new TestConnector)->send($request); + $this->fail('A non-seekable body was retried.'); + } catch (BodyException $exception) { + $this->assertSame('The request body is not seekable and cannot be retried safely.', $exception->getMessage()); + $this->assertInstanceOf(InternalServerErrorException::class, $exception->getPrevious()); + $this->assertSame(['payload'], $sent->getArrayCopy()); + } + } + + // Upstream raises negative attempts and intervals to their minimum. Hypervel rejects them; a zero interval is + // valid and is the default. + #[DataProvider('invalidRetryPolicies')] + public function testInvalidAttemptsOrIntervalsAreRejected(int $times, int $sleepMilliseconds, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs($message); + + (new UserRequest)->retry($times, $sleepMilliseconds); + } + + /** + * Get the invalid retry attempts and intervals. + * + * @return array + */ + public static function invalidRetryPolicies(): array + { + return [ + 'zero attempts' => [0, 0, 'Retry attempts must contain at least one attempt.'], + 'negative attempts' => [-1, 0, 'Retry attempts must contain at least one attempt.'], + 'negative interval' => [2, -1, 'The retry delay must be a non-negative integer.'], + ]; + } + + /** + * Fake a transport that reads each request body from its current position and fails the first attempt. + * + * @return ArrayObject + */ + protected function fakeOneFailedUpload(): ArrayObject + { + $sent = new ArrayObject; + + Http::fake(function (HttpRequest $request) use ($sent): PromiseInterface { + $sent[] = $request->toPsrRequest()->getBody()->getContents(); + + return Http::response(status: count($sent) === 1 ? 500 : 200); + }); + + return $sent; + } +} diff --git a/tests/Saloon/Feature/SimulatedResponsePayloadTest.php b/tests/Saloon/Feature/SimulatedResponsePayloadTest.php new file mode 100644 index 0000000000..52c80505ec --- /dev/null +++ b/tests/Saloon/Feature/SimulatedResponsePayloadTest.php @@ -0,0 +1,43 @@ + 'Sam'], 200, ['X-Greeting' => 'Howdy']), + ]); + + $fakeResponse = new FakeResponse(['name' => 'Gareth'], 201, ['X-Greeting' => 'Hello']); + + $request = new UserRequest; + $request->middleware()->onRequest(fn (): FakeResponse => $fakeResponse); + + $response = TestConnector::make()->send($request, $mockClient); + + $this->assertSame(['name' => 'Gareth'], $response->json()); + $this->assertSame(201, $response->status()); + $this->assertSame('Hello', $response->header('X-Greeting')); + } +} diff --git a/tests/Saloon/Http/SoloRequestTest.php b/tests/Saloon/Feature/SoloRequestTest.php similarity index 51% rename from tests/Saloon/Http/SoloRequestTest.php rename to tests/Saloon/Feature/SoloRequestTest.php index 1d245ca031..91457366f7 100644 --- a/tests/Saloon/Http/SoloRequestTest.php +++ b/tests/Saloon/Feature/SoloRequestTest.php @@ -2,26 +2,28 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Feature; -use Hypervel\Container\Container; -use Hypervel\Contracts\Cache\Factory as CacheFactory; -use Hypervel\Contracts\Config\Repository as ConfigRepository; -use Hypervel\Events\Dispatcher; -use Hypervel\Http\Client\Factory; -use Hypervel\RateLimiter\RateLimiter; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Connectors\NullConnector; use Hypervel\Saloon\Http\Faking\MockClient; use Hypervel\Saloon\Http\Faking\MockResponse; -use Hypervel\Saloon\Http\Sender; use Hypervel\Saloon\Http\SoloRequest; -use Hypervel\Saloon\SaloonManager; -use Hypervel\Tests\TestCase; -use Mockery as m; +use Hypervel\Saloon\SaloonServiceProvider; +use Hypervel\Testbench\TestCase; +// Upstream's cases send real requests and are in tests/Integration/Saloon/Feature/SoloRequestTest.php. class SoloRequestTest extends TestCase { + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return [SaloonServiceProvider::class]; + } + public function testItSendsAnAbsoluteEndpointThroughTheNormalLifecycle(): void { $request = new SoloRequestStub; @@ -36,26 +38,14 @@ public function testItSendsAnAbsoluteEndpointThroughTheNormalLifecycle(): void $this->assertSame('complete', $response->body()); } - protected function setUp(): void + public function testItCanBePreparedThroughItsConnector(): void { - parent::setUp(); + $request = new SoloRequestStub; + $pendingRequest = $request->createPendingRequest(); - $http = new Factory; - $http->registerConnection('saloon'); - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('string') - ->with('saloon.connection.name') - ->andReturn('saloon'); - $manager = new SaloonManager( - new Sender($http, $config), - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - $config, - new Dispatcher, - ); - $container = new Container; - $container->instance('saloon', $manager); - Container::setInstance($container); + $this->assertSame($request, $pendingRequest->request()); + $this->assertSame($request->connector(), $pendingRequest->connector()); + $this->assertSame('https://api.example.com/users', (string) $pendingRequest->uri()); } } @@ -63,6 +53,9 @@ class SoloRequestStub extends SoloRequest { protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return 'https://api.example.com/users'; diff --git a/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php b/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php new file mode 100644 index 0000000000..fc3ef671cf --- /dev/null +++ b/tests/Saloon/Fixtures/Authenticators/CustomOAuthAuthenticator.php @@ -0,0 +1,30 @@ +greeting; + } +} diff --git a/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php b/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php new file mode 100644 index 0000000000..75888df3f9 --- /dev/null +++ b/tests/Saloon/Fixtures/Authenticators/PizzaAuthenticator.php @@ -0,0 +1,30 @@ +replaceHeaders(['X-Pizza' => $this->pizza, 'X-Drink' => $this->drink]); + + $pendingRequest->withOptions(['debug' => true]); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php b/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php new file mode 100644 index 0000000000..1db81b104a --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/AbsoluteTokenOAuthConnector.php @@ -0,0 +1,35 @@ +body(), 'Error:'); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php b/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php new file mode 100644 index 0000000000..be4d2f0405 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php @@ -0,0 +1,30 @@ + $config + */ + public function __construct(protected array $config = []) + { + } + + /** + * Define the base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://oauth.saloon.dev'; + } + + /** + * Define default Oauth config. + */ + protected function defaultOauthConfig(): OAuthConfig + { + return new OAuthConfig(...[ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + ...$this->config, + ]); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/ConfigConnector.php b/tests/Saloon/Fixtures/Connectors/ConfigConnector.php new file mode 100644 index 0000000000..8c4c9e4869 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/ConfigConnector.php @@ -0,0 +1,31 @@ + false, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php b/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php new file mode 100644 index 0000000000..f5d7d42883 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/CustomBaseUrlConnector.php @@ -0,0 +1,27 @@ +baseUrl; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php b/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php new file mode 100644 index 0000000000..2ec49dc6ec --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/CustomExceptionConnector.php @@ -0,0 +1,32 @@ +body(), 'Error:'); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php b/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php new file mode 100644 index 0000000000..3b54602abe --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/CustomRequestClientCredentialsConnector.php @@ -0,0 +1,22 @@ + $scopes + */ + protected function resolveAccessTokenRequest(OAuthConfig $oauthConfig, array $scopes = [], string $scopeSeparator = ' '): Request + { + return new CustomClientCredentialsAccessTokenRequest($oauthConfig, $scopes, $scopeSeparator); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php b/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php new file mode 100644 index 0000000000..439b134be5 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/CustomRequestOAuth2Connector.php @@ -0,0 +1,62 @@ +greeting, $refreshToken, $expiresAt); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php b/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php new file mode 100644 index 0000000000..e1dfb30a59 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/DefaultAuthenticatorConnector.php @@ -0,0 +1,41 @@ +url ?? TestConnector::API_URL; + } + + /** + * Define the base headers that will be applied in every request. + * + * @return string[] + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } + + /** + * Define the default body. + * + * @return string[] + */ + protected function defaultBody(): array + { + return [ + 'name' => 'Gareth', + 'drink' => 'Moonshine', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php b/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php new file mode 100644 index 0000000000..d39c42f1c2 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/HasMultipartBodyConnector.php @@ -0,0 +1,56 @@ +url ?? TestConnector::API_URL; + } + + /** + * Define the base headers that will be applied in every request. + * + * @return string[] + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } + + /** + * Define the default body. + * + * @return list + */ + protected function defaultBody(): array + { + return [ + new MultipartValue('nickname', 'Gareth', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + new MultipartValue('drink', 'Moonshine', 'moonshine.txt', ['X-My-Head' => 'Spinning!']), + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/HeaderConnector.php b/tests/Saloon/Fixtures/Connectors/HeaderConnector.php new file mode 100644 index 0000000000..8cfcf39015 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/HeaderConnector.php @@ -0,0 +1,32 @@ + 'Sam', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/ModifiedPsrRequestConnector.php b/tests/Saloon/Fixtures/Connectors/ModifiedPsrRequestConnector.php new file mode 100644 index 0000000000..b94a5d2a1a --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/ModifiedPsrRequestConnector.php @@ -0,0 +1,20 @@ +withUri(new Uri('https://google.com')); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/NoConfigAuthCodeConnector.php b/tests/Saloon/Fixtures/Connectors/NoConfigAuthCodeConnector.php new file mode 100644 index 0000000000..ece8b514d4 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/NoConfigAuthCodeConnector.php @@ -0,0 +1,39 @@ + $config + */ + public function __construct(protected array $config = []) + { + } + + /** + * Define the base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://oauth.saloon.dev'; + } + + /** + * Define an OAuth configuration that is empty unless arguments are given. + */ + protected function defaultOAuthConfig(): OAuthConfig + { + return new OAuthConfig(...['clientId' => '', 'clientSecret' => '', ...$this->config]); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php b/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php new file mode 100644 index 0000000000..45d23766e5 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/NoConfigClientCredentialsConnector.php @@ -0,0 +1,39 @@ + $config + */ + public function __construct(protected array $config = []) + { + } + + /** + * Define the base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://oauth.saloon.dev'; + } + + /** + * Define an OAuth configuration that is empty unless arguments are given. + */ + protected function defaultOAuthConfig(): OAuthConfig + { + return new OAuthConfig(...['clientId' => '', 'clientSecret' => '', ...$this->config]); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php b/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php new file mode 100644 index 0000000000..89120d8696 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/OAuth2Connector.php @@ -0,0 +1,44 @@ + $config + */ + public function __construct(protected array $config = []) + { + } + + /** + * Define the base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://oauth.saloon.dev'; + } + + /** + * Define default Oauth config. + */ + protected function defaultOauthConfig(): OAuthConfig + { + return new OAuthConfig(...[ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://my-app.saloon.dev/auth/callback', + ...$this->config, + ]); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php b/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php new file mode 100644 index 0000000000..6774d40633 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/QueryParameterConnector.php @@ -0,0 +1,41 @@ +url)) { + $this->url = TestConnector::API_URL; + } + } + + /** + * Define the base url of the api. + */ + public function resolveBaseUrl(): string + { + return $this->url; + } + + /** + * Define the default query parameters. + */ + protected function defaultQuery(): array + { + return [ + 'sort' => 'first_name', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/RequestSelectionConnector.php b/tests/Saloon/Fixtures/Connectors/RequestSelectionConnector.php new file mode 100644 index 0000000000..771831a8d7 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/RequestSelectionConnector.php @@ -0,0 +1,38 @@ + $times + */ + public function __construct( + protected array|int $times = 1, + protected Closure|int $sleepMilliseconds = 0, + protected ?Closure $when = null, + protected bool $throw = true, + ?string $url = null, + ) { + parent::__construct($url); + } + + /** + * Resolve the default retry policy. + */ + protected function defaultRetryPolicy(): ?RetryPolicy + { + return new RetryPolicy($this->times, $this->sleepMilliseconds, $this->when, $this->throw); + } +} diff --git a/tests/Saloon/Fixtures/Connectors/TestConnector.php b/tests/Saloon/Fixtures/Connectors/TestConnector.php new file mode 100644 index 0000000000..a9047fef67 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/TestConnector.php @@ -0,0 +1,45 @@ +url ?? self::API_URL; + } + + /** + * Define the base headers that will be applied in every request. + * + * @return string[] + */ + protected function defaultHeaders(): array + { + return [ + 'Accept' => 'application/json', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php b/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php new file mode 100644 index 0000000000..a620176f19 --- /dev/null +++ b/tests/Saloon/Fixtures/Connectors/TimeoutConnector.php @@ -0,0 +1,37 @@ +json()); + } +} diff --git a/tests/Saloon/Fixtures/Data/User.php b/tests/Saloon/Fixtures/Data/User.php new file mode 100644 index 0000000000..b02276d899 --- /dev/null +++ b/tests/Saloon/Fixtures/Data/User.php @@ -0,0 +1,30 @@ +json(); + + return new static($data['name'], $data['actual_name'], $data['twitter']); + } +} diff --git a/tests/Saloon/Fixtures/Data/UserWithResponse.php b/tests/Saloon/Fixtures/Data/UserWithResponse.php new file mode 100644 index 0000000000..790aba5159 --- /dev/null +++ b/tests/Saloon/Fixtures/Data/UserWithResponse.php @@ -0,0 +1,34 @@ +json(); + + return new static($data['name'], $data['actual_name'], $data['twitter']); + } +} diff --git a/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php b/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php new file mode 100644 index 0000000000..f64e50e62d --- /dev/null +++ b/tests/Saloon/Fixtures/Exceptions/ConnectorRequestException.php @@ -0,0 +1,19 @@ +pendingRequest = $pendingRequest; + + parent::__construct($message); + } + + /** + * Get the pending request. + */ + public function getPendingRequest(): PendingRequest + { + return $this->pendingRequest; + } +} diff --git a/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php b/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php new file mode 100644 index 0000000000..1559089a2b --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/BeforeSaveUserFixture.php @@ -0,0 +1,29 @@ +statusCode = 222; + + return $recordedResponse; + } +} diff --git a/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php b/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php new file mode 100644 index 0000000000..8e2b80415e --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/CallableMockResponse.php @@ -0,0 +1,19 @@ + $pendingRequest->request()::class], 200); + } +} diff --git a/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php b/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php new file mode 100644 index 0000000000..1883ff6be3 --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/MissingNameFixture.php @@ -0,0 +1,11 @@ + '**REDACTED-TWITTER**', + // The name Sam + '/Sam/' => fn (string $value): string => substr_replace($value, 'xxx', 1), + ]; + } +} diff --git a/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php b/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php new file mode 100644 index 0000000000..5627e16abc --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/SafeUserFixture.php @@ -0,0 +1,49 @@ + 'secret', + + // Check for case-insensitive + 'cache-control' => function (string $value): string { + return $value . ', yeehaw'; + }, + ]; + } + + /** + * Swap any sensitive JSON parameters. + */ + protected function defineSensitiveJsonParameters(): array + { + return [ + // You can also define callables that should be run to replace the value! + + 'name' => static function (string $value): string { + return substr_replace($value, 'xxx', 1); + }, + 'actual_name' => 'REDACTED', + 'twitter' => '@saloonphp', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php b/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php new file mode 100644 index 0000000000..5a69de4c83 --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/SuperheroFixture.php @@ -0,0 +1,28 @@ + 'REDACTED', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Mocking/UnseekableBodyMockResponse.php b/tests/Saloon/Fixtures/Mocking/UnseekableBodyMockResponse.php new file mode 100644 index 0000000000..85481b4f53 --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/UnseekableBodyMockResponse.php @@ -0,0 +1,27 @@ +withBody(new NoSeekStream($response->getBody())); + } +} diff --git a/tests/Saloon/Fixtures/Mocking/UserFixture.php b/tests/Saloon/Fixtures/Mocking/UserFixture.php new file mode 100644 index 0000000000..5dc03c1bf4 --- /dev/null +++ b/tests/Saloon/Fixtures/Mocking/UserFixture.php @@ -0,0 +1,18 @@ +withToken('plugin-auth'); + } +} diff --git a/tests/Saloon/Fixtures/Plugins/WithBootTestPlugin.php b/tests/Saloon/Fixtures/Plugins/WithBootTestPlugin.php new file mode 100644 index 0000000000..db0b5cee84 --- /dev/null +++ b/tests/Saloon/Fixtures/Plugins/WithBootTestPlugin.php @@ -0,0 +1,21 @@ +request(); + + $pendingRequest->withHeader('X-Plugin-User-Id', $request->userId); + $pendingRequest->withHeader('X-Plugin-Group-Id', $request->groupId); + } +} diff --git a/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php b/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php new file mode 100644 index 0000000000..6686a9a78a --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/AbsoluteEndpointRequest.php @@ -0,0 +1,45 @@ +endpoint; + } + + /** + * Resolve whether this request may replace the connector base URL. + */ + public function allowsBaseUrlOverride(): ?bool + { + return $this->allowBaseUrlOverride; + } +} diff --git a/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php b/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php new file mode 100644 index 0000000000..5af9ac8f6e --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/AlwaysHasFailureRequest.php @@ -0,0 +1,33 @@ +body(), 'Yee-naw:'); + } +} diff --git a/tests/Saloon/Fixtures/Requests/BootAuthenticatorRequest.php b/tests/Saloon/Fixtures/Requests/BootAuthenticatorRequest.php new file mode 100644 index 0000000000..34b958827d --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/BootAuthenticatorRequest.php @@ -0,0 +1,36 @@ +withToken('howdy-partner'); + } +} diff --git a/tests/Saloon/Fixtures/Requests/ConfigRequest.php b/tests/Saloon/Fixtures/Requests/ConfigRequest.php new file mode 100644 index 0000000000..f660f875e3 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/ConfigRequest.php @@ -0,0 +1,36 @@ + false, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php b/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php new file mode 100644 index 0000000000..b916c4e0f9 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/CustomEndpointRequest.php @@ -0,0 +1,39 @@ +endpoint; + } + + /** + * Set an endpoint. + */ + public function setEndpoint(string $endpoint): CustomEndpointRequest + { + $this->endpoint = $endpoint; + + return $this; + } +} diff --git a/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php b/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php new file mode 100644 index 0000000000..ba94b855f8 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/CustomExceptionUserRequest.php @@ -0,0 +1,35 @@ +body(), 'Yee-naw:'); + } +} diff --git a/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php b/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php new file mode 100644 index 0000000000..80ec2e6cba --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/CustomResponseConnectorRequest.php @@ -0,0 +1,26 @@ + 'Luke Combs', + ]; + } + + /** + * Define the default query parameters. + */ + protected function defaultQuery(): array + { + return [ + 'format' => 'json', + ]; + } + + // Upstream's defaultData() is removed: nothing calls it, so the request has no default body. + + /** + * Define the default request options. + */ + protected function defaultOptions(): array + { + return [ + 'debug' => true, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php b/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php new file mode 100644 index 0000000000..be3762a38e --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/DifferentServiceUserRequest.php @@ -0,0 +1,26 @@ + 'Sam', + 'catchphrase' => 'Yeehaw!', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php new file mode 100644 index 0000000000..723bb7b6a9 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/HasJsonBodyRequest.php @@ -0,0 +1,40 @@ + 'Sam', + 'catchphrase' => 'Yeehaw!', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php new file mode 100644 index 0000000000..90d5aa68f8 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/HasMultipartBodyRequest.php @@ -0,0 +1,40 @@ + + */ + protected function defaultBody(): array + { + return [ + new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php b/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php new file mode 100644 index 0000000000..dc705d09ff --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/HasStreamBodyRequest.php @@ -0,0 +1,43 @@ +Howdy

'; + } +} diff --git a/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php b/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php new file mode 100644 index 0000000000..4512b9c74f --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/HeaderErrorRequest.php @@ -0,0 +1,24 @@ + 'Howdy', + ]; + } + + /** + * Define the default request options. + */ + protected function defaultOptions(): array + { + return [ + 'timeout' => 5, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/InvalidResponseClass.php b/tests/Saloon/Fixtures/Requests/InvalidResponseClass.php new file mode 100644 index 0000000000..2c4aaed175 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/InvalidResponseClass.php @@ -0,0 +1,30 @@ +withHeader('X-Howdy', 'Yeehaw'); + } +} diff --git a/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php b/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php new file mode 100644 index 0000000000..d9a67bbe36 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/NotFoundFailedRequest.php @@ -0,0 +1,40 @@ +status() === 404) { + return false; + } + + return $response->serverError() || $response->clientError(); + } +} diff --git a/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php b/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php new file mode 100644 index 0000000000..4cfe37ca7d --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/NullHeaderRequest.php @@ -0,0 +1,34 @@ + null, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/OAuth/CustomAccessTokenRequest.php b/tests/Saloon/Fixtures/Requests/OAuth/CustomAccessTokenRequest.php new file mode 100644 index 0000000000..0ee85faefa --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/OAuth/CustomAccessTokenRequest.php @@ -0,0 +1,11 @@ + 'date_of_birth', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/PagedSuperheroRequest.php b/tests/Saloon/Fixtures/Requests/PagedSuperheroRequest.php new file mode 100644 index 0000000000..4a733b6830 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/PagedSuperheroRequest.php @@ -0,0 +1,24 @@ + 'user', + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php b/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php new file mode 100644 index 0000000000..195cd50c5e --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/QueryParameterRequest.php @@ -0,0 +1,43 @@ +endpoint; + } + + /** + * Define the default query parameters. + */ + protected function defaultQuery(): array + { + return [ + 'per_page' => 100, + ]; + } +} diff --git a/tests/Saloon/Fixtures/Requests/RequiresAuthRequest.php b/tests/Saloon/Fixtures/Requests/RequiresAuthRequest.php new file mode 100644 index 0000000000..925fb02401 --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/RequiresAuthRequest.php @@ -0,0 +1,34 @@ + $times + */ + public function __construct( + protected array|int $times = 1, + protected Closure|int $sleepMilliseconds = 0, + protected ?Closure $when = null, + protected bool $throw = true, + ) { + } + + /** + * Resolve the default retry policy. + */ + protected function defaultRetryPolicy(): ?RetryPolicy + { + return new RetryPolicy($this->times, $this->sleepMilliseconds, $this->when, $this->throw); + } +} diff --git a/tests/Saloon/Fixtures/Requests/SubRequest.php b/tests/Saloon/Fixtures/Requests/SubRequest.php new file mode 100644 index 0000000000..ccbe461e9c --- /dev/null +++ b/tests/Saloon/Fixtures/Requests/SubRequest.php @@ -0,0 +1,9 @@ +connector->send(new UserRequest)->json(); + } +} diff --git a/tests/Saloon/Fixtures/Responses/CustomResponse.php b/tests/Saloon/Fixtures/Responses/CustomResponse.php new file mode 100644 index 0000000000..f28fa3de93 --- /dev/null +++ b/tests/Saloon/Fixtures/Responses/CustomResponse.php @@ -0,0 +1,11 @@ +json('foo')); + } + + /** + * Get the foo value. + */ + public function foo(): ?string + { + return $this->json('foo'); + } +} diff --git a/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php b/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php new file mode 100644 index 0000000000..310ff0b0e1 --- /dev/null +++ b/tests/Saloon/Fixtures/Responses/UserResponseNoExtendSaloonResponse.php @@ -0,0 +1,9 @@ +authenticate(new CookieAuthenticator('session', 'secret', $domain)), - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); - - $pendingRequest->applyAuthentication(); - - $this->assertCount(1, $pendingRequest->cookies()); - $cookie = $pendingRequest->cookies()[0]; - $this->assertSame('session', $cookie['Name']); - $this->assertSame('secret', $cookie['Value']); - $this->assertSame($expected, ltrim($cookie['Domain'], '.')); - $this->assertSame($domain === null, $cookie['HostOnly'] ?? false); - $this->assertSame($scheme === 'https', $cookie['Secure']); - $this->assertTrue($cookie['Discard']); - $this->assertTrue((new SetCookie($cookie))->matchesDomain('api.example.com')); - $this->assertSame($domain !== null, (new SetCookie($cookie))->matchesDomain('other.example.com')); - } - - /** - * Provide inferred and explicitly scoped cookie domains. - */ - public static function cookieDomains(): array - { - return [ - 'inferred HTTPS' => [null, 'api.example.com', 'https'], - 'explicit HTTPS' => ['.example.com', 'example.com', 'https'], - 'inferred HTTP' => [null, 'api.example.com', 'http'], - 'explicit HTTP' => ['.example.com', 'example.com', 'http'], - ]; - } - - public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void - { - $http = new Factory; - $http->registerConnection('saloon'); - $cookies = []; - $http->fake(function (HttpRequest $request, array $options) use (&$cookies) { - $cookies[] = $options['cookies']->toArray(); - - return Factory::response('', count($cookies) === 1 ? 500 : 200); - }); - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('string')->with('saloon.connection.name')->andReturn('saloon'); - $manager = new SaloonManager( - new Sender($http, $config), - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - $config, - new Dispatcher, - ); - $request = (new CookieAuthRequestStub) - ->authenticate(new CookieAuthenticator('session', 'original')) - ->authenticate(new CookieAuthenticator('session', 'request')) - ->retry(2); - $pendingCookies = []; - $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void { - $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement')); - $pendingCookies[] = $pendingRequest->cookies(); - }); - - $response = $manager->send(new CookieAuthConnectorStub, $request); - - $this->assertSame(200, $response->status()); - $this->assertCount(2, $cookies); - foreach ($cookies as $attemptCookies) { - $this->assertCount(1, $attemptCookies); - $this->assertSame('session', $attemptCookies[0]['Name']); - $this->assertSame('replacement', $attemptCookies[0]['Value']); - $this->assertSame('api.example.com', $attemptCookies[0]['Domain']); - $this->assertTrue($attemptCookies[0]['HostOnly']); - $this->assertTrue($attemptCookies[0]['Secure']); - $this->assertTrue($attemptCookies[0]['Discard']); - } - $this->assertSame($pendingCookies[0], $pendingCookies[1]); - $this->assertCount(2, $pendingCookies[0]); - $this->assertSame([], $request->cookies()); - } -} - -class CookieAuthConnectorStub extends Connector -{ - /** - * Set the API base URL. - */ - public function __construct(protected string $baseUrl = 'https://api.example.com') - { - } - - /** - * Resolve the API base URL. - */ - public function resolveBaseUrl(): string - { - return $this->baseUrl; - } -} - -class CookieAuthRequestStub extends Request -{ - protected Method $method = Method::GET; - - /** - * Resolve the request endpoint. - */ - public function resolveEndpoint(): string - { - return '/users'; - } -} diff --git a/tests/Saloon/Http/BodyTraitsTest.php b/tests/Saloon/Http/BodyTraitsTest.php deleted file mode 100644 index b35c7f8be7..0000000000 --- a/tests/Saloon/Http/BodyTraitsTest.php +++ /dev/null @@ -1,252 +0,0 @@ -Taylor'], - ]; - - foreach ($cases as [$request, $contentType, $body]) { - $pendingRequest = $this->prepare($request); - - $this->assertSame($contentType, $pendingRequest->headers()['Content-Type']); - $this->assertSame($body, (string) $pendingRequest->preparedBody()); - } - } - - public function testExplicitContentTypeWinsTheBodyTraitDefault(): void - { - $pendingRequest = $this->prepare( - (new BodyTraitsJsonRequest)->withHeader('Content-Type', 'application/vnd.api+json'), - ); - - $this->assertSame('application/vnd.api+json', $pendingRequest->headers()['Content-Type']); - } - - public function testRawBodyTraitsKeepExactBytesWithoutInventingAContentType(): void - { - foreach ([new BodyTraitsStringRequest, new BodyTraitsStreamRequest] as $request) { - $pendingRequest = $this->prepare($request); - - $this->assertArrayNotHasKey('Content-Type', $pendingRequest->headers()); - $this->assertSame('Taylor', (string) $pendingRequest->preparedBody()); - } - } - - public function testMultipartDefaultsUseTheMergedRepositoryAndItsBoundary(): void - { - $pendingRequest = $this->prepare( - new BodyTraitsMultipartRequest, - new BodyTraitsMultipartConnector, - ); - - $contentType = $pendingRequest->headers()['Content-Type']; - $body = (string) $pendingRequest->preparedBody(); - - $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); - $this->assertStringContainsString('--' . substr($contentType, strlen('multipart/form-data; boundary=')), $body); - $this->assertStringContainsString('name="connector"', $body); - $this->assertStringContainsString("\r\nconnector\r\n", $body); - $this->assertStringContainsString('name="request"', $body); - $this->assertStringContainsString("\r\nrequest\r\n", $body); - } - - public function testCustomBodyRepositoriesExposeTheirResolvedValue(): void - { - $this->assertSame('custom', (new BodyTraitsCustomRequest)->body()); - } - - /** - * Prepare a request with isolated framework dependencies. - */ - protected function prepare(Request $request, ?Connector $connector = null): PendingRequest - { - return (new PendingRequest( - $connector ?? new BodyTraitsConnector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ))->bootPlugins()->prepareBody(); - } -} - -class BodyTraitsConnector extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } -} - -class BodyTraitsMultipartConnector extends BodyTraitsConnector -{ - use HasMultipartBody; - - protected function defaultBody(): array - { - return [new MultipartValue('connector', 'connector')]; - } -} - -class BodyTraitsJsonRequest extends Request -{ - use HasJsonBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): array - { - return ['name' => 'Taylor']; - } -} - -class BodyTraitsFormRequest extends Request -{ - use HasFormBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): array - { - return ['name' => 'Taylor']; - } -} - -class BodyTraitsXmlRequest extends Request -{ - use HasXmlBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): ?string - { - return 'Taylor'; - } -} - -class BodyTraitsStringRequest extends Request -{ - use HasStringBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): ?string - { - return 'Taylor'; - } -} - -class BodyTraitsStreamRequest extends Request -{ - use HasStreamBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): StreamInterface - { - return Utils::streamFor('Taylor'); - } -} - -class BodyTraitsMultipartRequest extends Request -{ - use HasMultipartBody; - - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBody(): array - { - return [new MultipartValue('request', 'request')]; - } -} - -class BodyTraitsCustomRequest extends Request -{ - public function resolveEndpoint(): string - { - return '/users'; - } - - protected function defaultBodyRepository(): BodyRepository - { - return new CustomBodyRepository('custom'); - } -} - -class CustomBodyRepository implements BodyRepository -{ - public function __construct(protected mixed $value) - { - } - - public function set(mixed $value): static - { - $this->value = $value; - - return $this; - } - - public function all(): mixed - { - return $this->value; - } - - public function isEmpty(): bool - { - return $this->value === null; - } - - public function isNotEmpty(): bool - { - return ! $this->isEmpty(); - } - - public function toStream(): StreamInterface - { - return Utils::streamFor((string) $this->value); - } -} diff --git a/tests/Saloon/Http/DebugTest.php b/tests/Saloon/Http/DebugTest.php deleted file mode 100644 index afa4073e55..0000000000 --- a/tests/Saloon/Http/DebugTest.php +++ /dev/null @@ -1,141 +0,0 @@ -withBody('request-body', null) - ->debugRequest(function (PendingRequest $pendingRequest, RequestInterface $psrRequest) use (&$capturedRequest): void { - $capturedRequest = $psrRequest; - }); - $pendingRequest = $this->pendingRequest($request) - ->executeRequestPipeline() - ->finalizeUri() - ->prepareBody(); - - $pendingRequest->createPsrRequest(); - - $this->assertSame(1, DebugRequestStub::$psrHookCalls); - $this->assertInstanceOf(RequestInterface::class, $capturedRequest); - $this->assertSame('handled', $capturedRequest->getHeaderLine('X-Psr-Hook')); - $this->assertSame('request-body', (string) $capturedRequest->getBody()); - } - - public function testResponseDebuggerBuffersANonSeekableBodyBeforeTheCallback(): void - { - $capturedBody = null; - $request = (new DebugRequestStub)->debugResponse( - function (Response $response, ResponseInterface $psrResponse) use (&$capturedBody): void { - $capturedBody = $response->body(); - $this->assertTrue($psrResponse->getBody()->isSeekable()); - }, - ); - $pendingRequest = $this->pendingRequest($request); - $psrRequest = new PsrRequest('GET', 'https://api.example.com/users'); - $response = $pendingRequest->createResponse( - new HttpResponse(new PsrResponse(body: new NoSeekStream(Utils::streamFor('response-body')))), - $psrRequest, - ); - - $result = $pendingRequest->executeResponsePipeline($response); - - $this->assertSame('response-body', $capturedBody); - $this->assertSame('response-body', $result->body()); - } - - public function testDefaultRequestDebuggerRestoresTheSeekableBodyPosition(): void - { - $pendingRequest = $this->pendingRequest(new DebugRequestStub); - $body = Utils::streamFor('request-body'); - $body->seek(4); - $request = new PsrRequest('POST', 'https://api.example.com/users', body: $body); - - $this->assertSame('request-body', DebuggerReaderStub::readBody($body)); - - $this->assertSame(4, $body->tell()); - $this->assertSame('est-body', $body->getContents()); - } - - protected function setUp(): void - { - parent::setUp(); - - DebugRequestStub::$psrHookCalls = 0; - } - - /** - * Create a pending request with isolated framework dependencies. - */ - protected function pendingRequest(Request $request): PendingRequest - { - return new PendingRequest( - new DebugConnectorStub, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); - } -} - -class DebugConnectorStub extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } -} - -class DebugRequestStub extends Request -{ - use HasStringBody; - - public static int $psrHookCalls = 0; - - protected Method $method = Method::POST; - - public function resolveEndpoint(): string - { - return '/users'; - } - - public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface - { - ++static::$psrHookCalls; - - return $request->withHeader('X-Psr-Hook', 'handled'); - } -} - -class DebuggerReaderStub extends Debugger -{ - public static function readBody(StreamInterface $body): string - { - return static::requestBody($body); - } -} diff --git a/tests/Saloon/Http/FakeResponseTest.php b/tests/Saloon/Http/FakeResponseTest.php deleted file mode 100644 index 2f1a73cf96..0000000000 --- a/tests/Saloon/Http/FakeResponseTest.php +++ /dev/null @@ -1,43 +0,0 @@ - 'Taylor'], 201, ['X-Count' => 2]); - $text = new FakeResponse('complete'); - - $this->assertInstanceOf(JsonBodyRepository::class, $json->body()); - $this->assertSame('{"name":"Taylor"}', (string) $json->createPsrResponse()->getBody()); - $this->assertSame(201, $json->status()); - $this->assertSame(['2'], $json->createPsrResponse()->getHeader('X-Count')); - $this->assertInstanceOf(StringBodyRepository::class, $text->body()); - $this->assertSame('complete', (string) $text->createPsrResponse()->getBody()); - } - - public function testItResolvesConfiguredExceptionsAgainstThePendingRequest(): void - { - $pendingRequest = m::mock(PendingRequest::class); - $exception = new RuntimeException('Failed.'); - $response = (new FakeResponse)->throw( - fn (PendingRequest $request): RuntimeException => $request === $pendingRequest - ? $exception - : new RuntimeException('Wrong request.'), - ); - - $this->assertSame($exception, $response->getException($pendingRequest)); - $this->assertNull((new FakeResponse)->getException($pendingRequest)); - } -} diff --git a/tests/Saloon/Http/HeaderNormalizerTest.php b/tests/Saloon/Http/HeaderNormalizerTest.php deleted file mode 100644 index 99e096bd61..0000000000 --- a/tests/Saloon/Http/HeaderNormalizerTest.php +++ /dev/null @@ -1,20 +0,0 @@ -expectException(InvalidHeaderException::class); - $this->expectExceptionMessageIs('HTTP header names must be strings.'); - - HeaderNormalizer::normalize(['Content-Type', 'application/json']); - } -} diff --git a/tests/Saloon/Http/MockClientTest.php b/tests/Saloon/Http/MockClientTest.php deleted file mode 100644 index 28639e7222..0000000000 --- a/tests/Saloon/Http/MockClientTest.php +++ /dev/null @@ -1,274 +0,0 @@ -http(); - $client = new MockClient([ - new MockResponse('sequence'), - MockConnectorStub::class => new MockResponse('connector'), - 'https://api.example.com/users/*' => new MockResponse('url'), - MockRequestA::class => new MockResponse('request'), - ]); - $manager = $this->manager($http); - - $request = $manager->send(new MockConnectorStub, new MockRequestA, $client); - $connector = $manager->send(new MockConnectorStub, new MockRequestB, $client); - $url = $manager->send(new OtherMockConnectorStub, new MockRequestB, $client); - $sequence = $manager->send(new OtherMockConnectorStub, new MockRequestC, $client); - - $this->assertSame('request', $request->body()); - $this->assertSame('connector', $connector->body()); - $this->assertSame('url', $url->body()); - $this->assertSame('sequence', $sequence->body()); - $this->assertTrue($request->isMocked()); - $this->assertFalse($client->isEmpty()); - $client->assertSentCount(4); - $http->assertNothingSent(); - } - - public function testUnmatchedRequestsAreStrictUnlessTheirUrlIsAllowed(): void - { - $http = $this->http(); - $http->fake(['*' => Factory::response('network')]); - $manager = $this->manager($http); - $client = (new MockClient)->allowStrayRequests(['https://api.example.com/allowed*']); - - $allowed = $manager->send(new OtherMockConnectorStub, new AllowedMockRequest, $client); - - $this->assertSame('network', $allowed->body()); - $this->assertFalse($allowed->isMocked()); - $client->assertSentCount(1); - - $this->expectException(NoMockResponseFoundException::class); - - $manager->send(new OtherMockConnectorStub, new MockRequestC, $client); - } - - public function testExplicitRequestAndGlobalMockClientsUseTheExpectedPrecedence(): void - { - $http = $this->http(); - $manager = $this->manager($http); - $manager->fake([new MockResponse('global')]); - $requestClient = new MockClient([new MockResponse('request')]); - $explicitClient = new MockClient([new MockResponse('explicit')]); - $request = (new MockRequestA)->withMockClient($requestClient); - - $this->assertSame('explicit', $manager->send(new MockConnectorStub, $request, $explicitClient)->body()); - $this->assertSame('request', $manager->send(new MockConnectorStub, $request)->body()); - $this->assertSame('global', $manager->send(new MockConnectorStub, new MockRequestA)->body()); - } - - public function testAssertionsSupportRequestClassesUrlsAndTypedClosureUnions(): void - { - $http = $this->http(); - $manager = $this->manager($http); - $client = new MockClient([ - new MockResponse('c'), - new MockResponse('a'), - new MockResponse('b'), - ]); - $manager->send(new OtherMockConnectorStub, new MockRequestC, $client); - $manager->send(new MockConnectorStub, new MockRequestA, $client); - $manager->send(new MockConnectorStub, new MockRequestB, $client); - $typedCalls = 0; - - $client->assertSent(function (MockRequestA|MockRequestB $request) use (&$typedCalls): bool { - ++$typedCalls; - - return $request instanceof MockRequestA; - }); - $client->assertSent(MockRequestA::class); - $client->assertSent('https://api.example.com/users/*'); - $client->assertNotSent('https://api.example.com/missing'); - $client->assertSentInOrder([ - MockRequestC::class, - MockRequestA::class, - MockRequestB::class, - ]); - $client->assertSentCount(1, MockRequestA::class); - - $this->assertSame(1, $typedCalls); - $this->assertSame(MockRequestB::class, $client->lastRequest()::class); - $this->assertSame(MockRequestB::class, $client->lastPendingRequest()?->request()::class); - $this->assertCount(2, $client->recorded(fn (Request $request): bool => $request instanceof MockRequestA || $request instanceof MockRequestB)); - } - - public function testMissingFixtureRecordsTheNetworkResponseAndThenReplaysIt(): void - { - $http = $this->http(); - $http->fake(['*' => Factory::response('recorded', 201, ['X-Trace' => 'abc'])]); - $manager = $this->manager($http); - $client = new MockClient([ - MockRequestA::class => new Fixture('users/show', $this->files), - ]); - - $recorded = $manager->send(new MockConnectorStub, new MockRequestA, $client); - $replayed = $manager->send(new MockConnectorStub, new MockRequestA, $client); - - $this->assertFalse($recorded->isMocked()); - $this->assertTrue($replayed->isMocked()); - $this->assertSame('recorded', $replayed->body()); - $this->assertSame('abc', $replayed->header('X-Trace')); - $http->assertSentCount(1); - $client->assertSentCount(2); - } - - public function testGlobalClientDelegatesToTheContainerManagerWithoutStaticState(): void - { - $manager = $this->manager($this->http()); - $container = new Container; - $container->instance('saloon', $manager); - Container::setInstance($container); - - $global = MockClient::global([new MockResponse('global')]); - - $this->assertSame($global, MockClient::getGlobal()); - $this->assertSame($global, $manager->mockClient()); - - MockClient::destroyGlobal(); - - $this->assertNull(MockClient::getGlobal()); - } - - protected function setUp(): void - { - parent::setUp(); - - $this->files = new Filesystem; - $this->fixturePath = ParallelTesting::tempDir('SaloonMockClientTest'); - $this->files->deleteDirectory($this->fixturePath); - $this->files->ensureDirectoryExists($this->fixturePath); - } - - protected function tearDown(): void - { - $this->files->deleteDirectory($this->fixturePath); - - parent::tearDown(); - } - - /** - * Create an isolated HTTP factory with the Saloon connection. - */ - protected function http(): Factory - { - $http = new Factory; - $http->registerConnection('saloon'); - - return $http; - } - - /** - * Create the manager with isolated framework services. - */ - protected function manager(Factory $http): SaloonManager - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('string') - ->with('saloon.connection.name') - ->andReturn('saloon'); - $config->shouldReceive('string') - ->with('saloon.fixtures.path', m::type(Closure::class)) - ->andReturn($this->fixturePath); - $config->shouldReceive('boolean') - ->with('saloon.fixtures.throw_on_missing', false) - ->andReturn(false); - - $manager = new SaloonManager( - new Sender($http, $config), - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - $config, - new Dispatcher, - ); - - $container = new Container; - $container->instance('saloon', $manager); - Container::setInstance($container); - - return $manager; - } -} - -class MockConnectorStub extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } -} - -class OtherMockConnectorStub extends MockConnectorStub -{ -} - -class MockRequestA extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/users/a'; - } -} - -class MockRequestB extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/users/b'; - } -} - -class MockRequestC extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/other'; - } -} - -class AllowedMockRequest extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/allowed/request'; - } -} diff --git a/tests/Saloon/Http/RequestDelayTest.php b/tests/Saloon/Http/RequestDelayTest.php deleted file mode 100644 index af305d87f9..0000000000 --- a/tests/Saloon/Http/RequestDelayTest.php +++ /dev/null @@ -1,45 +0,0 @@ -request()->delay(250); - - $this->assertSame(250, $request->delayMilliseconds()); - } - - public function testItRejectsNegativeDelays(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIsOrContains('representable non-negative'); - - $this->request()->delay(-1); - } - - public function testItRejectsDelaysThatOverflowMicroseconds(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIsOrContains('representable non-negative'); - - $this->request()->delay(intdiv(PHP_INT_MAX, 1000) + 1); - } - - /** - * Create an object with an operation-owned request delay. - */ - protected function request(): object - { - return new class { - use HasDelay; - }; - } -} diff --git a/tests/Saloon/Http/RequestOptionsTest.php b/tests/Saloon/Http/RequestOptionsTest.php deleted file mode 100644 index 7679054440..0000000000 --- a/tests/Saloon/Http/RequestOptionsTest.php +++ /dev/null @@ -1,66 +0,0 @@ -request() - ->withOptions(['allow_redirects' => ['strict' => true]]) - ->maxRedirects(3); - - $this->assertSame(['strict' => true, 'max' => 3], $request->options()['allow_redirects']); - } - - public function testMaxRedirectsReenablesRedirectsWithoutDeprecation(): void - { - set_error_handler(static function (int $severity, string $message, string $file, int $line): never { - throw new ErrorException($message, 0, $severity, $file, $line); - }, E_DEPRECATED); - - try { - $request = $this->request() - ->withoutRedirecting() - ->maxRedirects(3); - - $this->assertSame(['max' => 3], $request->options()['allow_redirects']); - } finally { - restore_error_handler(); - } - } - - public function testWithoutRedirectingDisablesAConfiguredRedirectLimit(): void - { - $request = $this->request() - ->maxRedirects(3) - ->withoutRedirecting(); - - $this->assertFalse($request->options()['allow_redirects']); - } - - public function testMaxRedirectsReplacesTheBooleanEnabledForm(): void - { - $request = $this->request() - ->withOptions(['allow_redirects' => true]) - ->maxRedirects(3); - - $this->assertSame(['max' => 3], $request->options()['allow_redirects']); - } - - /** - * Create an object with operation-owned request options. - */ - protected function request(): object - { - return new class { - use HasOptions; - }; - } -} diff --git a/tests/Saloon/Http/RequestTest.php b/tests/Saloon/Http/RequestTest.php deleted file mode 100644 index 5888e6500c..0000000000 --- a/tests/Saloon/Http/RequestTest.php +++ /dev/null @@ -1,230 +0,0 @@ -make(ContainerRequestStub::class); - $second = $container->make(ContainerRequestStub::class); - - $this->assertNotSame($first, $second); - } - - public function testMakeForwardsRequiredConstructorArguments(): void - { - $request = RequiredArgumentRequestStub::make('users/1'); - - $this->assertSame('users/1', $request->resolveEndpoint()); - } - - public function testContainerResolutionOfRequiredArgumentRequestFailsNaturally(): void - { - $container = new Container; - - $this->expectException(ArgumentCountError::class); - - $container->make(RequiredArgumentRequestStub::class); - } - - public function testReplaceHeadersMatchesNamesCaseInsensitively(): void - { - $request = (new ContainerRequestStub) - ->withHeaders([ - 'Authorization' => 'Bearer old', - 'X-Keep' => 'yes', - ]) - ->replaceHeaders([ - 'authorization' => 'Bearer new', - 'AUTHORIZATION' => 'Bearer newest', - ]); - - $this->assertSame([ - 'X-Keep' => 'yes', - 'AUTHORIZATION' => 'Bearer newest', - ], $request->headers()); - } - - public function testIncomingHeaderWinsWhenAnExistingCaseVariantFollowsItsExactName(): void - { - $request = (new ContainerRequestStub) - ->withHeaders([ - 'Authorization' => 'Bearer stale exact', - 'authorization' => 'Bearer stale variant', - 'X-Keep' => 'yes', - ]) - ->replaceHeaders([ - 'Authorization' => 'Bearer new', - ]); - - $this->assertSame([ - 'X-Keep' => 'yes', - 'Authorization' => 'Bearer new', - ], $request->headers()); - } - - public function testWithHeadersRemainsAdditive(): void - { - $request = (new ContainerRequestStub) - ->withHeaders(['X-Value' => 'first']) - ->withHeaders(['X-Value' => 'second']); - - $this->assertSame(['first', 'second'], $request->headers()['X-Value']); - } - - public function testAcceptReplacesTheExistingAcceptHeader(): void - { - $request = (new ContainerRequestStub) - ->accept('text/plain') - ->acceptJson(); - - $this->assertSame('application/json', $request->headers()['Accept']); - } - - #[DataProvider('invalidCookies')] - public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIs($message); - - (new ContainerRequestStub)->withCookie(new SetCookie($cookie)); - } - - /** - * Provide cookies that cannot be sent with a request. - */ - public static function invalidCookies(): array - { - return [ - 'null domain' => [ - ['Name' => 'locale', 'Value' => 'en'], - 'An outgoing cookie must have a domain.', - ], - 'empty domain' => [ - ['Name' => 'locale', 'Value' => 'en', 'Domain' => ''], - 'Invalid cookie: The cookie domain must not be empty', - ], - 'null value' => [ - ['Name' => 'locale', 'Domain' => 'api.example.com'], - 'Invalid cookie: The cookie value must not be empty', - ], - ]; - } - - public function testCloneOwnsIndependentInitializedRequestState(): void - { - $this->assertNull((new ContainerRequestStub)->url()); - $request = (new ContainerRequestStub) - ->withUrl('https://api.example.test/users/1') - ->withHeader('X-Original', 'yes') - ->withQueryParameters(['page' => 1]) - ->withOptions(['verify' => true]) - ->delay(10) - ->withCookies(['original' => 'yes'], '.example.test') - ->retry([10]) - ->withData(['original' => true]) - ->disableCaching(); - $request->middleware()->onRequest(static fn ($pendingRequest) => $pendingRequest, 'original'); - - $clone = clone $request; - $clone - ->withUrl('https://api.example.test/users/2') - ->withHeader('X-Clone', 'yes') - ->withQueryParameters(['page' => 2]) - ->withOptions(['verify' => false]) - ->delay(20) - ->withCookies(['clone' => 'yes'], 'api.example.test') - ->retry(3, 20) - ->withData(['clone' => true]) - ->enableCaching() - ->invalidateCache(); - $clone->middleware()->onRequest(static fn ($pendingRequest) => $pendingRequest, 'clone'); - - $this->assertSame('https://api.example.test/users/1', $request->url()); - $this->assertSame('https://api.example.test/users/2', $clone->url()); - $this->assertSame('yes', $request->headers()['X-Original']); - $this->assertSame('application/json', $request->headers()['Content-Type']); - $this->assertSame(['page' => 1], $request->queryParameters()); - $this->assertTrue($request->options()['verify']); - $this->assertSame(10, $request->delayMilliseconds()); - $this->assertCount(1, $request->middleware()->requestPipeline()->pipes()); - $this->assertSame(['original' => true], $request->body()); - $this->assertSame(CookieJar::fromArray(['original' => 'yes'], '.example.test')->toArray(), $request->cookies()); - $this->assertSame([10], $request->retryPolicy()->times); - $this->assertFalse($request->cachingEnabled()); - $this->assertFalse($request->shouldInvalidateCache()); - - $this->assertSame(['original' => true, 'clone' => true], $clone->body()); - $this->assertCount(2, $clone->cookies()); - $this->assertSame(3, $clone->retryPolicy()->times); - $this->assertTrue($clone->cachingEnabled()); - $this->assertTrue($clone->shouldInvalidateCache()); - } - - public function testRawQueryDefaultsOverridesAndCloneIsolation(): void - { - $this->assertNull((new ContainerRequestStub)->queryString()); - $request = new RawQueryRequestStub; - $this->assertSame('tag=a&tag=b', $request->queryString()); - $request->withQueryString('cursor=a%2Fb')->withQueryParameters(['limit' => 10]); - $clone = clone $request; - - $this->assertSame($clone, $clone->withQueryString('')); - $this->assertSame('', $clone->queryString()); - $this->assertSame('cursor=a%2Fb', $request->queryString()); - $this->assertSame(['limit' => 10], $clone->queryParameters()); - } -} - -class ContainerRequestStub extends Request -{ - use HasCaching; - - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return 'users'; - } -} - -class RawQueryRequestStub extends ContainerRequestStub -{ - /** - * Resolve the default raw query string override. - */ - protected function defaultQueryString(): ?string - { - return 'tag=a&tag=b'; - } -} - -class RequiredArgumentRequestStub extends Request -{ - protected Method $method = Method::GET; - - public function __construct(protected string $endpoint) - { - } - - public function resolveEndpoint(): string - { - return $this->endpoint; - } -} diff --git a/tests/Saloon/Http/ResponseTest.php b/tests/Saloon/Http/ResponseTest.php deleted file mode 100644 index afca8dbca8..0000000000 --- a/tests/Saloon/Http/ResponseTest.php +++ /dev/null @@ -1,398 +0,0 @@ -response(422)->truncateExceptionsAt(37); - $serverResponse = $this->response(500)->truncateExceptionsAt(38); - $request = new ResponseRequestStub; - $request->throwVerdict = true; - $generalResponse = $this->response(200, request: $request)->truncateExceptionsAt(39); - - $clientException = $clientResponse->toException(); - $serverException = $serverResponse->toException(); - $generalException = $generalResponse->toException(); - - $this->assertInstanceOf(ClientException::class, $clientException); - $this->assertSame(37, $clientException->truncateExceptionsAt); - $this->assertInstanceOf(ServerException::class, $serverException); - $this->assertSame(38, $serverException->truncateExceptionsAt); - $this->assertInstanceOf(RequestException::class, $generalException); - $this->assertSame(39, $generalException->truncateExceptionsAt); - } - - public function testRequestFailureAndExceptionVerdictsTakePriorityOverConnectorVerdicts(): void - { - $connector = new ResponseConnectorStub; - $connector->failureVerdict = true; - $connector->customException = true; - - $request = new ResponseRequestStub; - $request->failureVerdict = false; - $request->throwVerdict = true; - $request->customException = true; - - $response = $this->response(500, connector: $connector, request: $request); - - $this->assertFalse($response->failed()); - $this->assertInstanceOf(ResponseRequestExceptionStub::class, $response->toException()); - } - - public function testFailurePoliciesDriveOnErrorAndFailureNamedThrowingHelpers(): void - { - $connector = new ResponseConnectorStub; - $connector->failureVerdict = true; - $response = $this->response(200, connector: $connector); - $called = false; - - $response->onError(function () use (&$called): void { - $called = true; - }); - - $this->assertTrue($response->successful()); - $this->assertTrue($response->failed()); - $this->assertTrue($called); - - $request = new ResponseRequestStub; - $request->failureVerdict = false; - $suppressed = $this->response(404, connector: $connector, request: $request); - - $this->assertSame($suppressed, $suppressed->throwIfClientError()); - - $this->expectException(ClientException::class); - $suppressed->throwIfStatus(404); - } - - public function testThrowPolicyCanSuppressAResponseThatStillReportsFailed(): void - { - $connector = new ResponseConnectorStub; - $connector->throwVerdict = false; - $request = new ResponseRequestStub; - $request->throwVerdict = false; - $response = $this->response(500, connector: $connector, request: $request); - - $this->assertTrue($response->failed()); - $this->assertNull($response->toException()); - $this->assertSame($response, $response->throw()); - } - - public function testAlwaysThrowOnErrorsUsesTheIntegrationThrowPolicy(): void - { - $request = new ResponseAlwaysThrowRequestStub; - $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, $request); - $request->bootAlwaysThrowOnErrors($pendingRequest); - $response = $this->response(500, request: $request, pendingRequest: $pendingRequest); - - $this->expectException(ServerException::class); - $pendingRequest->executeResponsePipeline($response); - } - - public function testResponseCanBeConvertedToADataUrl(): void - { - $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub); - $psrRequest = new PsrRequest('GET', 'https://api.example.com/users'); - $response = Response::fromResponse( - new HttpResponse(new PsrResponse(200, ['Content-Type' => 'text/plain'], 'hello')), - $pendingRequest, - $psrRequest, - ); - - $this->assertSame('data:text/plain;base64,aGVsbG8=', $response->dataUrl()); - } - - public function testNonSeekableBodyIsBufferedOnce(): void - { - $pendingRequest = $this->pendingRequest(new ResponseConnectorStub, new ResponseRequestStub); - $psrRequest = new PsrRequest('GET', 'https://api.example.com/users'); - $httpResponse = new HttpResponse(new PsrResponse( - body: new NoSeekStream(Utils::streamFor('response body')), - )); - $response = Response::fromResponse($httpResponse, $pendingRequest, $psrRequest); - - $this->assertSame('response body', $response->body()); - $this->assertSame('response body', $response->body()); - $this->assertTrue($response->stream()->isSeekable()); - $this->assertSame($psrRequest, $response->toPsrRequest()); - } - - public function testNonSeekableBodyConsumptionLeavesLinesAtTheEnd(): void - { - $response = $this->response(200, body: new NoSeekStream(Utils::streamFor("one\ntwo\n"))); - - $this->assertSame("one\ntwo\n", $response->body()); - $this->assertSame(8, $response->stream()->tell()); - $this->assertSame([], iterator_to_array($response->lines())); - - $response->stream()->rewind(); - - $this->assertSame(['one', 'two'], iterator_to_array($response->lines())); - } - - public function testJsonLinesReadANonSeekableResponseFromItsCurrentPosition(): void - { - $stream = new NoSeekStream(Utils::streamFor("skip\n{\"id\":1}\n{\"id\":2}\n")); - $stream->read(5); - $response = $this->response(200, body: $stream); - - $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($response->jsonLines())); - $this->assertSame($stream, $response->stream()); - } - - public function testBodyExportsPreservePositionsAndCallerOwnedResources(): void - { - $response = $this->response(200, body: 'response body'); - $response->stream()->seek(4); - $resource = fopen('php://temp', 'wb+'); - $this->assertIsResource($resource); - fwrite($resource, 'stale contents'); - - $response->saveBodyToFile($resource, false); - - $this->assertIsResource($resource); - $this->assertSame(4, $response->stream()->tell()); - $this->assertSame('response body', stream_get_contents($resource)); - fclose($resource); - - $rawStream = $response->getRawStream(); - $this->assertIsResource($rawStream); - $this->assertSame('response body', stream_get_contents($rawStream)); - fclose($rawStream); - } - - public function testFailedBodyExportDoesNotCloseACallerOwnedResource(): void - { - $source = m::mock(StreamInterface::class); - $source->shouldReceive('isSeekable')->once()->andReturn(false); - $source->shouldReceive('eof')->once()->andReturn(false); - $source->shouldReceive('read')->once()->andThrow(new RuntimeException('read failed')); - $response = $this->response(200, body: $source); - $resource = fopen('php://temp', 'wb+'); - $this->assertIsResource($resource); - - try { - $response->saveBodyToFile($resource, false); - $this->fail('The response source should fail while being copied.'); - } catch (RuntimeException $exception) { - $this->assertSame('read failed', $exception->getMessage()); - $this->assertIsResource($resource); - } finally { - if (is_resource($resource)) { - fclose($resource); - } - } - } - - public function testRequestDataObjectTakesPriorityOverConnectorDataObject(): void - { - $response = $this->response( - 200, - connector: new ResponseDtoConnectorStub, - request: new ResponseDtoRequestStub, - ); - - $this->assertInstanceOf(ResponseRequestDtoStub::class, $response->dto()); - } - - public function testConnectorCreatesTheDataObjectWhenTheRequestDoesNot(): void - { - $response = $this->response(200, connector: new ResponseDtoConnectorStub); - - $this->assertInstanceOf(ResponseConnectorDtoStub::class, $response->dto()); - } - - public function testDataObjectReceivesItsOriginalResponse(): void - { - $response = $this->response(200, request: new ResponseWithResponseDtoRequestStub); - $dataObject = $response->dto(); - - $this->assertInstanceOf(ResponseWithResponseDtoStub::class, $dataObject); - $this->assertSame($response, $dataObject->getResponse()); - } - - public function testDtoOrFailPreservesTheRequestException(): void - { - $response = $this->response(500, request: new ResponseDtoRequestStub); - - try { - $response->dtoOrFail(); - $this->fail('A failed response should not be converted into a data object.'); - } catch (LogicException $exception) { - $this->assertSame('Unable to create a data transfer object because the response failed.', $exception->getMessage()); - $this->assertInstanceOf(ServerException::class, $exception->getPrevious()); - } - } - - /** - * Create a Saloon response for the given operation. - */ - protected function response( - int $status, - StreamInterface|string $body = 'response body', - ?Connector $connector = null, - ?Request $request = null, - ?PendingRequest $pendingRequest = null, - ): Response { - $connector ??= new ResponseConnectorStub; - $request ??= new ResponseRequestStub; - $pendingRequest ??= $this->pendingRequest($connector, $request); - $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users'); - - return Response::fromResponse( - new HttpResponse(new PsrResponse($status, body: $body)), - $pendingRequest, - $psrRequest, - ); - } - - /** - * Create a pending request with isolated framework dependencies. - */ - protected function pendingRequest(Connector $connector, Request $request): PendingRequest - { - return new PendingRequest( - $connector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); - } -} - -class ResponseConnectorStub extends Connector -{ - public ?bool $failureVerdict = null; - - public ?bool $throwVerdict = null; - - public bool $customException = false; - - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } - - public function hasRequestFailed(Response $response): ?bool - { - return $this->failureVerdict; - } - - public function shouldThrowRequestException(Response $response): bool - { - return $this->throwVerdict ?? $response->failed(); - } - - public function getRequestException(Response $response): ?RequestException - { - return $this->customException ? new ResponseConnectorExceptionStub($response) : null; - } -} - -class ResponseRequestStub extends Request -{ - public ?bool $failureVerdict = null; - - public ?bool $throwVerdict = null; - - public bool $customException = false; - - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/users'; - } - - public function hasRequestFailed(Response $response): ?bool - { - return $this->failureVerdict; - } - - public function shouldThrowRequestException(Response $response): bool - { - return $this->throwVerdict ?? $response->failed(); - } - - public function getRequestException(Response $response): ?RequestException - { - return $this->customException ? new ResponseRequestExceptionStub($response) : null; - } -} - -class ResponseAlwaysThrowRequestStub extends ResponseRequestStub -{ - use AlwaysThrowOnErrors; -} - -class ResponseDtoConnectorStub extends ResponseConnectorStub -{ - public function createDtoFromResponse(Response $response): ResponseConnectorDtoStub - { - return new ResponseConnectorDtoStub; - } -} - -class ResponseDtoRequestStub extends ResponseRequestStub -{ - public function createDtoFromResponse(Response $response): ResponseRequestDtoStub - { - return new ResponseRequestDtoStub; - } -} - -class ResponseWithResponseDtoRequestStub extends ResponseRequestStub -{ - public function createDtoFromResponse(Response $response): ResponseWithResponseDtoStub - { - return new ResponseWithResponseDtoStub; - } -} - -class ResponseConnectorDtoStub -{ -} - -class ResponseRequestDtoStub -{ -} - -class ResponseWithResponseDtoStub implements WithResponse -{ - use HasResponse; -} - -class ResponseConnectorExceptionStub extends RequestException -{ -} - -class ResponseRequestExceptionStub extends RequestException -{ -} diff --git a/tests/Saloon/Http/UrlResolverTest.php b/tests/Saloon/Http/UrlResolverTest.php deleted file mode 100644 index fb767c8c89..0000000000 --- a/tests/Saloon/Http/UrlResolverTest.php +++ /dev/null @@ -1,142 +0,0 @@ -assertSame('https://api.example.com/v1/users?active=1', (string) $uri); - } - - public function testEmptyEndpointPreservesTheConnectorBasePath(): void - { - $this->assertSame( - 'https://api.example.com/v1', - (string) UrlResolver::resolve('https://api.example.com/v1', '', false), - ); - $this->assertSame( - 'https://api.example.com/v1/', - (string) UrlResolver::resolve('https://api.example.com/v1', '/', false), - ); - } - - public function testBaseAndEndpointQueriesAreRetainedBeforeRepositoryOverrides(): void - { - $this->assertSame( - 'https://api.example.com/v1/users?key=base&keep=base', - (string) UrlResolver::resolve('https://api.example.com/v1?key=base&keep=base', '/users', false), - ); - - $uri = UrlResolver::resolve( - 'https://api.example.com/v1?key=base&keep=base', - '/users?endpoint=value', - false, - ); - - $this->assertSame('key=base&keep=base&endpoint=value', $uri->getQuery()); - $this->assertSame( - 'keep=base&endpoint=value&key=repository', - UrlResolver::withQuery($uri, ['key' => 'repository'])->getQuery(), - ); - } - - public function testAbsoluteEndpointRequiresAnExplicitOverride(): void - { - $this->expectException(PendingRequestException::class); - - UrlResolver::resolve('https://api.example.com', 'https://uploads.example.com/files', false); - } - - public function testAbsoluteHttpEndpointCanBeExplicitlyAllowed(): void - { - $uri = UrlResolver::resolve('https://api.example.com', 'https://uploads.example.com/files', true); - - $this->assertSame('https://uploads.example.com/files', (string) $uri); - } - - public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void - { - $this->expectException(PendingRequestException::class); - - UrlResolver::resolve('', '/users', true); - } - - public function testAlternateSchemesAreRejected(): void - { - $this->expectException(PendingRequestException::class); - - UrlResolver::resolve('', 'file:///etc/passwd', true); - } - - public function testRepositoryQueryReplacesExactAndNestedRawFamilies(): void - { - $uri = UrlResolver::resolve( - 'https://api.example.com', - '/users?filter=old&filter%5Bname%5D=Taylor&keep=one&keep=two&flag', - false, - ); - - $resolved = UrlResolver::withQuery($uri, [ - 'filter' => ['name' => 'Abigail'], - 'page' => 2, - ]); - - $this->assertSame( - 'keep=one&keep=two&flag&filter%5Bname%5D=Abigail&page=2', - $resolved->getQuery(), - ); - } - - public function testNullAndEmptyArrayValuesRemoveMatchingRawFamilies(): void - { - $uri = UrlResolver::resolve( - 'https://api.example.com', - '/users?remove=one&remove%5Bnested%5D=two&empty%5B0%5D=value&keep=yes', - false, - ); - - $resolved = UrlResolver::withQuery($uri, ['remove' => null, 'empty' => []]); - - $this->assertSame('keep=yes', $resolved->getQuery()); - } - - public function testDecodedNamesUseQueryStringSemanticsWithoutNormalizingRetainedPairs(): void - { - $uri = UrlResolver::resolve( - 'https://api.example.com', - '/users?first+name=old&retained=a+b&literal=%7Bvalue%7D', - false, - ); - - $resolved = UrlResolver::withQuery($uri, ['first name' => 'Taylor']); - - $this->assertSame('retained=a+b&literal=%7Bvalue%7D&first%20name=Taylor', $resolved->getQuery()); - } - - public function testStructuredValuesAreNormalized(): void - { - $uri = UrlResolver::resolve('https://api.example.com', '/users', false); - - $resolved = UrlResolver::withQuery($uri, [ - 'collection' => new Collection(['one', 'two']), - 'stringable' => new Stringable('value'), - 'not-a-number' => NAN, - ]); - - $this->assertSame( - 'collection%5B0%5D=one&collection%5B1%5D=two&stringable=value¬-a-number=NAN', - $resolved->getQuery(), - ); - } -} diff --git a/tests/Saloon/Hypervel/Feature/EventsTest.php b/tests/Saloon/Hypervel/Feature/EventsTest.php new file mode 100644 index 0000000000..91f0c89211 --- /dev/null +++ b/tests/Saloon/Hypervel/Feature/EventsTest.php @@ -0,0 +1,75 @@ + 'Sam'], 200), + ]); + + Event::fake(); + + $response = TestConnector::make()->send(new UserRequest); + + Event::assertDispatched(SendingSaloonRequest::class, function (SendingSaloonRequest $event) use ($response): bool { + return $response->pendingRequest() === $event->pendingRequest; + }); + + Event::assertDispatched(SentSaloonRequest::class, function (SentSaloonRequest $event) use ($response): bool { + return $response === $event->response && $response->pendingRequest() === $event->pendingRequest; + }); + } + + public function testAScopedEventFakeIsRemovedFromTheResolvedManagerAfterwards(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Alex']), + MockResponse::make(['name' => 'Taylor']), + ]); + $received = []; + Event::listen(SentSaloonRequest::class, function (SentSaloonRequest $event) use (&$received): void { + $received[] = $event->response->json('name'); + }); + $connector = new TestConnector; + + $connector->send(new UserRequest); + + Event::fakeFor(function () use ($connector): void { + $response = $connector->send(new UserRequest); + + Event::assertDispatched( + SentSaloonRequest::class, + fn (SentSaloonRequest $event): bool => $event->response === $response, + ); + }); + + $connector->send(new UserRequest); + + $this->assertSame(['Sam', 'Taylor'], $received); + } +} diff --git a/tests/Saloon/Hypervel/Feature/MockRequestTest.php b/tests/Saloon/Hypervel/Feature/MockRequestTest.php new file mode 100644 index 0000000000..b06a6c65e1 --- /dev/null +++ b/tests/Saloon/Hypervel/Feature/MockRequestTest.php @@ -0,0 +1,323 @@ + 'Sam'], 200), + new MockResponse(['name' => 'Alex'], 200), + new MockResponse(['error' => 'Server Unavailable'], 500), + ]); + + $connector = new TestConnector; + + $responseA = $connector->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sam'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connector->send(new UserRequest); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $responseC = $connector->send(new UserRequest); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(['error' => 'Server Unavailable'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + + $this->expectException(NoMockResponseFoundException::class); + + $connector->send(new UserRequest); + } + + public function testARequestCanBeMockedWithASequenceUsingAClosure(): void + { + Saloon::fake([ + function (PendingRequest $request): MockResponse { + return new MockResponse(['request' => (string) $request->uri()]); + }, + ]); + + $responseA = TestConnector::make()->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + } + + public function testARequestCanBeMockedWithAConnectorDefined(): void + { + $responseA = new MockResponse(['name' => 'Sammyjo20']); + $responseB = new MockResponse(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $connectorARequest = new UserRequest; + $connectorBRequest = new QueryParameterConnectorRequest; + + Saloon::fake([ + TestConnector::class => $responseA, + QueryParameterConnector::class => $responseB, + ]); + + $responseA = $connectorA->send($connectorARequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorB->send($connectorBRequest); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + } + + public function testARequestCanBeMockedWithAConnectorDefinedUsingAClosure(): void + { + Saloon::fake([ + TestConnector::class => function (PendingRequest $request): MockResponse { + return new MockResponse(['request' => (string) $request->uri()]); + }, + ]); + + $responseA = TestConnector::make()->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + } + + public function testARequestCanBeMockedWithARequestDefined(): void + { + $responseA = new MockResponse(['name' => 'Sammyjo20']); + $responseB = new MockResponse(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $requestA = new UserRequest; + $requestB = new QueryParameterConnectorRequest; + + Saloon::fake([ + UserRequest::class => $responseA, + QueryParameterConnectorRequest::class => $responseB, + ]); + + $responseA = $connectorA->send($requestA); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorB->send($requestB); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + } + + public function testARequestCanBeMockedWithARequestDefinedUsingAClosure(): void + { + Saloon::fake([ + UserRequest::class => function (PendingRequest $request): MockResponse { + return new MockResponse(['request' => (string) $request->uri()]); + }, + ]); + + $responseA = TestConnector::make()->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + } + + public function testARequestCanBeMockedWithAUrlDefined(): void + { + $responseA = new MockResponse(['name' => 'Sammyjo20']); + $responseB = new MockResponse(['name' => 'Alex']); + $responseC = new MockResponse(['error' => 'Server Broken'], 500); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + Saloon::fake([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + 'google.com/*' => $responseC, // Test Different Route, + ]); + + $responseA = $connectorA->send($requestA); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorA->send($requestB); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $responseC = $connectorB->send($requestC); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(['error' => 'Server Broken'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + } + + public function testYouCanCreateWildcardUrlMocks(): void + { + $responseA = new MockResponse(['name' => 'Sammyjo20']); + $responseB = new MockResponse(['name' => 'Alex']); + $responseC = new MockResponse(['error' => 'Server Broken'], 500); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + Saloon::fake([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + '*' => $responseC, + ]); + + $responseA = $connectorA->send($requestA); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['name' => 'Sammyjo20'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + + $responseB = $connectorA->send($requestB); + + $this->assertTrue($responseB->isMocked()); + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame(200, $responseB->status()); + + $responseC = $connectorB->send($requestC); + + $this->assertTrue($responseC->isMocked()); + $this->assertSame(['error' => 'Server Broken'], $responseC->json()); + $this->assertSame(500, $responseC->status()); + } + + public function testARequestCanBeMockedWithAUrlDefinedUsingAClosure(): void + { + Saloon::fake([ + 'tests.saloon.dev/*' => function (PendingRequest $request): MockResponse { + return new MockResponse(['request' => (string) $request->uri()]); + }, + ]); + + $responseA = TestConnector::make()->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertSame(['request' => 'https://tests.saloon.dev/api/user'], $responseA->json()); + $this->assertSame(200, $responseA->status()); + } + + public function testTheSameGlobalMockClientIsReusedOnFurtherCalls(): void + { + $mockClientA = Saloon::fake([ + new MockResponse(['name' => 'Sam'], 200), + ]); + + $mockClientB = Saloon::fake([ + new MockResponse(['name' => 'Alex'], 200), + ]); + + $this->assertEquals(new MockClient([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['name' => 'Alex'], 200), + ]), MockClient::getGlobal()); + + $this->assertSame($mockClientA, $mockClientB); + $this->assertSame($mockClientA, Saloon::mockClient()); + $this->assertSame($mockClientB, Saloon::mockClient()); + } + + public function testFakingAgainKeepsTheGlobalMockClientsRecordsAndSettings(): void + { + $mockClient = Saloon::fake([ + new MockResponse(['name' => 'Sam']), + ]); + + $responseA = TestConnector::make()->send(new UserRequest); + $mockClient->withoutCache(); + + Saloon::fake([ + new MockResponse(['name' => 'Alex']), + ]); + + $responseB = TestConnector::make()->send(new UserRequest); + + $this->assertSame(['name' => 'Alex'], $responseB->json()); + $this->assertSame($mockClient, Saloon::mockClient()); + $this->assertTrue($mockClient->shouldBypassResponseCache()); + $this->assertSame([$responseA, $responseB], $mockClient->recorded()->all()); + } + + public function testAnExplicitMockClientReplacesTheGlobalMockClient(): void + { + $originalClient = Saloon::fake([ + new MockResponse(['name' => 'Sam']), + ]); + $replacementClient = new MockClient([ + new MockResponse(['name' => 'Alex']), + ]); + + $this->assertSame($replacementClient, Saloon::fake($replacementClient)); + $this->assertSame($replacementClient, Saloon::mockClient()); + + $response = TestConnector::make()->send(new UserRequest); + + $this->assertSame(['name' => 'Alex'], $response->json()); + $this->assertCount(1, $replacementClient->recorded()); + $this->assertCount(0, $originalClient->recorded()); + } +} diff --git a/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php b/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php new file mode 100644 index 0000000000..a8514ac9aa --- /dev/null +++ b/tests/Saloon/Hypervel/Feature/SimulatedResponsePayloadTest.php @@ -0,0 +1,43 @@ + 'Sam'], 200, ['X-Greeting' => 'Howdy']), + ]); + + $fakeResponse = new FakeResponse(['name' => 'Gareth'], 201, ['X-Greeting' => 'Hello']); + + $request = new UserRequest; + $request->middleware()->onRequest(fn (): FakeResponse => $fakeResponse); + + $response = TestConnector::make()->send($request); + + $this->assertSame(['name' => 'Gareth'], $response->json()); + $this->assertSame(201, $response->status()); + $this->assertSame('Hello', $response->header('X-Greeting')); + } +} diff --git a/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php new file mode 100644 index 0000000000..fe6b8625d9 --- /dev/null +++ b/tests/Saloon/Hypervel/Unit/MockClientAssertionsTest.php @@ -0,0 +1,170 @@ + new MockResponse(['name' => 'Sam'], 200), + ]); + + TestConnector::make()->send(new UserRequest); + + Saloon::assertSent(UserRequest::class); + } + + public function testThatAssertSentWorksWithAClosure(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + $originalRequest = new UserRequest; + $originalResponse = TestConnector::make()->send($originalRequest); + + Saloon::assertSent(function (Request $request, Response $response) use ($originalRequest, $originalResponse): bool { + return $request === $originalRequest && $response === $originalResponse; + }); + + $newRequest = new ErrorRequest; + $newResponse = TestConnector::make()->send($newRequest); + + Saloon::assertSent(function (Request $request, Response $response) use ($newRequest, $newResponse): bool { + return $request === $newRequest && $response === $newResponse; + }); + } + + public function testThatAssertSentWorksWithAUrl(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + TestConnector::make()->send(new UserRequest); + + Saloon::assertSent('saloon.dev/*'); + Saloon::assertSent('/user'); + Saloon::assertSent('api/user'); + } + + public function testThatAssertNotSentWorksWithARequest(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + TestConnector::make()->send(new ErrorRequest); + + Saloon::assertNotSent(UserRequest::class); + } + + public function testThatAssertNotSentWorksWithAClosure(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ErrorRequest::class => new MockResponse(['error' => 'Server Error'], 500), + ]); + + TestConnector::make()->send(new ErrorRequest); + + Saloon::assertNotSent(function (Request $request): bool { + return $request instanceof UserRequest; + }); + } + + public function testThatAssertNotSentWorksWithAUrl(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + TestConnector::make()->send(new UserRequest); + + Saloon::assertNotSent('google.com/*'); + Saloon::assertNotSent('/error'); + } + + // REMOVED: that assertSentJson works properly - the deprecated assertSentJson() is not ported. + + public function testAssertNothingSentWorksProperly(): void + { + Saloon::fake([ + UserRequest::class => new MockResponse(['name' => 'Sam'], 200), + ]); + + Saloon::assertNothingSent(); + } + + public function testAssertSentCountWorksProperly(): void + { + Saloon::fake([ + new MockResponse(['name' => 'Sam'], 200), + new MockResponse(['name' => 'Taylor'], 200), + new MockResponse(['name' => 'Marcel'], 200), + ]); + + TestConnector::make()->send(new UserRequest); + TestConnector::make()->send(new UserRequest); + TestConnector::make()->send(new UserRequest); + + Saloon::assertSentCount(3); + } + + public function testYouCanMockExceptions(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Patrick'])->throw(fn (PendingRequest $pendingRequest): Exception => new Exception('Unable to connect!')), + ]); + + $okResponse = TestConnector::make()->send(new UserRequest); + + $this->assertSame(['name' => 'Sam'], $okResponse->json()); + + $this->expectException(Exception::class); + $this->expectExceptionMessageIs('Unable to connect!'); + + TestConnector::make()->send(new UserRequest); + } + + public function testYouCanMockNormalExceptions(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Michael'])->throw(new Exception('Custom Exception!')), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessageIs('Custom Exception!'); + + TestConnector::make()->send(new UserRequest); + } +} diff --git a/tests/Saloon/OAuth2/OAuthGrantTest.php b/tests/Saloon/OAuth2/OAuthGrantTest.php deleted file mode 100644 index 6dca3ea1e1..0000000000 --- a/tests/Saloon/OAuth2/OAuthGrantTest.php +++ /dev/null @@ -1,436 +0,0 @@ -manager(), $this->config()); - - $authorization = $connector->authorizationUrl( - scopes: ['profile'], - state: 'known-state', - additionalQueryParameters: [ - 'client_id' => 'attacker', - 'state' => 'attacker-state', - 'prompt' => '', - 'include_granted_scopes' => false, - ], - codeChallenge: 'challenge', - ); - - parse_str(parse_url((string) $authorization, PHP_URL_QUERY) ?: '', $query); - - $this->assertSame('known-state', $authorization->state); - $this->assertSame('client', $query['client_id']); - $this->assertSame('known-state', $query['state']); - $this->assertSame('openid profile', $query['scope']); - $this->assertSame('', $query['prompt']); - $this->assertSame('0', $query['include_granted_scopes']); - $this->assertSame('challenge', $query['code_challenge']); - $this->assertSame('S256', $query['code_challenge_method']); - } - - public function testAuthorizationUrlPreservesEndpointQueryAndGeneratesState(): void - { - $config = new OAuthConfig( - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.example.com/callback', - authorizeEndpoint: 'oauth/authorize?audience=users', - ); - - $authorization = (new AuthorizationCodeConnectorStub($this->manager(), $config))->authorizationUrl(); - - $this->assertNotSame('', $authorization->state); - $this->assertStringContainsString('audience=users&response_type=code', (string) $authorization); - $this->assertStringContainsString('state=' . $authorization->state, (string) $authorization); - } - - public function testAuthorizationUrlReplacesProtocolParametersFromBaseAndEndpointQueries(): void - { - $config = new OAuthConfig( - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.example.com/callback', - authorizeEndpoint: 'oauth/authorize?client_id=endpoint&audience=users', - ); - $authorization = (new BaseQueryAuthorizationCodeConnectorStub($this->manager(), $config)) - ->authorizationUrl(state: 'known-state'); - $queryString = parse_url((string) $authorization, PHP_URL_QUERY) ?: ''; - parse_str($queryString, $query); - - $this->assertSame(1, substr_count($queryString, 'client_id=')); - $this->assertSame('client', $query['client_id']); - $this->assertSame('value', $query['base']); - $this->assertSame('users', $query['audience']); - } - - public function testStateValidationRequiresACompleteMatchingPair(): void - { - $connector = new AuthorizationCodeConnectorStub($this->manager(), $this->config()); - - foreach ([ - ['returned', null], - [null, 'expected'], - ['', 'expected'], - ['returned', ''], - ['returned', 'expected'], - ] as [$returnedState, $expectedState]) { - try { - $connector->getAccessToken('code', $returnedState, $expectedState); - $this->fail('Invalid state was accepted.'); - } catch (InvalidStateException) { - $this->addToAssertionCount(1); - } - } - } - - public function testAuthorizationCodeGrantSendsTokenRefreshAndUserRequests(): void - { - Date::setTestNow('2026-08-10 12:00:00'); - - try { - $manager = $this->manager(); - $mockClient = $manager->fake([ - GetAccessTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse { - $this->assertSame('code', $pendingRequest->body()['code']); - $this->assertSame('verifier', $pendingRequest->body()['code_verifier']); - $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']); - $this->assertSame('per-call', $pendingRequest->headers()['X-Request']); - - return MockResponse::make([ - 'access_token' => 'access', - 'refresh_token' => 'refresh', - 'expires_in' => '3600', - ]); - }, - GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse { - $this->assertSame('refresh', $pendingRequest->body()['refresh_token']); - - return MockResponse::make(['access_token' => 'renewed']); - }, - GetUserRequest::class => function (PendingRequest $pendingRequest): MockResponse { - $this->assertSame('Bearer renewed', $pendingRequest->headers()['Authorization']); - - return MockResponse::make(['id' => 1]); - }, - ]); - $connector = new AuthorizationCodeConnectorStub($manager, $this->config( - requestModifier: fn (Request $request) => $request->withHeader('X-OAuth', 'configured'), - )); - - $authenticator = $connector->getAccessToken( - code: 'code', - state: 'state', - expectedState: 'state', - requestModifier: fn (Request $request) => $request->withHeader('X-Request', 'per-call'), - codeVerifier: 'verifier', - ); - $renewed = $connector->refreshAccessToken($authenticator); - $user = $connector->getUser($renewed); - - $this->assertSame('access', $authenticator->getAccessToken()); - $this->assertSame('refresh', $authenticator->getRefreshToken()); - $this->assertSame(Date::now()->addHour()->getTimestamp(), $authenticator->getExpiresAt()?->getTimestamp()); - $this->assertSame('renewed', $renewed->getAccessToken()); - $this->assertSame('refresh', $renewed->getRefreshToken()); - $this->assertSame(['id' => 1], $user->json()); - $mockClient->assertSentCount(3); - } finally { - Date::setTestNow(); - } - } - - #[DataProvider('refreshEndpoints')] - public function testRefreshUsesTheConfiguredEndpoint(?string $endpoint, bool $allowOverride, string $expected): void - { - $manager = $this->manager(); - $mockClient = $manager->fake([ - GetRefreshTokenRequest::class => function (PendingRequest $pendingRequest) use ($expected): MockResponse { - $this->assertSame($expected, (string) $pendingRequest->uri()); - $this->assertSame([ - 'grant_type' => 'refresh_token', - 'refresh_token' => 'refresh', - 'client_id' => 'client', - 'client_secret' => 'secret', - ], $pendingRequest->body()); - $this->assertSame('configured', $pendingRequest->headers()['X-OAuth']); - $this->assertSame('per-call', $pendingRequest->headers()['X-Request']); - - return MockResponse::make(['access_token' => 'renewed']); - }, - ]); - $config = new OAuthConfig( - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.example.com/callback', - tokenEndpoint: 'oauth/token', - refreshEndpoint: $endpoint, - requestModifier: fn (Request $request) => $request->withHeader('X-OAuth', 'configured'), - allowBaseUrlOverride: $allowOverride, - ); - - $authenticator = (new AuthorizationCodeConnectorStub($manager, $config))->refreshAccessToken( - 'refresh', - requestModifier: fn (Request $request) => $request->withHeader('X-Request', 'per-call'), - ); - - $this->assertSame('renewed', $authenticator->getAccessToken()); - $this->assertSame('refresh', $authenticator->getRefreshToken()); - $mockClient->assertSentCount(1); - } - - /** - * Provide refresh endpoints and their resolved URLs. - */ - public static function refreshEndpoints(): array - { - return [ - 'token endpoint default' => [null, false, 'https://provider.example.com/oauth/token'], - 'relative refresh endpoint' => ['oauth/refresh?audience=users', false, 'https://provider.example.com/oauth/refresh?audience=users'], - 'trusted absolute refresh endpoint' => ['https://oauth.example.net/refresh', true, 'https://oauth.example.net/refresh'], - ]; - } - - public function testAbsoluteRefreshEndpointRequiresOverridePermission(): void - { - $manager = $this->manager(); - $mockClient = $manager->fake([MockResponse::make(['access_token' => 'renewed'])]); - $connector = new AuthorizationCodeConnectorStub($manager, new OAuthConfig( - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.example.com/callback', - refreshEndpoint: 'https://oauth.example.net/refresh', - )); - - $this->expectException(PendingRequestException::class); - $this->expectExceptionMessageIs('The request endpoint cannot replace the connector base URL.'); - - try { - $connector->refreshAccessToken('refresh'); - } finally { - $mockClient->assertNothingSent(); - } - } - - public function testReturningTheRawTokenResponseSkipsAuthenticatorValidation(): void - { - $manager = $this->manager(); - $manager->fake([MockResponse::make(['provider_specific' => true])]); - $connector = new AuthorizationCodeConnectorStub($manager, $this->config()); - - $response = $connector->getAccessToken('code', returnResponse: true); - - $this->assertSame(['provider_specific' => true], $response->json()); - } - - public function testClientCredentialsGrantsUseTheirExpectedAuthenticationShape(): void - { - $manager = $this->manager(); - $mockClient = $manager->fake([ - GetClientCredentialsTokenRequest::class => function (PendingRequest $pendingRequest): MockResponse { - $this->assertSame('client', $pendingRequest->body()['client_id']); - $this->assertNull($pendingRequest->transportAuthentication()); - - return MockResponse::make(['access_token' => 'body-token']); - }, - GetClientCredentialsTokenBasicAuthRequest::class => function (PendingRequest $pendingRequest): MockResponse { - $this->assertArrayNotHasKey('client_id', $pendingRequest->body()); - $this->assertSame(['client', 'secret'], $pendingRequest->transportAuthentication()); - - return MockResponse::make(['access_token' => 'basic-token']); - }, - ]); - - $bodyAuthenticator = (new ClientCredentialsConnectorStub($manager, $this->config())) - ->getAccessToken(['profile']); - $basicAuthenticator = (new ClientCredentialsBasicAuthConnectorStub($manager, $this->config())) - ->getAccessToken(['profile']); - - $this->assertSame('body-token', $bodyAuthenticator->getAccessToken()); - $this->assertSame('basic-token', $basicAuthenticator->getAccessToken()); - $mockClient->assertSentCount(2); - } - - public function testTokenResponseValidationRejectsInvalidValues(): void - { - Date::setTestNow('2026-08-10 12:00:00'); - - try { - foreach ([ - [], - ['access_token' => ''], - ['access_token' => 123], - ['access_token' => 'token', 'refresh_token' => []], - ['access_token' => 'token', 'expires_in' => -1], - ['access_token' => 'token', 'expires_in' => '1.5'], - ['access_token' => 'token', 'expires_in' => 1.0E+30], - ['access_token' => 'token', 'expires_in' => PHP_INT_MAX], - ] as $body) { - $manager = $this->manager(); - $manager->fake([MockResponse::make($body)]); - - try { - (new ClientCredentialsConnectorStub($manager, $this->config()))->getAccessToken(); - $this->fail('An invalid OAuth token response was accepted.'); - } catch (UnexpectedValueException) { - $this->addToAssertionCount(1); - } - } - } finally { - Date::setTestNow(); - } - } - - public function testTokenExpiryAcceptsTheExactRepresentableBoundary(): void - { - Date::setTestNow('2026-08-10 12:00:00'); - - try { - $expiresIn = PHP_INT_MAX - Date::now()->getTimestamp(); - $manager = $this->manager(); - $manager->fake([MockResponse::make([ - 'access_token' => 'token', - 'expires_in' => $expiresIn, - ])]); - - $authenticator = (new ClientCredentialsConnectorStub($manager, $this->config()))->getAccessToken(); - - $this->assertSame(PHP_INT_MAX, $authenticator->getExpiresAt()?->getTimestamp()); - $this->assertTrue($authenticator->hasNotExpired()); - } finally { - Date::setTestNow(); - } - } - - public function testInvalidPkceConfigurationIsRejectedBeforeSending(): void - { - $connector = new AuthorizationCodeConnectorStub($this->manager(), $this->config()); - - foreach ([['', 'S256'], ['challenge', 'unsupported']] as [$challenge, $method]) { - try { - $connector->authorizationUrl(codeChallenge: $challenge, codeChallengeMethod: $method); - $this->fail('An invalid PKCE configuration was accepted.'); - } catch (InvalidArgumentException) { - $this->addToAssertionCount(1); - } - } - } - - /** - * Create an OAuth configuration. - */ - protected function config(?callable $requestModifier = null): OAuthConfig - { - return new OAuthConfig( - clientId: 'client', - clientSecret: 'secret', - redirectUri: 'https://app.example.com/callback', - defaultScopes: ['openid'], - requestModifier: $requestModifier, - ); - } - - /** - * Create the manager with isolated framework services. - */ - protected function manager(): SaloonManager - { - $http = new Factory; - $http->registerConnection('saloon'); - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('string') - ->with('saloon.connection.name') - ->andReturn('saloon'); - - return new SaloonManager( - new Sender($http, $config), - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - $config, - new Dispatcher, - ); - } -} - -abstract class OAuthConnectorStub extends Connector -{ - public function __construct( - protected SaloonManager $manager, - protected readonly OAuthConfig $config, - ) { - } - - public function resolveBaseUrl(): string - { - return 'https://provider.example.com'; - } - - public function send(Request $request, ?MockClient $mockClient = null): Response - { - return $this->manager->send($this, $request, $mockClient); - } - - protected function defaultOAuthConfig(): OAuthConfig - { - return $this->config; - } -} - -class AuthorizationCodeConnectorStub extends OAuthConnectorStub -{ - use AuthorizationCodeGrant; -} - -class BaseQueryAuthorizationCodeConnectorStub extends AuthorizationCodeConnectorStub -{ - public function resolveBaseUrl(): string - { - return 'https://provider.example.com?client_id=base&base=value'; - } -} - -class ClientCredentialsConnectorStub extends OAuthConnectorStub -{ - use ClientCredentialsGrant; -} - -class ClientCredentialsBasicAuthConnectorStub extends OAuthConnectorStub -{ - use ClientCredentialsBasicAuthGrant; -} diff --git a/tests/Saloon/PackageMetadataTest.php b/tests/Saloon/PackageMetadataTest.php index 11e1385dfb..d2326908e2 100644 --- a/tests/Saloon/PackageMetadataTest.php +++ b/tests/Saloon/PackageMetadataTest.php @@ -9,7 +9,6 @@ use Hypervel\Support\DefaultProviders; use Hypervel\Tests\TestCase; use JsonException; -use ReflectionClass; class PackageMetadataTest extends TestCase { @@ -30,6 +29,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'ext-simplexml', 'guzzlehttp/guzzle', 'guzzlehttp/psr7', + 'guzzlehttp/uri-template', 'hypervel/cache', 'hypervel/collections', 'hypervel/conditionable', @@ -44,7 +44,6 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'hypervel/macroable', 'hypervel/prompts', 'hypervel/rate-limiter', - 'hypervel/reflection', 'hypervel/support', 'nesbot/carbon', 'psr/http-message', @@ -90,29 +89,6 @@ public function testProviderAndFacadeAreDiscoverable(): void $this->assertNotContains(SaloonServiceProvider::class, (new DefaultProviders)->toArray()); } - public function testFacadeDocumentsTheManagerSurface(): void - { - $docblock = (new ReflectionClass(Saloon::class))->getDocComment(); - $this->assertIsString($docblock); - - foreach ([ - 'middleware', - 'fake', - 'mockClient', - 'clearFake', - 'assertSent', - 'assertNotSent', - 'assertSentInOrder', - 'assertNothingSent', - 'assertSentCount', - 'resolveCacheScopeUsing', - 'fixturePath', - 'throwOnMissingFixtures', - ] as $method) { - $this->assertStringContainsString(" {$method}(", $docblock); - } - } - /** * Read the split-package Composer manifest. * diff --git a/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php new file mode 100644 index 0000000000..33a7e69d2f --- /dev/null +++ b/tests/Saloon/Pagination/Feature/AsyncPaginationTest.php @@ -0,0 +1,123 @@ +paginate($request); + + $superheroes = []; + + $iteratorCounter = 0; + + foreach ($paginator->pool() as $response) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + } + + $this->assertSame(4, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + + // Now we'll pool the same paginator again + + $collection = (new Collection($paginator->pool())) + ->map(static fn (Response $response): array => $response->json('data')) + ->collapse(); + + $this->assertSame(range(1, 20), $collection->sortBy('id')->pluck('id')->values()->all()); + + $this->assertSame(20, $paginator->totalResults()); + } + + public function testYouCanPaginateAsynchronouslyThroughManyPagesOfResultsWithLimitOffsetPagination(): void + { + $connector = new OffsetConnector; + $request = new SuperheroLimitOffsetRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + + $iteratorCounter = 0; + + foreach ($paginator->pool() as $response) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + } + + $this->assertSame(4, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + + // Now we'll pool the same paginator again + + $collection = (new Collection($paginator->pool())) + ->map(static fn (Response $response): array => $response->json('data')) + ->collapse(); + + $this->assertSame(range(1, 20), $collection->sortBy('id')->pluck('id')->values()->all()); + + $this->assertSame(20, $paginator->totalResults()); + } + + public function testIfYouDontImplementTheGetTotalPagesMethodOnAPaginatorItWillThrowAnExceptionIfYouTryToUseAsyncPagination(): void + { + $connector = new SyncPagedConnector; + $request = new SuperheroPagedRequest; + $paginator = $connector->paginate($request); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Implement [getTotalPages] to use pooled pagination.'); + + $paginator->pool(); + } + + public function testIfThePaginatorReturnsAllThePagesInTheFirstPageItWontContinue(): void + { + $connector = new PagedConnector; + $request = new SuperheroPagedRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + + $iteratorCounter = 0; + + $paginator->perPageLimit(100); + + foreach ($paginator->pool() as $response) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + } + + $this->assertSame(1, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } +} diff --git a/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php b/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php new file mode 100644 index 0000000000..4d1cee91f3 --- /dev/null +++ b/tests/Saloon/Pagination/Feature/AsyncPoolPaginationTest.php @@ -0,0 +1,64 @@ +send()->wait(). +class AsyncPoolPaginationTest extends PaginationTestCase +{ + public function testYouCanMakeAPoolOfRequestsWithPagedPagination(): void + { + $connector = new PagedConnector; + $request = new SuperheroPagedRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + $paginator->pool(5, function (Response $response) use (&$iteratorCounter, &$superheroes): void { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + }); + + $this->assertSame(4, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + sort($mapped); + + $this->assertSame(range(1, 20), $mapped); + } + + public function testYouCanMakeAPoolOfRequestsWithLimitOffsetPagination(): void + { + $connector = new OffsetConnector; + $request = new SuperheroLimitOffsetRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + $paginator->pool(5, function (Response $response) use (&$iteratorCounter, &$superheroes): void { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + }); + + $this->assertSame(4, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + sort($mapped); + + $this->assertSame(range(1, 20), $mapped); + } +} diff --git a/tests/Saloon/Pagination/Feature/CollectTest.php b/tests/Saloon/Pagination/Feature/CollectTest.php new file mode 100644 index 0000000000..d4a781ed6f --- /dev/null +++ b/tests/Saloon/Pagination/Feature/CollectTest.php @@ -0,0 +1,74 @@ +paginate($request); + + $collection = $paginator->collect(throughItems: false)->collect(); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertContainsOnlyInstancesOf(Response::class, $collection); + + $toIds = static fn (array $items): array => array_map(static fn (array $item): int => $item['id'], $items); + + $this->assertSame([1, 2, 3, 4, 5], $toIds($collection[0]->json('data'))); + $this->assertSame([6, 7, 8, 9, 10], $toIds($collection[1]->json('data'))); + $this->assertSame([11, 12, 13, 14, 15], $toIds($collection[2]->json('data'))); + $this->assertSame([16, 17, 18, 19, 20], $toIds($collection[3]->json('data'))); + + $this->assertSame(20, $paginator->totalResults()); + } + + /** + * @see https://github.com/saloonphp/saloon/issues/464 + */ + public function testMultipleCollectCallsOnSameLazyCollectionDoNotTriggerInfiniteLoopDetection(): void + { + $singlePageResponse = [ + 'data' => [['id' => 1], ['id' => 2]], + 'next_page_url' => null, + ]; + + Saloon::fake(new MockClient([ + MockResponse::make($singlePageResponse), + MockResponse::make($singlePageResponse), + MockResponse::make($singlePageResponse), + MockResponse::make($singlePageResponse), + MockResponse::make($singlePageResponse), + MockResponse::make($singlePageResponse), + ])); + + $connector = new PagedConnector; + $request = new SuperheroPagedRequest; + $lazyCollection = $connector->paginate($request)->collect(); + + $first = $lazyCollection->collect(); + $this->assertInstanceOf(Collection::class, $first); + $this->assertSame([1, 2], $first->pluck('id')->all()); + + $lazyCollection->collect(); + $lazyCollection->collect(); + $lazyCollection->collect(); + $lazyCollection->collect(); + + $again = $lazyCollection->collect(); + $this->assertSame([1, 2], $again->pluck('id')->all()); + } +} diff --git a/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php new file mode 100644 index 0000000000..a6101bc34c --- /dev/null +++ b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php @@ -0,0 +1,154 @@ + MockResponse::make(['next_page_url' => 'infinity']), + ]); + + Saloon::fake($mockClient); + + $connector = new PagedConnector; + + // We'll create a paginator that expects the "next_page_url" to be empty. + // This will compare the responses and throw an exception if the last + // five responses have been the same. + + $paginator = $connector->paginate(new SuperheroPagedRequest); + + $thrownException = false; + + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); + + $mockClient->assertSentCount(5); + + $thrownException = true; + } + + $this->assertTrue($thrownException); + } + + public function testThePaginationPluginCanDetectAPotentialInfiniteLoopAfterTheInitialRequest(): void + { + // We'll have two regular requests sent, and then we'll have 6 exactly the same + // response bodies. + + $mockClient = new MockClient([ + MockResponse::make(['next_page_url' => '2']), + MockResponse::make(['next_page_url' => '3']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + ]); + + Saloon::fake($mockClient); + + $connector = new PagedConnector; + + $paginator = $connector->paginate(new SuperheroPagedRequest); + + $thrownException = false; + + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); + + $mockClient->assertSentCount(7); + + $thrownException = true; + } + + $this->assertTrue($thrownException); + } + + // Checksums use xxh128 instead of md5 and are keyed by page position, so a repeated load replaces its page's entry. + public function testThePaginatorOnlyKeepsFiveResponseChecksumsInMemoryAtOnce(): void + { + $mockClient = new MockClient([ + MockResponse::make($responseA = ['next_page_url' => '1']), + MockResponse::make($responseB = ['next_page_url' => '2']), + MockResponse::make($responseC = ['next_page_url' => '3']), + MockResponse::make($responseD = ['next_page_url' => '4']), + MockResponse::make($responseE = ['next_page_url' => '5']), + MockResponse::make($responseF = ['next_page_url' => '6']), + MockResponse::make($responseG = ['next_page_url' => null]), + ]); + + Saloon::fake($mockClient); + + $connector = new PagedConnector; + + $paginator = $connector->paginate(new SuperheroPagedRequest); + + iterator_to_array($paginator); + + $previousBodyChecksums = (fn (): array => $this->lastFiveBodyChecksums)->call($paginator); + + // We should only have four items because after each successful fifth attempt + // we will remove the oldest one from the array. + + $this->assertCount(4, $previousBodyChecksums); + + $this->assertSame([ + 3 => hash('xxh128', json_encode($responseD)), + 4 => hash('xxh128', json_encode($responseE)), + 5 => hash('xxh128', json_encode($responseF)), + 6 => hash('xxh128', json_encode($responseG)), + ], $previousBodyChecksums); + } + + public function testYouCanDisableTheInfiniteLoopDetectionOnAPaginator(): void + { + $mockClient = new MockClient([ + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + MockResponse::make(['next_page_url' => 'infinity']), + ]); + + Saloon::fake($mockClient); + + $connector = new DisabledInfiniteLoopConnector; + + $paginator = $connector->paginate(new SuperheroPagedRequest); + + $thrownException = false; + + try { + iterator_to_array($paginator); + } catch (NoMockResponseFoundException) { + // We'll detect a "NoMockResponseFoundException" because our mock client should attempt to + // keep making requests because the infinite loop detection has been disabled. + + $thrownException = true; + } + + $this->assertTrue($thrownException); + } +} diff --git a/tests/Saloon/Pagination/Feature/ItemsTest.php b/tests/Saloon/Pagination/Feature/ItemsTest.php new file mode 100644 index 0000000000..2a18215b99 --- /dev/null +++ b/tests/Saloon/Pagination/Feature/ItemsTest.php @@ -0,0 +1,33 @@ +paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator->items() as $item) { + $superheroes[] = $item; + ++$iteratorCounter; + } + + $this->assertSame(20, $iteratorCounter); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } +} diff --git a/tests/Saloon/Pagination/Feature/PaginationTest.php b/tests/Saloon/Pagination/Feature/PaginationTest.php new file mode 100644 index 0000000000..8e2efa0286 --- /dev/null +++ b/tests/Saloon/Pagination/Feature/PaginationTest.php @@ -0,0 +1,203 @@ +paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(4, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + + // Now we'll test the collect method + + $collection = $paginator->collect()->collect(); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertSame(range(1, 20), $collection->pluck('id')->toArray()); + + $this->assertSame(20, $paginator->totalResults()); + } + + public function testYouCanPaginateAutomaticallyThroughManyPagesOfResultsWithLimitOffsetPagination(): void + { + $connector = new OffsetConnector; + $request = new SuperheroLimitOffsetRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + + foreach ($paginator as $item) { + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + + // Now we'll test the collect method + + $collection = $paginator->collect()->collect(); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertSame(range(1, 20), $collection->pluck('id')->toArray()); + + $this->assertSame(20, $paginator->totalResults()); + } + + public function testYouCanPaginateAutomaticallyThroughManyPagesOfResultsWithCursorPagination(): void + { + $connector = new CursorConnector; + $request = new SuperheroCursorRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + + foreach ($paginator as $item) { + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + + // Now we'll test the collect method + + $collection = $paginator->collect()->collect(); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertSame(range(1, 20), $collection->pluck('id')->toArray()); + + $this->assertSame(20, $paginator->totalResults()); + } + + public function testYouCanSpecifyTheMaximumNumberOfPagesToIterateOver(): void + { + $connector = new CursorConnector; + $request = new SuperheroCursorRequest; + $paginator = $connector->paginate($request); + + $paginator->maxPages(2); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertCount(10, $superheroes); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 10), $mapped); + } + + public function testIfThePaginatorReturnsAllThePagesInTheFirstPageItWontContinue(): void + { + $connector = new PagedConnector; + $request = new SuperheroPagedRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + $paginator->perPageLimit(100); + + foreach ($paginator as $response) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $response->json('data')); + } + + $this->assertSame(1, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } + + public function testThePaginatorWillThrowAnExceptionIfYouUseARequestThatIsNotPaginatable(): void + { + $connector = new PagedConnector; + $request = new UserRequest; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The request must implement [' . Paginatable::class . '] to be used with a paginator.'); + + $connector->paginate($request); + } + + public function testAnIndividualRequestCanImplementTheGetPaginatedResultsInterfaceToOverwriteTheConnectorsPaginator(): void + { + $connector = new PagedConnector; + $request = new MappedPagedRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + + foreach ($paginator->items() as $item) { + $superheroes[] = $item; + } + + $this->assertSame([ + 'Batman', + 'Superman', + 'Flash', + 'Green Lantern', + 'Green Arrow', + 'Wonder Woman', + 'Martian Manhunter', + 'Robin/Nightwing', + 'Blue Beetle', + 'Black Canary', + 'Spider Man', + 'Captain America', + 'Iron Man', + 'Thor', + 'Hulk', + 'Wolverine', + 'Daredevil', + 'Hawkeye', + 'Cyclops', + 'Silver Surfer', + ], $superheroes); + } +} diff --git a/tests/Saloon/Pagination/Feature/PerPageLimitTest.php b/tests/Saloon/Pagination/Feature/PerPageLimitTest.php new file mode 100644 index 0000000000..45d9b66788 --- /dev/null +++ b/tests/Saloon/Pagination/Feature/PerPageLimitTest.php @@ -0,0 +1,88 @@ +paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + $paginator->perPageLimit(10); + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } + + public function testYouCanSpecifyAPerPageLimitOnALimitOffsetPaginator(): void + { + $connector = new OffsetConnector; + $request = new SuperheroLimitOffsetRequest; + $paginator = $connector->paginate($request); + + $paginator->perPageLimit(10); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } + + public function testYouCanSpecifyAPerPageLimitOnACursorPaginator(): void + { + $connector = new CursorConnector; + $request = new SuperheroCursorRequest; + $paginator = $connector->paginate($request); + + $paginator->perPageLimit(10); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertSame(20, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(1, 20), $mapped); + } +} diff --git a/tests/Saloon/Pagination/Feature/StartPageTest.php b/tests/Saloon/Pagination/Feature/StartPageTest.php new file mode 100644 index 0000000000..3c5564f3f4 --- /dev/null +++ b/tests/Saloon/Pagination/Feature/StartPageTest.php @@ -0,0 +1,59 @@ +paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertSame(10, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(11, 20), $mapped); + } + + public function testYouCanSpecifyAStartPageOnAPaginatorInstance(): void + { + $connector = new PagedConnector; + $request = new SuperheroPagedRequest; + $paginator = $connector->paginate($request); + + $superheroes = []; + $iteratorCounter = 0; + + $paginator->startPage(3); + + foreach ($paginator as $item) { + ++$iteratorCounter; + $superheroes = array_merge($superheroes, $item->json('data')); + } + + $this->assertSame(2, $iteratorCounter); + $this->assertSame(10, $paginator->totalResults()); + + $mapped = array_map(static fn (array $superhero): int => $superhero['id'], $superheroes); + + $this->assertSame(range(11, 20), $mapped); + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php new file mode 100644 index 0000000000..7cd49b53d7 --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/Async/OffsetConnector.php @@ -0,0 +1,57 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends OffsetPaginator { + /** + * Per Page. + */ + protected ?int $perPageLimit = 5; + + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return $this->getOffset() >= (int) $response->json('total'); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + + /** + * Get the total number of pages. + */ + protected function getTotalPages(Response $response): int + { + return (int) ceil($response->json('total') / $this->perPageLimit); + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php new file mode 100644 index 0000000000..812ebd98ac --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/Async/PagedConnector.php @@ -0,0 +1,52 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends PagedPaginator { + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return empty($response->json('next_page_url')); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + + /** + * Get the total number of pages. + */ + protected function getTotalPages(Response $response): int + { + return (int) ceil($response->json('total') / $response->json('per_page')); + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php new file mode 100644 index 0000000000..b164623904 --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/CursorConnector.php @@ -0,0 +1,54 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends CursorPaginator { + /** + * Get the next cursor. + */ + protected function getNextCursor(Response $response): int|string + { + $nextPageUrl = $response->json('next_page_url'); + parse_str(parse_url($nextPageUrl, PHP_URL_QUERY), $queryParams); + + return $queryParams['cursor']; + } + + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return empty($response->json('next_page_url')); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php new file mode 100644 index 0000000000..1f22636494 --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/CustomStartPagePagedConnector.php @@ -0,0 +1,48 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends PagedPaginator { + /** + * Set a custom start page. + */ + protected int $startPage = 3; + + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return empty($response->json('next_page_url')); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php new file mode 100644 index 0000000000..fa0fe0dbfb --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/DisabledInfiniteLoopConnector.php @@ -0,0 +1,48 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends PagedPaginator { + /** + * Do not detect infinite loops. + */ + protected bool $detectInfiniteLoop = false; + + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return empty($response->json('next_page_url')); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php new file mode 100644 index 0000000000..dfc864445a --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/OffsetConnector.php @@ -0,0 +1,48 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends OffsetPaginator { + /** + * Per Page. + */ + protected ?int $perPageLimit = 5; + + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return $this->getOffset() >= (int) $response->json('total'); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php new file mode 100644 index 0000000000..af6c6e4e45 --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/PagedConnector.php @@ -0,0 +1,43 @@ +paginate($this); + } + + return new class(connector: $this, request: $request) extends PagedPaginator { + /** + * Check if we are on the last page. + */ + protected function isLastPage(Response $response): bool + { + return empty($response->json('next_page_url')); + } + + /** + * Get the results from the page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data') ?? []; + } + }; + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php b/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php new file mode 100644 index 0000000000..ad874d7ce4 --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Connectors/TestConnector.php @@ -0,0 +1,22 @@ +collect('data')->pluck('superhero')->toArray(); + } +} diff --git a/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php b/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php new file mode 100644 index 0000000000..eb509a48de --- /dev/null +++ b/tests/Saloon/Pagination/Fixtures/Requests/SuperheroCursorRequest.php @@ -0,0 +1,22 @@ +toPsrRequest()->getUri(); + $query = Query::parse($uri->getQuery()); + $total = count(self::SUPERHEROES); + $page = (int) ($query['page'] ?? 1); + $perPage = (int) ($query['per_page'] ?? 5); + $cursor = (int) ($query['cursor'] ?? 0); + + return match ($uri->getPath()) { + '/api/superheroes/per-page' => Factory::response([ + 'data' => self::superheroes(($page - 1) * $perPage, $perPage), + 'total' => $total, + 'per_page' => $perPage, + 'next_page_url' => $page * $perPage < $total + ? self::BASE_URL . '/superheroes/per-page?page=' . ($page + 1) + : null, + ]), + '/api/superheroes/cursor' => Factory::response([ + 'data' => self::superheroes($cursor, $perPage), + 'next_page_url' => $cursor + $perPage < $total + ? self::BASE_URL . '/superheroes/cursor?cursor=' . ($cursor + $perPage) + : null, + ]), + '/api/superheroes/limit-offset' => Factory::response([ + 'data' => self::superheroes((int) ($query['offset'] ?? 0), (int) ($query['limit'] ?? 5)), + 'total' => $total, + ]), + default => Factory::response(status: 404), + }; + }); + } + + /** + * Get one slice of superheroes. + * + * @return list + */ + protected static function superheroes(int $offset, int $limit): array + { + $superheroes = []; + + foreach (array_slice(self::SUPERHEROES, $offset, $limit) as $index => $name) { + $superheroes[] = ['id' => $offset + $index + 1, 'superhero' => $name]; + } + + return $superheroes; + } +} diff --git a/tests/Saloon/Pagination/PaginationTestCase.php b/tests/Saloon/Pagination/PaginationTestCase.php new file mode 100644 index 0000000000..71a0267b77 --- /dev/null +++ b/tests/Saloon/Pagination/PaginationTestCase.php @@ -0,0 +1,33 @@ + [false, '0']; } - public function testRequestCanMapPaginatedItems(): void - { - $manager = $this->manager(); - $manager->fake([ - MappedPagedRequestStub::class => static function (PendingRequest $pendingRequest): MockResponse { - $page = (int) $pendingRequest->request()->queryParameters()['page']; - - return MockResponse::make([ - 'data' => [['name' => 'item-' . $page]], - 'page' => $page, - 'pages' => 1, - ]); - }, - ]); - $paginator = new PagedPaginatorStub( - new PaginationConnectorStub($manager), - new MappedPagedRequestStub, - ); - - $this->assertSame(['item-1'], iterator_to_array($paginator->items(), false)); - $this->assertSame(1, $paginator->request()->mapping->calls); - } - #[DataProvider('responseChanges')] public function testItemsAreMappedOnceAfterTheCompleteResponsePipeline(bool $replace): void { @@ -444,6 +422,22 @@ public function testMappedItemsAreReleasedOnAdvanceAndRewind(): void $this->assertSame(0, $paginator->totalResults()); } + public function testADroppedPaginatorIsFreedWhileItsResponseIsKept(): void + { + $manager = $this->manager(); + $manager->fake([PagedRequestStub::class => MockResponse::make([ + 'data' => [1], 'page' => 1, 'pages' => 2, + ])]); + $paginator = new PagedPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub); + $reference = WeakReference::create($paginator); + + $response = $paginator->current(); + unset($paginator); + + $this->assertNull($reference->get()); + $this->assertSame([1], $response->json('data')); + } + public function testPooledMappingReleasesItemsBeforePageCountsAndCallbacks(): void { $manager = $this->manager(); @@ -1000,18 +994,48 @@ public function testLinkPaginationMayStartAtPageZero(): void $this->assertCount(2, $paginator->pool()); } - public function testRepeatedBodiesStopASequentialPaginationLoop(): void + public function testBodyChecksumsCanIgnoreFieldsThatChangeOnEveryRequest(): void { $manager = $this->manager(); - $manager->fake([PagedRequestStub::class => MockResponse::make(['data' => [1]])]); + $requested = 0; + $manager->fake([PagedRequestStub::class => static function () use (&$requested): MockResponse { + return MockResponse::make(['data' => [1], 'request_id' => ++$requested]); + }]); + $paginator = (new DataChecksumPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub)) + ->maxPages(6); + $caught = null; + + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $caught = $exception; + } + + $this->assertInstanceOf(PaginationException::class, $caught); + $this->assertSame(5, $requested); + } + + public function testLoopDetectionChecksTheResponseReturnedByRequestMiddleware(): void + { + $manager = $this->manager(); + $requested = 0; + $manager->fake([NormalizedPagedRequestStub::class => static function () use (&$requested): MockResponse { + return MockResponse::make(['data' => [1], 'request_id' => ++$requested]); + }]); $paginator = (new NeverEndingPagedPaginatorStub( new PaginationConnectorStub($manager), - new PagedRequestStub, + new NormalizedPagedRequestStub, ))->maxPages(6); + $caught = null; - $this->expectException(PaginationException::class); + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $caught = $exception; + } - iterator_to_array($paginator); + $this->assertInstanceOf(PaginationException::class, $caught); + $this->assertSame(5, $requested); } public function testPooledPaginationFetchesTheFirstPageThenBoundsRemainingWork(): void @@ -1066,13 +1090,6 @@ public function testCursorPaginationCannotBePooledAndOffsetRequiresALimit(): voi (new OffsetPaginatorStub($connector, new OffsetRequestStub))->current(); } - public function testPaginatorRequiresAPaginatableRequest(): void - { - $this->expectException(InvalidArgumentException::class); - - new PagedPaginatorStub(new PaginationConnectorStub($this->manager()), new NonPaginatableRequestStub); - } - /** * Create a Saloon manager. */ @@ -1095,15 +1112,24 @@ protected function manager(): SaloonManager class PaginationConnectorStub extends Connector { + /** + * Create a connector that sends through the given manager. + */ public function __construct(protected SaloonManager $manager) { } + /** + * Resolve the base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Send the request through the test manager. + */ public function send(Request $request, ?MockClient $mockClient = null): Response { return $this->manager->send($this, $request, $mockClient); @@ -1114,29 +1140,27 @@ class PagedRequestStub extends Request implements Paginatable { protected Method $method = Method::GET; + /** + * Resolve the endpoint. + */ public function resolveEndpoint(): string { return '/paged'; } } -class MappedPagedRequestStub extends PagedRequestStub implements MapPaginatedResponseItems +class NormalizedPagedRequestStub extends PagedRequestStub { - public object $mapping; - /** - * Share mapping observations across request clones. + * Remove the per-request identifier from each response body. */ - public function __construct() + public function boot(PendingRequest $pendingRequest): void { - $this->mapping = (object) ['calls' => 0]; - } - - public function mapPaginatedResponseItems(Response $response): array - { - ++$this->mapping->calls; - - return array_column($response->json('data'), 'name'); + $pendingRequest->middleware()->onResponse(static fn (Response $response): Response => Response::fromResponse( + new HttpResponse(new PsrResponse($response->status(), [], json_encode(['data' => $response->json('data')]))), + $response->pendingRequest(), + $response->toPsrRequest(), + )); } } @@ -1144,6 +1168,9 @@ class OffsetRequestStub extends Request implements Paginatable { protected Method $method = Method::GET; + /** + * Resolve the endpoint. + */ public function resolveEndpoint(): string { return '/offset'; @@ -1154,34 +1181,36 @@ class CursorRequestStub extends Request implements Paginatable { protected Method $method = Method::GET; + /** + * Resolve the endpoint. + */ public function resolveEndpoint(): string { return '/cursor'; } } -class NonPaginatableRequestStub extends Request -{ - protected Method $method = Method::GET; - - public function resolveEndpoint(): string - { - return '/invalid'; - } -} - class PagedPaginatorStub extends PagedPaginator { + /** + * Determine if the response is the last page. + */ protected function isLastPage(Response $response): bool { return $response->json('page') >= $response->json('pages'); } + /** + * Get the items from one page. + */ protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); } + /** + * Get the total number of pages. + */ protected function getTotalPages(Response $response): int { return (int) $response->json('pages'); @@ -1190,17 +1219,34 @@ protected function getTotalPages(Response $response): int class NeverEndingPagedPaginatorStub extends PagedPaginator { + /** + * Keep requesting pages. + */ protected function isLastPage(Response $response): bool { return false; } + /** + * Get the items from one page. + */ protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); } } +class DataChecksumPaginatorStub extends NeverEndingPagedPaginatorStub +{ + /** + * Compare pages by their items only. + */ + protected function getBodyChecksum(Response $response): string + { + return hash('xxh128', json_encode($response->json('data'))); + } +} + class MappingPagedPaginatorStub extends PagedPaginatorStub { public array $mappedPages = []; @@ -1237,16 +1283,25 @@ class RenamedPagedPaginatorStub extends NeverEndingPagedPaginatorStub class OffsetPaginatorStub extends OffsetPaginator { + /** + * Determine if the response is the last page. + */ protected function isLastPage(Response $response): bool { return $response->json('offset') + count($response->json('data')) >= $response->json('total'); } + /** + * Get the items from one page. + */ protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); } + /** + * Get the total number of pages. + */ protected function getTotalPages(Response $response): int { return (int) ceil($response->json('total') / $this->perPageLimit); @@ -1255,16 +1310,25 @@ protected function getTotalPages(Response $response): int class CursorPaginatorStub extends CursorPaginator { + /** + * Get the next cursor. + */ protected function getNextCursor(Response $response): int|string { return $response->json('next'); } + /** + * Determine if the response is the last page. + */ protected function isLastPage(Response $response): bool { return $response->json('next') === null; } + /** + * Get the items from one page. + */ protected function getPageItems(Response $response, Request $request): array { return $response->json('data'); diff --git a/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php new file mode 100644 index 0000000000..770bf57521 --- /dev/null +++ b/tests/Saloon/RateLimit/Feature/HasRateLimitsTest.php @@ -0,0 +1,635 @@ +make('config')->set('saloon.rate_limiter.store', 'worker-array'); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + Http::preventStrayRequests(); + CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC'); + } + + public function testWhenMakingARequestWithTheHasRateLimitsTraitAddedItWillRecordTheHitsAndThrowExceptions(): void + { + $limit = Limit::perMinute(3); + $connector = new TestConnector([$limit]); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + // Let's start by making sure nothing has been recorded + + $this->assertSame(3, Saloon::inspectRateLimit($connector, $limit)->remaining()); + + // We'll now send four requests. The first three will be fine, the fourth should throw an exception + + $responseA = $connector->send(new UserRequest); + $this->assertSame(200, $responseA->status()); + + $result = Saloon::inspectRateLimit($connector, $limit); + $this->assertSame(2, $result->remaining()); + $this->assertSame(60, $result->resetAfter()); + + // A successful response records no "too many attempts" cooldown + + $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed()); + + $responseB = $connector->send(new UserRequest); + $this->assertSame(200, $responseB->status()); + $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining()); + + $responseC = $connector->send(new UserRequest); + $this->assertSame(200, $responseC->status()); + $this->assertSame(0, Saloon::inspectRateLimit($connector, $limit)->remaining()); + $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed()); + + // Finally, when we make the fourth request it should throw an exception + + $thrown = false; + + try { + $connector->send(new UserRequest); + } catch (RateLimitReachedException $exception) { + $thrown = true; + + $this->assertSame( + sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class), + $exception->getMessage(), + ); + $this->assertSame($limit, $exception->policy()); + $this->assertSame(0, $exception->result()->remaining()); + $this->assertSame(60, $exception->result()->retryAfter()); + } + + $this->assertTrue($thrown); + $this->assertTrue(Saloon::inspectRateLimit($connector, $limit)->denied()); + Http::assertSentCount(3); + } + + public function testWhenMakingARequestWithTheHasRateLimitsTraitAddedItWillRecordTheHitsAndCanSleep(): void + { + Sleep::fake(syncWithCarbon: true); + + $limit = Limit::perSecond(3, 5); + $connector = new TestConnector([$limit], waitFor: [$limit]); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + // We'll now send four requests. The first three will be fine, the fourth should wait + + $responseA = $connector->send(new UserRequest); + $this->assertSame(200, $responseA->status()); + + $result = Saloon::inspectRateLimit($connector, $limit); + $this->assertSame(2, $result->remaining()); + $this->assertSame(5, $result->resetAfter()); + + $responseB = $connector->send(new UserRequest); + $this->assertSame(200, $responseB->status()); + $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining()); + + $responseC = $connector->send(new UserRequest); + $this->assertSame(200, $responseC->status()); + $this->assertSame(0, Saloon::inspectRateLimit($connector, $limit)->remaining()); + + // Now when we make this request, it should pause for 5 seconds and then start a new window + + $responseD = $connector->send(new UserRequest); + + $this->assertSame(200, $responseD->status()); + Sleep::assertSequence([Sleep::for(5)->seconds()]); + $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining()); + Http::assertSentCount(4); + } + + // The 429 response is returned and its cooldown is recorded, so the next request throws instead of the 429 itself. + public function testYouCanCreateALimiterThatListensFor429AndWillAutomaticallyBackOffForTheRetryAfterDuration(): void + { + $limit = Limit::perSecond(3, 5); + $connector = new TestConnector([$limit], waitFor: [$limit]); + $cooldown = Cooldown::for(null); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed()); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam', 'status' => 'Success']) + ->push(['name' => 'Gareth', 'status' => 'Success']) + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '500'])]); + + $connector->send(new UserRequest); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed()); + + $connector->send(new UserRequest); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed()); + $this->assertSame(429, $connector->send(new UserRequest)->status()); + + $thrown = false; + + try { + $connector->send(new UserRequest); + } catch (RateLimitReachedException $exception) { + $thrown = true; + + $this->assertSame( + sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class), + $exception->getMessage(), + ); + $this->assertInstanceOf(Cooldown::class, $exception->policy()); + $this->assertSame(500, $exception->result()->retryAfter()); + } + + $this->assertTrue($thrown); + Http::assertSentCount(3); + } + + #[DataProvider('unusableRetryAfterProvider')] + public function testIfTheRetryAfterHeaderIsMissingOrCannotBeParsedThenTheDefaultRetryIs60Seconds(?string $retryAfter): void + { + $limit = Limit::perSecond(3, 5); + $connector = new TestConnector([$limit], waitFor: [$limit]); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam', 'status' => 'Success']) + ->push(['name' => 'Gareth', 'status' => 'Success']) + ->push(['status' => 'Too Many Requests'], 429, $retryAfter === null ? [] : ['Retry-After' => $retryAfter])]); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + $thrown = false; + + try { + $connector->send(new UserRequest); + } catch (RateLimitReachedException $exception) { + $thrown = true; + + $this->assertSame( + sprintf('The request rate limit [saloon:%s] has been reached.', TestConnector::class), + $exception->getMessage(), + ); + $this->assertInstanceOf(Cooldown::class, $exception->policy()); + $this->assertSame(60, $exception->result()->retryAfter()); + } + + $this->assertTrue($thrown); + } + + /** + * Get Retry-After values that do not give a usable delay. + */ + public static function unusableRetryAfterProvider(): array + { + return [ + 'missing' => [null], + 'malformed' => ['not-working'], + 'not an HTTP date' => ['01/01/2023'], + ]; + } + + #[DataProvider('elapsedRetryAfterProvider')] + public function testAValidRetryAfterThatHasAlreadyPassedRecordsNoCooldown(string $retryAfter): void + { + $connector = new TestConnector([]); + + Http::fake(['*' => Http::sequence() + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => $retryAfter]) + ->push(['name' => 'Sam'])]); + + $this->assertSame(429, $connector->send(new UserRequest)->status()); + $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed()); + $this->assertSame(200, $connector->send(new UserRequest)->status()); + } + + /** + * Get valid Retry-After values whose delay has already passed. + */ + public static function elapsedRetryAfterProvider(): array + { + return [ + 'zero seconds' => ['0'], + 'past date' => ['Wed, 12 Aug 2026 12:00:00 GMT'], + ]; + } + + // No admission policies are declared, so only the response-driven cooldown can deny the fourth request. + public function testYouCanCustomiseWhenTheTooManyRequestsLimitIsApplied(): void + { + $connector = new CustomTooManyRequestsConnector([]); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam']) + ->push(['name' => 'Sam']) + ->push(['error' => 'Too Many Attempts'])]); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + $this->assertSame(['error' => 'Too Many Attempts'], $connector->send(new UserRequest)->json()); + + try { + $connector->send(new UserRequest); + $this->fail('The custom cooldown was not applied.'); + } catch (RateLimitReachedException $exception) { + $this->assertSame( + sprintf('The request rate limit [saloon:%s] has been reached.', CustomTooManyRequestsConnector::class), + $exception->getMessage(), + ); + $this->assertInstanceOf(Cooldown::class, $exception->policy()); + $this->assertSame(60, $exception->result()->retryAfter()); + } + + Http::assertSentCount(3); + } + + public function testYouCanDisableThe429ErrorDetection(): void + { + $connector = new DisabledTooManyRequestsConnector([]); + $cooldown = Cooldown::for(null); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam']) + ->push(['name' => 'Sam']) + ->push(['status' => 'Too Many Requests'], 429) + ->push(['name' => 'Sam'])]); + + $connector->send(new UserRequest); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed()); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $cooldown)->allowed()); + $this->assertSame(200, $connector->send(new UserRequest)->status()); + } + + public function testYouCanCustomiseThe429ErrorDetectionToSleepInstead(): void + { + Sleep::fake(syncWithCarbon: true); + + $connector = new SleepTooManyRequestsConnector([]); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam']) + ->push(['status' => 'Too Many Requests'], 429) + ->push(['name' => 'Jon'])]); + + $connector->send(new UserRequest); + + $response = $connector->send(new UserRequest); + + $this->assertSame(['name' => 'Jon'], $response->json()); + Sleep::assertSequence([Sleep::for(5)->seconds()]); + Http::assertSentCount(3); + } + + public function testTheRateLimiterCanBeUsedOnARequest(): void + { + $connector = new BaseConnector; + $request = new LimitedRequest; + $limit = Limit::perMinute(60); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + // Let's start by making sure nothing has been recorded + + $this->assertSame(60, Saloon::inspectRateLimit($request, $limit)->remaining()); + + $connector->send($request); + + $result = Saloon::inspectRateLimit($request, $limit); + $this->assertSame(59, $result->remaining()); + $this->assertSame(60, $result->resetAfter()); + $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed()); + } + + public function testWhenTheTheRateLimiterIsUsedOnBothTheConnectorOrRequestAllTheLimitsAreUsed(): void + { + $connectorLimit = Limit::perMinute(3); + $requestLimit = Limit::perMinute(60); + $connector = new TestConnector([$connectorLimit]); + $request = new LimitedRequest; + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $connector->send($request); + + $result = Saloon::inspectRateLimit($connector, $connectorLimit); + $this->assertSame(2, $result->remaining()); + $this->assertSame(60, $result->resetAfter()); + + $result = Saloon::inspectRateLimit($request, $requestLimit); + $this->assertSame(59, $result->remaining()); + $this->assertSame(60, $result->resetAfter()); + + $this->assertTrue(Saloon::inspectRateLimit($connector, Cooldown::for(null))->allowed()); + $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed()); + } + + public function testTheRateLimiterCanBeUsedOnASoloRequest(): void + { + $request = new LimitedSoloRequest; + $limit = Limit::perMinute(60); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $this->assertSame(60, Saloon::inspectRateLimit($request, $limit)->remaining()); + + $request->send(); + + $result = Saloon::inspectRateLimit($request, $limit); + $this->assertSame(59, $result->remaining()); + $this->assertSame(60, $result->resetAfter()); + $this->assertTrue(Saloon::inspectRateLimit($request, Cooldown::for(null))->allowed()); + } + + // REMOVED: you can specify a custom closure to determine the limiter based on response - Limit::custom() is not + // included. Response-driven limits override resolveRateLimitCooldown(), as + // testYouCanCustomiseWhenTheTooManyRequestsLimitIsApplied covers. + + public function testIfAConnectorHasTheAlwaysThrowOnErrorTraitThenTheLimiterWillTakePriority(): void + { + $limit = Limit::perMinute(3); + $connector = new ThrowConnector([$limit]); + + Http::fake(['*' => Http::sequence() + ->push(['name' => 'Sam'], 500) + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '10'])]); + + $thrown = false; + + try { + $connector->send(new UserRequest); + } catch (InternalServerErrorException) { + $thrown = true; + } + + $this->assertTrue($thrown); + + // The limit was charged before the request was sent, and a 429's cooldown is recorded before the response + // throws. + + $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining()); + + $thrown = false; + + try { + $connector->send(new UserRequest); + } catch (TooManyRequestsException) { + $thrown = true; + } + + $this->assertTrue($thrown); + $this->assertSame(10, Saloon::inspectRateLimit($connector, Cooldown::for(null))->retryAfter()); + } + + public function testTheLimitIsGivenTheCorrectPrefixEvenWithCustomNames(): void + { + $limit = Limit::perMinute(3); + $namedLimit = Limit::perMinute(3)->by('custom_name'); + $connector = new TestConnector([$limit, $namedLimit]); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $connector->send(new UserRequest); + + // Both limits are recorded under the connector's limiter name, so another name does not see them + + $this->assertSame(2, Saloon::inspectRateLimit($connector, $limit)->remaining()); + $this->assertSame(2, Saloon::inspectRateLimit($connector, $namedLimit)->remaining()); + $this->assertSame(3, Saloon::inspectRateLimit(new CustomPrefixConnector([]), $limit)->remaining()); + $this->assertSame(3, Saloon::inspectRateLimit(new CustomPrefixConnector([]), $namedLimit)->remaining()); + } + + public function testYouCanCustomiseThePrefix(): void + { + $limit = Limit::perMinute(3); + $namedLimit = Limit::perMinute(3)->by('custom_name'); + $connector = new CustomPrefixConnector([$limit, $namedLimit]); + $sharingConnector = new class([$limit]) extends TestConnector { + /** + * Resolve the limiter name. + */ + protected function resolveRateLimiterName(): string + { + return 'custom'; + } + }; + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $connector->send(new UserRequest); + $sharingConnector->send(new UserRequest); + + // Connectors with the same limiter name share the state of the same policy + + $this->assertSame(1, Saloon::inspectRateLimit($connector, $limit)->remaining()); + $this->assertSame(2, Saloon::inspectRateLimit($connector, $namedLimit)->remaining()); + $this->assertSame(3, Saloon::inspectRateLimit(new TestConnector([]), $limit)->remaining()); + } + + public function testResourcesWithTheSameLimiterNameShareCooldownsUnlessTheirCooldownKeysDiffer(): void + { + $connector = new CustomPrefixConnector([]); + $sharingConnector = new class([]) extends TestConnector { + /** + * Resolve the limiter name. + */ + protected function resolveRateLimiterName(): string + { + return 'custom'; + } + }; + $otherAccountConnector = new class([]) extends CustomPrefixConnector { + /** + * Resolve the cooldown key. + */ + protected function resolveRateLimitCooldownKey(PendingRequest $pendingRequest): string + { + return 'account:other'; + } + }; + + Http::fake(['*' => Http::sequence() + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '30']) + ->push(['name' => 'Sam'])]); + + $this->assertSame(429, $connector->send(new UserRequest)->status()); + + try { + $sharingConnector->send(new UserRequest); + $this->fail('A shared cooldown reached transport.'); + } catch (RateLimitReachedException $exception) { + $this->assertSame(30, $exception->result()->retryAfter()); + } + + $this->assertSame(200, $otherAccountConnector->send(new UserRequest)->status()); + Http::assertSentCount(2); + } + + // Connectors are read-only, so an operation-aware rateLimitingEnabled() replaces upstream's useRateLimitPlugin(). + public function testYouCanProgrammaticallyDisableTheRateLimiting(): void + { + $limit = Limit::perMinute(3); + $connector = new BaseConnector; + + Http::fake(['*' => Http::sequence() + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '30']) + ->push(['status' => 'Too Many Requests'], 429, ['Retry-After' => '90']) + ->push(['name' => 'Sam'])]); + + $this->assertSame(429, $connector->send(new OptionallyLimitedRequest($limit))->status()); + $this->assertSame(2, Saloon::inspectRateLimit(new OptionallyLimitedRequest($limit), $limit)->remaining()); + + // A disabled operation ignores the active cooldown and its policies, and records no cooldown of its own + + $this->assertSame(429, $connector->send(new OptionallyLimitedRequest($limit, limited: false))->status()); + $this->assertSame(200, $connector->send(new OptionallyLimitedRequest($limit, limited: false))->status()); + $this->assertSame(2, Saloon::inspectRateLimit(new OptionallyLimitedRequest($limit), $limit)->remaining()); + + try { + $connector->send(new OptionallyLimitedRequest($limit)); + $this->fail('The enabled request ignored the recorded cooldown.'); + } catch (RateLimitReachedException $exception) { + $this->assertSame(30, $exception->result()->retryAfter()); + } + + Http::assertSentCount(3); + } + + // REMOVED: it will fail if you dont configure a limiter properly - a limit without a window is rejected when it is + // created. See RateLimiter/LimitTest::testInvalidScalarValuesAreRejected. + + public function testAResourceCanWaitForOneLimitAndThrowForAnother(): void + { + Sleep::fake(syncWithCarbon: true); + + $burstLimit = Limit::perSecond(1); + $dailyLimit = Limit::perDay(2)->by('daily'); + $connector = new TestConnector([$burstLimit, $dailyLimit], waitFor: [$burstLimit]); + + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $connector->send(new UserRequest); + $connector->send(new UserRequest); + + try { + $connector->send(new UserRequest); + $this->fail('The daily limit was awaited.'); + } catch (RateLimitReachedException $exception) { + $this->assertSame( + sprintf('The request rate limit [saloon:%s:daily] has been reached.', TestConnector::class), + $exception->getMessage(), + ); + $this->assertSame($dailyLimit, $exception->policy()); + } + + Sleep::assertSequence([Sleep::for(1)->seconds(), Sleep::for(1)->seconds()]); + Http::assertSentCount(2); + } +} + +class ThrowConnector extends TestConnector +{ + use AlwaysThrowOnErrors; +} + +class OptionallyLimitedRequest extends Request +{ + use HasRateLimits; + + /** + * Define the HTTP method. + */ + protected Method $method = Method::GET; + + /** + * Create a request whose limit applies unless it is disabled. + */ + public function __construct( + protected Limit $limit, + protected bool $limited = true, + ) { + } + + /** + * Define the endpoint for the request. + */ + public function resolveEndpoint(): string + { + return '/user'; + } + + /** + * Determine if the request's rate limits apply to an operation. + */ + protected function rateLimitingEnabled(PendingRequest $pendingRequest): bool + { + return $this->limited; + } + + /** + * Resolve the limits. + */ + protected function resolveRateLimits(PendingRequest $pendingRequest): array + { + return [$this->limit]; + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php new file mode 100644 index 0000000000..15c782d815 --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Connectors/BaseConnector.php @@ -0,0 +1,18 @@ +json('error') === 'Too Many Attempts' ? 60 : null; + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php new file mode 100644 index 0000000000..d7dbc55b0b --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Connectors/DestructConnector.php @@ -0,0 +1,50 @@ +destructed = true; + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php new file mode 100644 index 0000000000..bed14f65b4 --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Connectors/DisabledTooManyRequestsConnector.php @@ -0,0 +1,19 @@ + $exception instanceof RequestException + && $exception->status() === 429, + ); + } + + /** + * Resolve the cooldown imposed by a response. + */ + protected function resolveRateLimitCooldown(Response $response): ?int + { + return $response->status() === 429 ? 5 : null; + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php b/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php new file mode 100644 index 0000000000..724327f646 --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Connectors/TestConnector.php @@ -0,0 +1,46 @@ + $limits + * @param list $waitFor + */ + public function __construct( + protected array $limits, + protected array $waitFor = [], + ) { + } + + /** + * Resolve the limits. + */ + protected function resolveRateLimits(PendingRequest $pendingRequest): array + { + return $this->limits; + } + + /** + * Determine if a denied rate limit should be awaited. + */ + protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool + { + return in_array($policy, $this->waitFor, true); + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php b/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php new file mode 100644 index 0000000000..fd05c75581 --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Jobs/ApiRateLimitedJob.php @@ -0,0 +1,47 @@ + + */ + public function middleware(): array + { + return [new ReleaseOnRateLimit]; + } + + /** + * Create a job that sends through the given connector. + */ + public function __construct(protected TestConnector $connector) + { + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->connector->send(new UserRequest); + } +} diff --git a/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php b/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php new file mode 100644 index 0000000000..7d4fde9715 --- /dev/null +++ b/tests/Saloon/RateLimit/Fixtures/Requests/LimitedRequest.php @@ -0,0 +1,39 @@ +make('config')->set('saloon.rate_limiter.store', 'worker-array'); + } + + public function testTheApiRateLimitedJobWillReleaseAJobOntoTheQueueIfALimitIsReached(): void + { + $limit = Limit::perMinute(3); + $connector = new TestConnector([$limit]); + + Http::preventStrayRequests(); + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $jobs = []; + + Queue::after(static function (JobProcessed $event) use (&$jobs): void { + $jobs[] = $event->job; + }); + + ApiRateLimitedJob::dispatchSync($connector); + ApiRateLimitedJob::dispatchSync($connector); + ApiRateLimitedJob::dispatchSync($connector); + ApiRateLimitedJob::dispatchSync($connector); + + $this->assertCount(4, $jobs); + $this->assertFalse($jobs[0]->isReleased()); + $this->assertFalse($jobs[1]->isReleased()); + $this->assertFalse($jobs[2]->isReleased()); + $this->assertTrue($jobs[3]->isReleased()); + + $this->assertTrue(Saloon::inspectRateLimit($connector, $limit)->denied()); + Http::assertSentCount(3); + } + + public function testSuccessfulJobsPassThroughAndReturnTheirResult(): void + { + $job = new RateLimitedJobStub; + + $result = (new ReleaseOnRateLimit)->handle( + $job, + fn (RateLimitedJobStub $handledJob): string => $handledJob === $job ? 'completed' : 'wrong job', + ); + + $this->assertSame('completed', $result); + $this->assertNull($job->releasedAfter); + } + + public function testDeniedRequestsReleaseTheJobForTheDecisionDelay(): void + { + $job = new RateLimitedJobStub; + $exception = new RateLimitReachedException( + 'saloon:provider', + Limit::perMinute(1)->by('provider'), + new LimitResult(false, 1, 0, 2_500_000, 2_500_000), + ); + + $result = (new ReleaseOnRateLimit)->handle($job, static fn (): never => throw $exception); + + $this->assertNull($result); + $this->assertSame(3, $job->releasedAfter); + } + + public function testOtherExceptionsPropagate(): void + { + $exception = new RuntimeException('Job failed.'); + + $this->expectExceptionObject($exception); + + (new ReleaseOnRateLimit)->handle(new RateLimitedJobStub, static fn (): never => throw $exception); + } +} + +class RateLimitedJobStub +{ + public ?int $releasedAfter = null; + + /** + * Release the job back onto the queue. + */ + public function release(int $delay = 0): void + { + $this->releasedAfter = $delay; + } +} diff --git a/tests/Saloon/RateLimit/RateLimitTest.php b/tests/Saloon/RateLimit/RateLimitTest.php index 77f44c4b6d..b75773df4a 100644 --- a/tests/Saloon/RateLimit/RateLimitTest.php +++ b/tests/Saloon/RateLimit/RateLimitTest.php @@ -13,6 +13,7 @@ use Hypervel\Events\Dispatcher; use Hypervel\Http\Client\Factory; use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Contracts\Decision; use Hypervel\RateLimiter\Cooldown; use Hypervel\RateLimiter\KeyResolver; use Hypervel\RateLimiter\Limit; @@ -37,50 +38,11 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; -use RuntimeException; +// Feature/HasRateLimitsTest covers consuming connector and request policies, denials before transport, cooldowns +// recorded before response middleware and waiting, and Unit/HelperTest covers Retry-After parsing. class RateLimitTest extends TestCase { - public function testConnectorAndRequestPoliciesAreConsumedInOrder(): void - { - [$manager, $limiter, $http] = $this->manager(); - $http->fake(['*' => Factory::response(['ok' => true])]); - $connector = new RateLimitedConnectorStub($manager); - $request = new RateLimitedRequestStub; - - $response = $connector->send($request); - - $this->assertTrue($response->successful()); - $this->assertSame(1, $limiter->inspect( - $connector->policy(), - 'saloon:' . $connector::class, - )->remaining()); - $this->assertSame(1, $limiter->inspect( - $request->policy(), - 'saloon:' . $request::class, - )->remaining()); - } - - public function testDeniedPolicyThrowsItsDecisionBeforeTransport(): void - { - [$manager, , $http] = $this->manager(); - $http->fake(['*' => Factory::response(['ok' => true])]); - $connector = new RateLimitedConnectorStub($manager, maximumAttempts: 1); - - $connector->send(new PlainRateLimitRequestStub); - - try { - $connector->send(new PlainRateLimitRequestStub); - $this->fail('A denied rate limit reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertInstanceOf(Limit::class, $exception->policy()); - $this->assertSame('connector', $exception->policy()->key); - $this->assertTrue($exception->result()->denied()); - } - - $http->assertSentCount(1); - } - public function testFakesAndCacheHitsDoNotConsumeAdmissionCapacity(): void { [$manager, $limiter, $http] = $this->manager(); @@ -89,7 +51,7 @@ public function testFakesAndCacheHitsDoNotConsumeAdmissionCapacity(): void $policy = Limit::perMinute(2)->by('cache'); $request = new CachedRateLimitedRequestStub($policy); - $manager->fake([MockResponse::make(['fake' => true])]); + $manager->fake((new MockClient([MockResponse::make(['fake' => true])]))->withoutCache()); $connector->send($request); $manager->clearFake(); @@ -132,6 +94,9 @@ public function testDeniedRequestGroupDoesNotChargeItsEarlierPolicyButKeepsTheSe $http->fake(['*' => Factory::response(['unexpected' => true])]); $connector = new RateLimitedConnectorStub($manager); $request = new class extends MultipleRateLimitRequestStub { + /** + * Resolve both policies in one request group. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return ['first' => $this->firstPolicy(), 'second' => $this->secondPolicy()]; @@ -154,245 +119,6 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array $http->assertNothingSent(); } - public function testServerCooldownIsRecordedBeforeResponseMiddleware(): void - { - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => '10']) - ->push(['unexpected' => true])]); - $connector = new PlainRateLimitConnectorStub($manager); - - try { - $connector->send(new ThrowingCooldownRequestStub); - $this->fail('The response middleware did not throw.'); - } catch (RuntimeException $exception) { - $this->assertSame('response middleware failed', $exception->getMessage()); - } - - try { - $connector->send(new ThrowingCooldownRequestStub); - $this->fail('A recorded cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame(10, $exception->result()->retryAfter()); - } - - $http->assertSentCount(1); - } - - public function testRetryAfterHttpDatesAreParsedAndInvalidValuesAreIgnored(): void - { - $now = CarbonImmutable::parse('2026-08-13 12:00:00 UTC'); - CarbonImmutable::setTestNow($now); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => $now->addSeconds(5)->toRfc7231String()]) - ->push([], 429, ['Retry-After' => 'invalid']) - ->push(['ok' => true])]); - $connector = new PlainRateLimitConnectorStub($manager); - $dated = new DateCooldownRequestStub; - - $connector->send($dated); - - try { - $connector->send($dated); - $this->fail('A date-based cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame(5, $exception->result()->retryAfter()); - } - - $invalid = new InvalidCooldownRequestStub; - $this->assertSame(429, $connector->send($invalid)->status()); - $this->assertTrue($connector->send($invalid)->successful()); - $http->assertSentCount(3); - } - - public function testObsoleteHttpDatesAndCheckedNumericDurationsAreParsed(): void - { - $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC'); - CarbonImmutable::setTestNow($now); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => $now->addSeconds(5)->format('l, d-M-y H:i:s \G\M\T')]) - ->push([], 429, ['Retry-After' => 'Thu Aug 6 12:00:06 2026']) - ->push([], 429, ['Retry-After' => 'Fri, 06 Aug 2026 12:00:07 GMT']) - ->push([], 429, ['Retry-After' => '0008']) - ->push([], 429, ['Retry-After' => str_repeat('9', 30)]) - ->push(['ok' => true])]); - $connector = new PlainRateLimitConnectorStub($manager); - $assertCooldown = function (Request $request, int $seconds) use ($connector): void { - $connector->send($request); - - try { - $connector->send($request); - $this->fail('A parsed cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame($seconds, $exception->result()->retryAfter()); - } - }; - - $assertCooldown(new Rfc850CooldownRequestStub, 5); - $assertCooldown(new AsctimeCooldownRequestStub, 6); - $assertCooldown(new MismatchedWeekdayCooldownRequestStub, 7); - $assertCooldown(new NumericCooldownRequestStub, 8); - $this->assertSame(429, $connector->send(new OversizedCooldownRequestStub)->status()); - $this->assertTrue($connector->send(new OversizedCooldownRequestStub)->successful()); - $http->assertSentCount(6); - } - - public function testTwoDigitAsctimeDayIsParsed(): void - { - CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC'); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => Factory::response( - body: [], - status: 429, - headers: ['Retry-After' => 'Thu Aug 13 12:00:07 2026'], - )]); - $connector = new PlainRateLimitConnectorStub($manager); - $request = new TwoDigitAsctimeCooldownRequestStub; - - $connector->send($request); - - try { - $connector->send($request); - $this->fail('A two-digit asctime cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame(7, $exception->result()->retryAfter()); - } - - $http->assertSentCount(1); - } - - public function testHttpDateProtocolRulesAndInvalidCalendarValuesAreHandled(): void - { - $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC'); - CarbonImmutable::setTestNow($now); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => 'Sunday, 06-Nov-70 12:00:00 GMT']) - ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 12:00:60 GMT']) - ->push([], 429, ['Retry-After' => 'Thu, 31 Feb 2026 12:00:09 GMT']) - ->push(['ok' => true]) - ->push([], 429, ['Retry-After' => 'Thu, 06 Aug 2026 25:00:09 GMT']) - ->push(['ok' => true])]); - $connector = new PlainRateLimitConnectorStub($manager); - $assertCooldown = function (Request $request, int $seconds) use ($connector): void { - $connector->send($request); - - try { - $connector->send($request); - $this->fail('A parsed cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame($seconds, $exception->result()->retryAfter()); - } - }; - - $assertCooldown( - new Rfc850FutureYearCooldownRequestStub, - CarbonImmutable::parse('2070-11-06 12:00:00 UTC')->getTimestamp() - $now->getTimestamp(), - ); - $assertCooldown(new LeapSecondCooldownRequestStub, 60); - - $invalid = new InvalidCalendarCooldownRequestStub; - $this->assertSame(429, $connector->send($invalid)->status()); - $this->assertTrue($connector->send($invalid)->successful()); - - $invalid = new InvalidTimeCooldownRequestStub; - $this->assertSame(429, $connector->send($invalid)->status()); - $this->assertTrue($connector->send($invalid)->successful()); - $http->assertSentCount(6); - } - - public function testRfc850YearResolutionUsesGmtAtACenturyBoundary(): void - { - $previousTimezone = date_default_timezone_get(); - date_default_timezone_set('America/Los_Angeles'); - - try { - $now = CarbonImmutable::parse('2100-01-01 00:30:00 UTC'); - CarbonImmutable::setTestNow($now); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => Factory::response( - body: [], - status: 429, - headers: ['Retry-After' => 'Sunday, 01-Jan-50 00:30:00 GMT'], - )]); - $connector = new PlainRateLimitConnectorStub($manager); - $request = new Rfc850FutureYearCooldownRequestStub; - - $connector->send($request); - - try { - $connector->send($request); - $this->fail('An RFC 850 cooldown resolved against the wrong timezone.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame( - CarbonImmutable::parse('2150-01-01 00:30:00 UTC')->getTimestamp() - $now->getTimestamp(), - $exception->result()->retryAfter(), - ); - } - - $http->assertSentCount(1); - } finally { - date_default_timezone_set($previousTimezone); - } - } - - public function testRfc850YearResolutionUsesTheCompleteFiftyYearBoundary(): void - { - $now = CarbonImmutable::parse('2026-08-06 12:00:00 UTC'); - CarbonImmutable::setTestNow($now); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => 'Thursday, 31-Dec-76 12:00:00 GMT']) - ->push(['ok' => true]) - ->push([], 429, ['Retry-After' => 'Thursday, 06-Aug-76 12:00:00 GMT']) - ->push([], 429, ['Retry-After' => 'Thursday, 01-Jan-76 12:00:00 GMT']) - ->push([], 429, ['Retry-After' => 'Thu, 31 Dec 2076 12:00:00 GMT'])]); - $connector = new PlainRateLimitConnectorStub($manager); - $assertCooldown = function (Request $request, string $availableAt) use ($connector, $now): void { - $connector->send($request); - - try { - $connector->send($request); - $this->fail('A date-based cooldown reached transport.'); - } catch (RateLimitReachedException $exception) { - $this->assertSame( - CarbonImmutable::parse($availableAt)->getTimestamp() - $now->getTimestamp(), - $exception->result()->retryAfter(), - ); - } - }; - - $rolledBack = new Rfc850BeyondFiftyYearsCooldownRequestStub; - $this->assertSame(429, $connector->send($rolledBack)->status()); - $this->assertTrue($connector->send($rolledBack)->successful()); - - $assertCooldown(new Rfc850ExactFiftyYearsCooldownRequestStub, '2076-08-06 12:00:00 UTC'); - $assertCooldown(new Rfc850WithinFiftyYearsCooldownRequestStub, '2076-01-01 12:00:00 UTC'); - $assertCooldown(new ImfBeyondFiftyYearsCooldownRequestStub, '2076-12-31 12:00:00 UTC'); - $http->assertSentCount(5); - } - - public function testWaitingUsesTheStoreDelayAndThenContinues(): void - { - CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC'); - Sleep::fake(syncWithCarbon: true); - [$manager, , $http] = $this->manager(); - $http->fake(['*' => $http->sequence() - ->push([], 429, ['Retry-After' => '2']) - ->push(['ok' => true])]); - $connector = new PlainRateLimitConnectorStub($manager); - $request = new WaitingCooldownRequestStub; - - $connector->send($request); - $response = $connector->send($request); - - $this->assertTrue($response->successful()); - Sleep::assertSlept(static fn ($duration): bool => (float) $duration->totalSeconds === 2.0); - $http->assertSentCount(2); - } - public function testWaitingForAGroupRechecksCooldownBeforeChargingTheGroup(): void { CarbonImmutable::setTestNow('2026-08-13 12:00:00 UTC'); @@ -409,7 +135,7 @@ public function testWaitingForAGroupRechecksCooldownBeforeChargingTheGroup(): vo $this->assertSame(10, $limiter->inspect($request->firstPolicy(), $limiterName)->remaining()); if (! $published) { - $limiter->block(Cooldown::for($request::class), 5, $limiterName); + $limiter->block(Cooldown::for(null), 5, $limiterName); $published = true; } }); @@ -469,15 +195,24 @@ protected function manager(): array class PlainRateLimitConnectorStub extends Connector { + /** + * Create a connector that sends through the given manager. + */ public function __construct(protected SaloonManager $manager) { } + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Send a request through the given manager. + */ public function send(Request $request, ?MockClient $mockClient = null): Response { return $this->manager->send($this, $request, $mockClient); @@ -488,6 +223,9 @@ class RateLimitedConnectorStub extends PlainRateLimitConnectorStub { use HasRateLimits; + /** + * Create a connector with the given attempt limit. + */ public function __construct( SaloonManager $manager, protected int $maximumAttempts = 2, @@ -495,11 +233,17 @@ public function __construct( parent::__construct($manager); } + /** + * Get the connector policy. + */ public function policy(): AdmissionPolicy { return Limit::perMinute($this->maximumAttempts)->by('connector'); } + /** + * Resolve the connector rate limits. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [$this->policy()]; @@ -510,146 +254,51 @@ class PlainRateLimitRequestStub extends Request { protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/resource'; } } -class RateLimitedRequestStub extends PlainRateLimitRequestStub -{ - use HasRateLimits; - - public function policy(): AdmissionPolicy - { - return Limit::perMinute(2)->by('request'); - } - - protected function resolveRateLimits(PendingRequest $pendingRequest): array - { - return [$this->policy()]; - } -} - class CachedRateLimitedRequestStub extends PlainRateLimitRequestStub implements Cacheable { use HasCaching; use HasRateLimits; + /** + * Create a cached request with the given policy. + */ public function __construct(protected AdmissionPolicy $policy) { } + /** + * Get the cache duration. + */ public function cacheFor(): DateInterval|DateTimeInterface|int { return 60; } + /** + * Resolve the request rate limits. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [$this->policy]; } } -class ThrowingCooldownRequestStub extends PlainRateLimitRequestStub -{ - use HasRateLimits; - - public function boot(PendingRequest $pendingRequest): void - { - $pendingRequest->middleware()->onResponse( - static fn (): never => throw new RuntimeException('response middleware failed'), - ); - } - - protected function resolveRateLimits(PendingRequest $pendingRequest): array - { - return []; - } -} - -class DateCooldownRequestStub extends PlainRateLimitRequestStub -{ - use HasRateLimits; - - protected function resolveRateLimits(PendingRequest $pendingRequest): array - { - return []; - } -} - -class InvalidCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class Rfc850CooldownRequestStub extends DateCooldownRequestStub -{ -} - -class AsctimeCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class TwoDigitAsctimeCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class MismatchedWeekdayCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class Rfc850FutureYearCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class Rfc850BeyondFiftyYearsCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class Rfc850ExactFiftyYearsCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class Rfc850WithinFiftyYearsCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class ImfBeyondFiftyYearsCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class LeapSecondCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class InvalidCalendarCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class InvalidTimeCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class NumericCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class OversizedCooldownRequestStub extends DateCooldownRequestStub -{ -} - -class WaitingCooldownRequestStub extends DateCooldownRequestStub -{ - protected function waitForRateLimits(): bool - { - return true; - } -} - class CallbackRateLimitRequestStub extends PlainRateLimitRequestStub { use HasRateLimits; + /** + * Resolve a policy with an after callback. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [Limit::perMinute(1)->after(static fn (): bool => true)]; @@ -660,11 +309,17 @@ class OperationRateLimitRequestStub extends PlainRateLimitRequestStub { use HasRateLimits; + /** + * Resolve a policy keyed by the operation's tenant header. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [Limit::perMinute(2)->by((string) $pendingRequest->headers()['X-Tenant'])]; } + /** + * Resolve the rate-limiter store name. + */ protected function resolveRateLimitStore(): string { return 'secondary'; @@ -675,16 +330,25 @@ class MultipleRateLimitRequestStub extends PlainRateLimitRequestStub { use HasRateLimits; + /** + * Get the first policy. + */ public function firstPolicy(): AdmissionPolicy { return Limit::perMinute(10)->by('first'); } + /** + * Get the second policy. + */ public function secondPolicy(): AdmissionPolicy { return Limit::perMinute(1)->by('second'); } + /** + * Resolve both policies. + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [$this->firstPolicy(), $this->secondPolicy()]; @@ -693,7 +357,10 @@ protected function resolveRateLimits(PendingRequest $pendingRequest): array class WaitingMultipleRateLimitRequestStub extends MultipleRateLimitRequestStub { - protected function waitForRateLimits(): bool + /** + * Wait for rate limits instead of throwing. + */ + protected function waitForRateLimits(AdmissionPolicy|Cooldown $policy, Decision $result): bool { return true; } diff --git a/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php b/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php deleted file mode 100644 index f20aca5e91..0000000000 --- a/tests/Saloon/RateLimit/ReleaseOnRateLimitTest.php +++ /dev/null @@ -1,67 +0,0 @@ -handle( - $job, - fn (RateLimitedJobStub $handledJob): string => $handledJob === $job ? 'completed' : 'wrong job', - ); - - $this->assertSame('completed', $result); - $this->assertNull($job->releasedAfter); - } - - public function testDeniedRequestsReleaseTheJobForTheDecisionDelay(): void - { - $job = new RateLimitedJobStub; - $exception = new RateLimitReachedException( - Limit::perMinute(1)->by('provider'), - new LimitResult(false, 1, 0, 2_500_000, 2_500_000), - ); - - $result = (new ReleaseOnRateLimit)->handle( - $job, - static fn () => throw $exception, - ); - - $this->assertNull($result); - $this->assertSame(3, $job->releasedAfter); - } - - public function testOtherExceptionsPropagate(): void - { - $exception = new RuntimeException('Job failed.'); - - $this->expectExceptionObject($exception); - - (new ReleaseOnRateLimit)->handle( - new RateLimitedJobStub, - static fn () => throw $exception, - ); - } -} - -class RateLimitedJobStub -{ - public ?int $releasedAfter = null; - - public function release(int $delay = 0): void - { - $this->releasedAfter = $delay; - } -} diff --git a/tests/Saloon/RateLimit/Unit/DestructTest.php b/tests/Saloon/RateLimit/Unit/DestructTest.php new file mode 100644 index 0000000000..6b52a66c5e --- /dev/null +++ b/tests/Saloon/RateLimit/Unit/DestructTest.php @@ -0,0 +1,23 @@ +assertFalse($destructed); + + unset($connector); + + $this->assertTrue($destructed); + } +} diff --git a/tests/Saloon/RateLimit/Unit/HelperTest.php b/tests/Saloon/RateLimit/Unit/HelperTest.php new file mode 100644 index 0000000000..f5d68e5bf3 --- /dev/null +++ b/tests/Saloon/RateLimit/Unit/HelperTest.php @@ -0,0 +1,123 @@ +make('config')->set('saloon.rate_limiter.store', 'worker-array'); + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + Http::preventStrayRequests(); + } + + #[DataProvider('retryAfterProvider')] + public function testTheRetryAfterHelperCanParseDifferentValues(string $now, ?string $retryAfter, ?int $expected): void + { + $this->assertSame($expected, $this->cooldownAt($now, $retryAfter)); + } + + /** + * Get Retry-After values, the time they are received and the cooldown they give. + */ + public static function retryAfterProvider(): array + { + $now = '2026-08-06 12:00:00'; + $secondsUntil = static fn (string $time): int => CarbonImmutable::parse($time . ' UTC')->getTimestamp() + - CarbonImmutable::parse($now . ' UTC')->getTimestamp(); + + return [ + 'missing' => [$now, null, 60], + 'zero seconds' => [$now, '0', null], + 'seconds' => [$now, '120', 120], + 'seconds with leading zeros' => [$now, '0008', 8], + 'too long for the rate limiter' => [$now, str_repeat('9', 30), 60], + 'unknown' => [$now, 'Unknown', 60], + 'not an HTTP date' => [$now, '2030-01-01 00:00:00', 60], + 'HTTP date' => [$now, 'Wed, 23 Oct 2030 06:28:00 GMT', $secondsUntil('2030-10-23 06:28:00')], + 'HTTP date with the wrong weekday' => [$now, 'Fri, 06 Aug 2026 12:00:07 GMT', 7], + 'HTTP date in the past' => [$now, 'Wed, 05 Aug 2026 12:00:00 GMT', null], + 'leap second' => [$now, 'Thu, 06 Aug 2026 12:01:60 GMT', 120], + 'invalid calendar date' => [$now, 'Thu, 31 Feb 2026 12:00:09 GMT', 60], + 'invalid time' => [$now, 'Thu, 06 Aug 2026 25:00:09 GMT', 60], + 'RFC 850 date' => [$now, 'Thursday, 06-Aug-26 12:00:05 GMT', 5], + 'asctime date' => [$now, 'Thu Aug 6 12:00:06 2026', 6], + 'asctime date with a two-digit day' => ['2026-08-13 12:00:00', 'Thu Aug 13 12:00:07 2026', 7], + + // A two-digit RFC 850 year more than 50 years ahead means the most recent past year with those digits. + 'RFC 850 year within 50 years' => [$now, 'Sunday, 06-Nov-70 12:00:00 GMT', $secondsUntil('2070-11-06 12:00:00')], + 'RFC 850 year 50 years ahead' => [$now, 'Thursday, 06-Aug-76 12:00:00 GMT', $secondsUntil('2076-08-06 12:00:00')], + 'RFC 850 year earlier in that year' => [$now, 'Thursday, 01-Jan-76 12:00:00 GMT', $secondsUntil('2076-01-01 12:00:00')], + 'RFC 850 year beyond 50 years' => [$now, 'Thursday, 31-Dec-76 12:00:00 GMT', null], + 'four-digit year beyond 50 years' => [$now, 'Thu, 31 Dec 2076 12:00:00 GMT', $secondsUntil('2076-12-31 12:00:00')], + ]; + } + + public function testRfc850YearsResolveAgainstGmtAtACenturyBoundary(): void + { + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set('America/Los_Angeles'); + + try { + $this->assertSame( + CarbonImmutable::parse('2150-01-01 00:30:00 UTC')->getTimestamp() + - CarbonImmutable::parse('2100-01-01 00:30:00 UTC')->getTimestamp(), + $this->cooldownAt('2100-01-01 00:30:00', 'Sunday, 01-Jan-50 00:30:00 GMT'), + ); + } finally { + date_default_timezone_set($previousTimezone); + } + } + + // REMOVED: the limit helper clones the limits - rate-limit policies are immutable, so the configured policies + // are never changed (RateLimiter/LimitTest::testFluentModifiersReturnImmutableCopies). + + // REMOVED: the limit helper throws an exception if two of the same limits have the same name - identical + // policies share one state and charge it once each, as RateLimiter/Fixtures/RateLimiterStoreContract's + // repeated-key case shows. + + /** + * Get the cooldown a 429 with the given Retry-After header gives at a UTC time. + */ + private function cooldownAt(string $now, ?string $retryAfter): ?int + { + CarbonImmutable::setTestNow($now . ' UTC'); + Http::fake(['*' => Http::response([], 429, $retryAfter === null ? [] : ['Retry-After' => $retryAfter])]); + $connector = new TestConnector([]); + + return $connector->resolveRateLimitCooldownFor($connector->send(new UserRequest)); + } +} diff --git a/tests/Saloon/Repositories/ArrayRepositoryTest.php b/tests/Saloon/Repositories/ArrayRepositoryTest.php deleted file mode 100644 index 6f3b85225c..0000000000 --- a/tests/Saloon/Repositories/ArrayRepositoryTest.php +++ /dev/null @@ -1,63 +0,0 @@ - 'Sam', - 'hero' => 'Iron Man', - ]); - - $repository->merge(['sidekick' => 'Gareth'], ['hero' => 'Black Widow']); - $repository->add('lazy', fn () => 'resolved'); - $repository->remove('name'); - - $this->assertSame([ - 'hero' => 'Black Widow', - 'sidekick' => 'Gareth', - 'lazy' => 'resolved', - ], $repository->all()); - $this->assertSame('Black Widow', $repository->get('hero')); - $this->assertSame('fallback', $repository->get('missing', 'fallback')); - } - - public function testItReplacesAllValues(): void - { - $repository = new ArrayRepository(['name' => 'Sam']); - - $repository->set(['name' => 'Gareth']); - - $this->assertSame(['name' => 'Gareth'], $repository->all()); - } - - public function testItMayBeConditionallyChanged(): void - { - $repository = new ArrayRepository; - - $repository->when(true, fn (ArrayRepository $values) => $values->add('name', 'Gareth')); - $repository->when(false, fn (ArrayRepository $values) => $values->add('name', 'Sam')); - - $this->assertSame(['name' => 'Gareth'], $repository->all()); - } - - public function testItReportsWhetherItIsEmpty(): void - { - $repository = new ArrayRepository; - - $this->assertTrue($repository->isEmpty()); - $this->assertFalse($repository->isNotEmpty()); - - $repository->add('name', 'Sam'); - - $this->assertFalse($repository->isEmpty()); - $this->assertTrue($repository->isNotEmpty()); - } -} diff --git a/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php deleted file mode 100644 index 969cd36730..0000000000 --- a/tests/Saloon/Repositories/Body/ArrayBodyRepositoryTest.php +++ /dev/null @@ -1,89 +0,0 @@ - 'Sam', - 'sidekick' => 'Mantas', - ]); - - $this->assertSame(['name' => 'Sam', 'sidekick' => 'Mantas'], $body->all()); - - $body->set(['name' => 'Gareth']); - - $this->assertSame(['name' => 'Gareth'], $body->all()); - } - - public function testItRejectsNonArrayValues(): void - { - $body = new FormBodyRepository; - - $this->expectException(InvalidArgumentException::class); - - $body->set('Sam'); - } - - public function testItAddsGetsAndRemovesValues(): void - { - $body = new FormBodyRepository; - - $body->add('name', 'Sam'); - $body->add(1, 'Mantas'); - $body->add(value: 'Gareth'); - - $this->assertSame('Sam', $body->get('name')); - $this->assertSame('Mantas', $body->get(1)); - $this->assertSame('fallback', $body->get('missing', 'fallback')); - $this->assertSame([ - 'name' => 'Sam', - 1 => 'Mantas', - 2 => 'Gareth', - ], $body->get()); - - $body->remove('name'); - $body->remove(1); - - $this->assertSame([2 => 'Gareth'], $body->all()); - } - - public function testItMergesAndConditionallyChangesValues(): void - { - $body = new FormBodyRepository(['name' => 'Sam']); - - $this->assertInstanceOf(MergeableBody::class, $body); - - $body->merge(['name' => 'Gareth'], ['sidekick' => 'Mantas']); - $body->when(true, fn (FormBodyRepository $repository) => $repository->add('hero', 'Black Widow')); - $body->when(false, fn (FormBodyRepository $repository) => $repository->add('hero', 'Iron Man')); - - $this->assertSame([ - 'name' => 'Gareth', - 'sidekick' => 'Mantas', - 'hero' => 'Black Widow', - ], $body->all()); - } - - public function testItReportsWhetherItIsEmpty(): void - { - $body = new FormBodyRepository; - - $this->assertTrue($body->isEmpty()); - $this->assertFalse($body->isNotEmpty()); - - $body->add('name', 'Sam'); - - $this->assertFalse($body->isEmpty()); - $this->assertTrue($body->isNotEmpty()); - } -} diff --git a/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php deleted file mode 100644 index 0b228f8c10..0000000000 --- a/tests/Saloon/Repositories/Body/MultipartBodyRepositoryTest.php +++ /dev/null @@ -1,106 +0,0 @@ -assertSame([], $body->all()); - $this->assertTrue($body->isEmpty()); - $this->assertFalse($body->isNotEmpty()); - } - - public function testItStoresAddsMergesAndRemovesValues(): void - { - $body = new MultipartBodyRepository([ - new MultipartValue('name', 'Sam'), - ]); - - $this->assertInstanceOf(MergeableBody::class, $body); - - $body->add('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']); - $body->merge([new MultipartValue('sidekick', 'Mantas')]); - - $this->assertEquals([ - new MultipartValue('name', 'Sam'), - new MultipartValue('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']), - new MultipartValue('sidekick', 'Mantas'), - ], $body->all()); - $this->assertEquals([ - new MultipartValue('name', 'Sam'), - new MultipartValue('name', 'Charlotte', 'welcome.txt', ['X-Test' => 'yes']), - ], $body->get('name')); - $this->assertEquals(new MultipartValue('sidekick', 'Mantas'), $body->get('sidekick')); - $this->assertSame('fallback', $body->get('missing', 'fallback')); - - $body->remove('name'); - - $this->assertEquals([new MultipartValue('sidekick', 'Mantas')], $body->all()); - } - - public function testItMayBeConditionallyChanged(): void - { - $body = new MultipartBodyRepository; - - $body->when(true, fn (MultipartBodyRepository $repository) => $repository->add('name', 'Gareth')); - $body->when(false, fn (MultipartBodyRepository $repository) => $repository->add('name', 'Sam')); - - $this->assertEquals([new MultipartValue('name', 'Gareth')], $body->all()); - } - - public function testItRejectsNonArrayRepositoryValues(): void - { - $body = new MultipartBodyRepository; - - $this->expectException(InvalidArgumentException::class); - - $body->set('invalid'); - } - - public function testItRejectsArraysContainingOtherValues(): void - { - $this->expectException(InvalidArgumentException::class); - - new MultipartBodyRepository([ - new MultipartValue('name', 'Sam'), - 'invalid', - ]); - } - - public function testItRejectsNonFiniteNumericValues(): void - { - $this->expectException(InvalidArgumentException::class); - - new MultipartValue('limit', INF); - } - - public function testItCreatesOneMultipartStreamWithTheConfiguredBoundary(): void - { - $body = new MultipartBodyRepository([ - new MultipartValue('name', 'Sam'), - new MultipartValue('count', 12), - new MultipartValue('ratio', 1.5, 'ratio.txt', ['X-Part' => 'yes']), - ], 'saloon-boundary'); - - $contents = (string) $body->toStream(); - - $this->assertSame('saloon-boundary', $body->boundary()); - $this->assertStringContainsString('name="name"', $contents); - $this->assertStringContainsString("\r\n\r\nSam\r\n", $contents); - $this->assertStringContainsString("\r\n\r\n12\r\n", $contents); - $this->assertStringContainsString('filename="ratio.txt"', $contents); - $this->assertStringContainsString("X-Part: yes\r\n", $contents); - $this->assertStringContainsString("\r\n\r\n1.5\r\n", $contents); - } -} diff --git a/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php b/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php deleted file mode 100644 index f5b527f425..0000000000 --- a/tests/Saloon/Repositories/Body/StreamBodyRepositoryTest.php +++ /dev/null @@ -1,92 +0,0 @@ -assertNull($body->all()); - $this->assertNull($body->get()); - $this->assertTrue($body->isEmpty()); - - $stream = $body->toStream(); - - $this->assertTrue($stream->isSeekable()); - $this->assertSame('', (string) $stream); - } - - public function testItStoresAndReplacesResources(): void - { - $first = fopen('php://memory', 'rw+'); - $second = fopen('php://memory', 'rw+'); - $stream = null; - - try { - fwrite($first, 'Howdy'); - fwrite($second, 'Yeehaw'); - rewind($second); - - $body = new StreamBodyRepository($first); - $body->set($second); - $stream = $body->toStream(); - - $this->assertSame($second, $body->get()); - $this->assertSame('Yeehaw', (string) $stream); - $this->assertFalse($body->isEmpty()); - $this->assertTrue($body->isNotEmpty()); - } finally { - fclose($first); - $stream?->close(); - } - } - - public function testItReturnsTheOriginalPsrStream(): void - { - $stream = Utils::streamFor('Howdy!'); - $body = new StreamBodyRepository($stream); - - $this->assertSame($stream, $body->get()); - $this->assertSame($stream, $body->toStream()); - } - - public function testItMayBeConditionallyChanged(): void - { - $stream = Utils::streamFor('Howdy!'); - $body = new StreamBodyRepository; - - $body->when(true, fn (StreamBodyRepository $repository) => $repository->set($stream)); - $body->when(false, fn (StreamBodyRepository $repository) => $repository->set(null)); - - $this->assertSame($stream, $body->get()); - } - - #[DataProvider('invalidValues')] - public function testItRejectsValuesThatAreNotStreams(mixed $value): void - { - $this->expectException(InvalidArgumentException::class); - - new StreamBodyRepository($value); - } - - /** - * @return iterable - */ - public static function invalidValues(): iterable - { - yield 'string' => ['Howdy']; - yield 'integer' => [123]; - yield 'array' => [[]]; - yield 'boolean' => [false]; - } -} diff --git a/tests/Saloon/Repositories/IntegerRepositoryTest.php b/tests/Saloon/Repositories/IntegerRepositoryTest.php deleted file mode 100644 index 76d8059377..0000000000 --- a/tests/Saloon/Repositories/IntegerRepositoryTest.php +++ /dev/null @@ -1,51 +0,0 @@ -assertNull($repository->get()); - $this->assertTrue($repository->isEmpty()); - $this->assertFalse($repository->isNotEmpty()); - } - - public function testRepositoryCanBeConstructedWithAValue(): void - { - $repository = new IntegerRepository(1); - - $this->assertSame(1, $repository->get()); - } - - public function testValueCanBeSet(): void - { - $repository = new IntegerRepository; - - $this->assertSame($repository, $repository->set(1)); - $this->assertSame(1, $repository->get()); - } - - public function testZeroIsNotEmpty(): void - { - $repository = new IntegerRepository(0); - - $this->assertFalse($repository->isEmpty()); - $this->assertTrue($repository->isNotEmpty()); - } - - public function testRepositoryIsConditionable(): void - { - $repository = (new IntegerRepository) - ->when(true, fn (IntegerRepository $repository) => $repository->set(1)); - - $this->assertSame(1, $repository->get()); - } -} diff --git a/tests/Saloon/SaloonManagerTest.php b/tests/Saloon/SaloonManagerTest.php index 832f889d54..218af91495 100644 --- a/tests/Saloon/SaloonManagerTest.php +++ b/tests/Saloon/SaloonManagerTest.php @@ -5,12 +5,14 @@ namespace Hypervel\Tests\Saloon; use Carbon\CarbonInterval; +use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Events\Dispatcher; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\Request as HttpRequest; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\Saloon\Contracts\Authenticator; use Hypervel\Saloon\Contracts\FakeResponse; use Hypervel\Saloon\Contracts\RequestMiddleware; use Hypervel\Saloon\Enums\Method; @@ -18,7 +20,6 @@ use Hypervel\Saloon\Events\SentSaloonRequest; use Hypervel\Saloon\Exceptions\Request\FatalRequestException; use Hypervel\Saloon\Exceptions\Request\RequestException; -use Hypervel\Saloon\Http\Auth\TokenAuthenticator; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\Faking\MockResponse; use Hypervel\Saloon\Http\PendingRequest; @@ -79,28 +80,43 @@ public function testEachRetryUsesFreshOperationStateAndRunsTheCompleteLifecycle( $http->assertSentCount(2); } - public function testFailedResponseIsReturnedWithoutAnExplicitThrowOrRetryPolicy(): void + public function testMiddlewareRunsInRegistrationOrderOnEveryAttemptWithoutAccumulating(): void { $http = $this->http(); - $http->fake(['*' => Factory::response('failed', 500)]); + $http->fake([ + '*' => $http->sequence() + ->pushStatus(500) + ->push('complete', 200) + ->push('again', 200), + ]); + $manager = $this->manager($http); + $manager->middleware()->onRequest(static function (): void { + OrderedManagerRequestStub::$order[] = 'global'; + }); + $request = (new OrderedManagerRequestStub)->retry(2); + $request->middleware()->onRequest(static function (): void { + OrderedManagerRequestStub::$order[] = 'request'; + }); + $attempt = ['global', 'plugin', 'connector boot', 'request', 'request boot']; - $response = $this->manager($http)->send(new ManagerConnectorStub, new ManagerRequestStub); + $manager->send(new OrderedManagerConnectorStub, $request); + $manager->send(new OrderedManagerConnectorStub, $request); - $this->assertSame(500, $response->status()); - $this->assertSame('failed', $response->body()); - $http->assertSentCount(1); + $this->assertSame([...$attempt, ...$attempt, ...$attempt], OrderedManagerRequestStub::$order); + $this->assertCount(1, $manager->middleware()->requestPipeline()->pipes()); + $this->assertCount(1, $request->middleware()->requestPipeline()->pipes()); } - public function testRetryCanReturnTheFinalFailedResponseWithoutThrowing(): void + public function testFailedResponseIsReturnedWithoutAnExplicitThrowOrRetryPolicy(): void { $http = $this->http(); $http->fake(['*' => Factory::response('failed', 500)]); - $request = (new ManagerRequestStub)->retry(2, throw: false); - $response = $this->manager($http)->send(new ManagerConnectorStub, $request); + $response = $this->manager($http)->send(new ManagerConnectorStub, new ManagerRequestStub); $this->assertSame(500, $response->status()); - $http->assertSentCount(2); + $this->assertSame('failed', $response->body()); + $http->assertSentCount(1); } public function testConnectorBootMayConfigureTheOperationRetryPolicy(): void @@ -202,19 +218,14 @@ public function testReturningAnotherPendingRequestCannotDivertBodyPreparation(): { $http = $this->http(); $capturedBody = null; - $http->fake(function (HttpRequest $request) use (&$capturedBody) { + $http->fake(function (HttpRequest $request) use (&$capturedBody): PromiseInterface { $capturedBody = $request->body(); return Factory::response(); }); $request = (new BodyManagerRequestStub)->withData(['name' => 'Taylor']); $request->middleware()->onRequest(function (): PendingRequest { - return new PendingRequest( - new ManagerConnectorStub, - new ManagerRequestStub, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); + return new PendingRequest(new ManagerConnectorStub, new ManagerRequestStub); }); $this->manager($http)->send(new ManagerConnectorStub, $request); @@ -229,12 +240,7 @@ public function testReturningAnotherPendingRequestCannotDivertALaterFake(): void $request = new ManagerRequestStub; $request->middleware() ->onRequest(function (): PendingRequest { - return new PendingRequest( - new ManagerConnectorStub, - new ManagerRequestStub, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); + return new PendingRequest(new ManagerConnectorStub, new ManagerRequestStub); }) ->onRequest(static fn (): FakeResponse => new MockResponse('middleware')); @@ -273,16 +279,22 @@ public function testConnectorAndRequestCanBothEnableAutomaticThrowing(): void public function testPluginAuthenticationIsAppliedOnce(): void { $http = $this->http(); - $authorization = null; - $http->fake(function (HttpRequest $request) use (&$authorization) { - $authorization = $request->header('Authorization'); + $sent = null; + $http->fake(function (HttpRequest $request) use (&$sent): PromiseInterface { + $sent = $request; return Factory::response(); }); - $this->manager($http)->send(new ManagerConnectorStub, new PluginAuthenticatedManagerRequestStub); + $this->manager($http)->send( + new ManagerConnectorStub, + (new PluginAuthenticatedManagerRequestStub)->withToken('configured'), + ); - $this->assertSame(['Bearer secret'], $authorization); + // The plugin's authenticator adds its header, so applying it twice would repeat the value. The configured + // token it replaced is not applied. + $this->assertSame(['secret'], $sent->header('X-Api-Key')); + $this->assertFalse($sent->hasHeader('Authorization')); } public function testSendingAndSentEventsArePairedForFakeResponses(): void @@ -311,7 +323,7 @@ public function testSendingListenerMutationsAreFinalAndPsrInspectionHasNoSideEff { $http = $this->http(); $capturedRequest = null; - $http->fake(function (HttpRequest $request) use (&$capturedRequest) { + $http->fake(function (HttpRequest $request) use (&$capturedRequest): PromiseInterface { $capturedRequest = $request; return Factory::response(); @@ -347,6 +359,9 @@ public function testSendingListenerMutationsAreFinalAndPsrInspectionHasNoSideEff $this->assertSame($response->toPsrRequest(), $response->pendingRequest()->toPsrRequest()); } + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -357,6 +372,7 @@ protected function setUp(): void ManagerRequestStub::$responseMiddlewareCalls = 0; ManagerRequestStub::$fatalMiddlewareCalls = 0; PsrHookManagerConnectorStub::$psrHookCalls = 0; + OrderedManagerRequestStub::$order = []; } /** @@ -394,11 +410,17 @@ class ManagerConnectorStub extends Connector { public int $bootCalls = 0; + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Configure a pending request for this resource. + */ public function boot(PendingRequest $pendingRequest): void { ++$this->bootCalls; @@ -410,6 +432,9 @@ public function boot(PendingRequest $pendingRequest): void class RetryingManagerConnectorStub extends ManagerConnectorStub { + /** + * Configure a pending request for this resource. + */ public function boot(PendingRequest $pendingRequest): void { parent::boot($pendingRequest); @@ -427,6 +452,9 @@ class PsrHookManagerConnectorStub extends ManagerConnectorStub { public static int $psrHookCalls = 0; + /** + * Handle the final PSR request before it is sent. + */ public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface { ++static::$psrHookCalls; @@ -451,11 +479,17 @@ class ManagerRequestStub extends Request protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; } + /** + * Configure a pending request for this resource. + */ public function boot(PendingRequest $pendingRequest): void { ++$this->bootCalls; @@ -483,9 +517,31 @@ class BodyManagerRequestStub extends ManagerRequestStub trait AppliesManagerAuthentication { + /** + * Authenticate the pending request with an API key. + */ public function bootAppliesManagerAuthentication(PendingRequest $pendingRequest): void { - $pendingRequest->authenticate(new TokenAuthenticator('secret')); + $pendingRequest->authenticate(new ApiKeyManagerAuthenticator('secret')); + } +} + +readonly class ApiKeyManagerAuthenticator implements Authenticator +{ + /** + * Create an API key authenticator. + */ + public function __construct( + public string $key, + ) { + } + + /** + * Apply the authentication to the request. + */ + public function set(PendingRequest $pendingRequest): void + { + $pendingRequest->withHeader('X-Api-Key', $this->key); } } @@ -494,8 +550,77 @@ class PluginAuthenticatedManagerRequestStub extends ManagerRequestStub use AppliesManagerAuthentication; } +class OrderedManagerConnectorStub extends Connector +{ + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } + + /** + * Configure a pending request for this resource. + */ + public function boot(PendingRequest $pendingRequest): void + { + $pendingRequest->middleware()->onRequest(static function (): void { + OrderedManagerRequestStub::$order[] = 'connector boot'; + }); + } +} + +trait RecordsManagerPluginMiddleware +{ + /** + * Register the plugin's request middleware. + */ + public function bootRecordsManagerPluginMiddleware(PendingRequest $pendingRequest): void + { + $pendingRequest->middleware()->onRequest(static function (): void { + OrderedManagerRequestStub::$order[] = 'plugin'; + }); + } +} + +class OrderedManagerRequestStub extends Request +{ + use RecordsManagerPluginMiddleware; + + /** + * The request middleware in the order it ran. + * + * @var list + */ + public static array $order = []; + + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } + + /** + * Configure a pending request for this resource. + */ + public function boot(PendingRequest $pendingRequest): void + { + $pendingRequest->middleware()->onRequest(static function (): void { + OrderedManagerRequestStub::$order[] = 'request boot'; + }); + } +} + class ManagerFakeRequestMiddleware implements RequestMiddleware { + /** + * Handle an outgoing request. + */ public function __invoke(PendingRequest $pendingRequest): ?FakeResponse { return new MockResponse('class middleware'); diff --git a/tests/Saloon/SensitiveParameterTest.php b/tests/Saloon/SensitiveParameterTest.php index c47ee68768..4b45f9c42f 100644 --- a/tests/Saloon/SensitiveParameterTest.php +++ b/tests/Saloon/SensitiveParameterTest.php @@ -6,7 +6,8 @@ use Hypervel\Saloon\Http\Auth\CookieAuthenticator; use Hypervel\Saloon\Traits\OAuth2\AuthorizationCodeGrant; -use Hypervel\Saloon\Traits\OAuth2\CreatesOAuthAuthenticator; +use Hypervel\Saloon\Traits\OAuth2\ClientCredentialsGrant; +use Hypervel\Saloon\Traits\OAuth2\ParsesOAuthTokenResponses; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; @@ -42,10 +43,13 @@ public static function sensitiveParameters(): array { return [ [CookieAuthenticator::class, '__construct', 'value'], - [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'response'], - [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticatorFromResponse', 'fallbackRefreshToken'], - [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticator', 'accessToken'], - [CreatesOAuthAuthenticator::class, 'createOAuthAuthenticator', 'refreshToken'], + [ParsesOAuthTokenResponses::class, 'parseOAuthTokenResponse', 'response'], + [AuthorizationCodeGrant::class, 'createOAuthAuthenticatorFromResponse', 'response'], + [AuthorizationCodeGrant::class, 'createOAuthAuthenticatorFromResponse', 'fallbackRefreshToken'], + [AuthorizationCodeGrant::class, 'createOAuthAuthenticator', 'accessToken'], + [AuthorizationCodeGrant::class, 'createOAuthAuthenticator', 'refreshToken'], + [ClientCredentialsGrant::class, 'createOAuthAuthenticatorFromResponse', 'response'], + [ClientCredentialsGrant::class, 'createOAuthAuthenticator', 'accessToken'], [AuthorizationCodeGrant::class, 'validateState', 'state'], [AuthorizationCodeGrant::class, 'validateState', 'expectedState'], ]; diff --git a/tests/Saloon/Unit/ArrayStoreTest.php b/tests/Saloon/Unit/ArrayStoreTest.php new file mode 100644 index 0000000000..d716d991f3 --- /dev/null +++ b/tests/Saloon/Unit/ArrayStoreTest.php @@ -0,0 +1,93 @@ +assertSame([], $store->all()); + } + + public function testYouCanSetIt(): void + { + $store = new ArrayRepository(['name' => 'Gareth', 'superhero' => 'Iron Man']); + + $store->set(['name' => 'Sam']); + + $this->assertSame(['name' => 'Sam'], $store->all()); + } + + public function testYouCanAddAnItem(): void + { + $store = new ArrayRepository; + $store->add('name', 'Sam'); + $store->add('superhero', fn (): string => 'Iron Man'); + + $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $store->all()); + } + + public function testYouCanConditionallyAddItemsToTheArrayStore(): void + { + $store = new ArrayRepository; + + $store->when(true, fn (ArrayRepository $store): ArrayRepository => $store->add('name', 'Gareth')); + $store->when(false, fn (ArrayRepository $store): ArrayRepository => $store->add('name', 'Sam')); + $store->when(true, fn (ArrayRepository $store): ArrayRepository => $store->add('sidekick', 'Mantas')); + $store->when(false, fn (ArrayRepository $store): ArrayRepository => $store->add('sidekick', 'Teo')); + + $this->assertSame(['name' => 'Gareth', 'sidekick' => 'Mantas'], $store->all()); + } + + public function testYouCanDeleteAnItem(): void + { + $store = new ArrayRepository(['name' => 'Sam']); + $store->remove('name'); + + $this->assertSame([], $store->all()); + } + + public function testYouCanGetAnItem(): void + { + $store = new ArrayRepository(['name' => 'Sam']); + + $this->assertSame('Sam', $store->get('name')); + $this->assertSame('fallback', $store->get('missing', 'fallback')); + } + + public function testYouCanGetAllItems(): void + { + $store = new ArrayRepository(['name' => 'Sam', 'superhero' => 'Iron Man']); + + $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $store->all()); + } + + public function testYouCanMergeItemsTogetherIntoTheContentStore(): void + { + $store = new ArrayRepository(['name' => 'Sam', 'superhero' => 'Iron Man']); + + $store->merge(['sidekick' => 'Gareth'], ['superhero' => 'Black Widow']); + + $this->assertSame(['name' => 'Sam', 'superhero' => 'Black Widow', 'sidekick' => 'Gareth'], $store->all()); + } + + public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void + { + $store = new ArrayRepository; + + $this->assertTrue($store->isEmpty()); + $this->assertFalse($store->isNotEmpty()); + + $store->add('name', 'Sam'); + + $this->assertFalse($store->isEmpty()); + $this->assertTrue($store->isNotEmpty()); + } +} diff --git a/tests/Saloon/Unit/AuthenticatesRequestsTest.php b/tests/Saloon/Unit/AuthenticatesRequestsTest.php new file mode 100644 index 0000000000..2c502e7203 --- /dev/null +++ b/tests/Saloon/Unit/AuthenticatesRequestsTest.php @@ -0,0 +1,243 @@ +withBasicAuth('Sammyjo20', 'Cowboy1'); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + $headers = $pendingRequest->headers(); + + $this->assertSame('Basic ' . base64_encode('Sammyjo20:Cowboy1'), $headers['Authorization']); + } + + public function testYouCanAttachAnAuthorizationTokenToARequest(): void + { + $request = UserRequest::make()->withToken('Sammyjo20'); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + $headers = $pendingRequest->headers(); + + $this->assertSame('Bearer Sammyjo20', $headers['Authorization']); + } + + // Upstream's digest-type argument and its array-sender case are not included: digest is the only supported type, + // and every request uses the HTTP client's Guzzle transport. + public function testYouCanAddDigestAuthToARequest(): void + { + $request = new UserRequest; + $request->withDigestAuth('Sammyjo20', 'Cowboy1'); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $this->assertSame(['Sammyjo20', 'Cowboy1', 'digest'], $pendingRequest->transportAuthentication()); + } + + public function testYouCanAddATokenToAQueryParameter(): void + { + $request = UserRequest::make()->authenticate(new QueryAuthenticator('token', 'Sammyjo20')); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + $query = $pendingRequest->queryParameters(); + + $this->assertSame('Sammyjo20', $query['token']); + } + + public function testYouCanAddAHeaderToARequest(): void + { + $request = UserRequest::make()->authenticate(new HeaderAuthenticator('Sammyjo20', 'X-Authorization')); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + $headers = $pendingRequest->headers(); + + $this->assertSame('Sammyjo20', $headers['X-Authorization']); + } + + public function testYouCanAddACertificateToARequest(): void + { + $certPath = __DIR__ . '/certificate.cer'; + + $requestA = UserRequest::make()->authenticate(new CertificateAuthenticator($certPath)); + + $pendingRequestA = (new TestConnector)->createPendingRequest($requestA); + + $this->assertSame($certPath, $pendingRequestA->certificate()); + + // Test with password + + $requestB = UserRequest::make()->authenticate(new CertificateAuthenticator($certPath, 'example')); + + $pendingRequestB = (new TestConnector)->createPendingRequest($requestB); + + $this->assertSame([$certPath, 'example'], $pendingRequestB->certificate()); + } + + public function testYouCanUseMultipleAuthenticatorsAtTheSameTimeUsingTheDefaultAuthMethod(): void + { + $request = UserRequest::make()->authenticate(new MultiAuthenticator( + new TokenAuthenticator('example'), + new HeaderAuthenticator('api-key', 'X-API-Key'), + )); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $headers = $pendingRequest->headers(); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer example', + 'X-API-Key' => 'api-key', + ], $headers); + } + + public function testYouCanUseANullAuthenticatorToDisableDefaultAuthenticationEntirely(): void + { + $connector = new DefaultAuthenticatorConnector; + $request = new UserRequest; + + $request->authenticate(new NullAuthenticator); + + $pendingRequest = $connector->createPendingRequest($request); + + $this->assertSame(['Accept' => 'application/json'], $pendingRequest->headers()); + } + + #[DataProvider('authorizationHeaderOrders')] + public function testTheLastAuthorizationHeaderReachesTheTransport(Authenticator $first, Authenticator $last, string $expected): void + { + $sent = []; + Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface { + $sent[] = $request->header('Authorization'); + + return Factory::response(); + }); + + (new TestConnector)->send(UserRequest::make()->authenticate(new MultiAuthenticator($first, $last))); + + $this->assertSame([[$expected]], $sent); + } + + /** + * Get authenticator pairs and the Authorization header the last one sets. + * + * @return array + */ + public static function authorizationHeaderOrders(): array + { + $basic = 'Basic ' . base64_encode('Sammyjo20:Cowboy1'); + + return [ + 'basic then token' => [new BasicAuthenticator('Sammyjo20', 'Cowboy1'), new TokenAuthenticator('example'), 'Bearer example'], + 'token then basic' => [new TokenAuthenticator('example'), new BasicAuthenticator('Sammyjo20', 'Cowboy1'), $basic], + ]; + } + + #[DataProvider('cookieDomains')] + public function testCookieDomainIsInferredOrExplicit(?string $domain, string $expected, string $scheme): void + { + $pendingRequest = (new TestConnector($scheme . '://api.example.com'))->createPendingRequest( + UserRequest::make()->authenticate(new CookieAuthenticator('session', 'secret', $domain)), + ); + + $this->assertCount(1, $pendingRequest->cookies()); + $cookie = $pendingRequest->cookies()[0]; + $this->assertSame('session', $cookie['Name']); + $this->assertSame('secret', $cookie['Value']); + $this->assertSame($expected, ltrim($cookie['Domain'], '.')); + $this->assertSame($domain === null, $cookie['HostOnly'] ?? false); + $this->assertSame($scheme === 'https', $cookie['Secure']); + $this->assertTrue($cookie['Discard']); + $this->assertTrue((new SetCookie($cookie))->matchesDomain('api.example.com')); + $this->assertSame($domain !== null, (new SetCookie($cookie))->matchesDomain('other.example.com')); + } + + /** + * Provide inferred and explicitly scoped cookie domains. + * + * @return array + */ + public static function cookieDomains(): array + { + return [ + 'inferred HTTPS' => [null, 'api.example.com', 'https'], + 'explicit HTTPS' => ['.example.com', 'example.com', 'https'], + 'inferred HTTP' => [null, 'api.example.com', 'http'], + 'explicit HTTP' => ['.example.com', 'example.com', 'http'], + ]; + } + + public function testReplacementAuthenticationReachesTheTransportWithoutAccumulatingAcrossRetries(): void + { + $cookies = []; + Http::fake(function (HttpRequest $request, array $options) use (&$cookies): PromiseInterface { + $cookies[] = $options['cookies']->toArray(); + + return Factory::response('', count($cookies) === 1 ? 500 : 200); + }); + $request = UserRequest::make() + ->authenticate(new CookieAuthenticator('session', 'original')) + ->authenticate(new CookieAuthenticator('session', 'request')) + ->retry(2); + $pendingCookies = []; + $request->middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$pendingCookies): void { + $pendingRequest->authenticate(new CookieAuthenticator('session', 'replacement')); + $pendingCookies[] = $pendingRequest->cookies(); + }); + + $response = (new TestConnector('https://api.example.com'))->send($request); + + $this->assertSame(200, $response->status()); + $this->assertCount(2, $cookies); + foreach ($cookies as $attemptCookies) { + $this->assertCount(1, $attemptCookies); + $this->assertSame('session', $attemptCookies[0]['Name']); + $this->assertSame('replacement', $attemptCookies[0]['Value']); + $this->assertSame('api.example.com', $attemptCookies[0]['Domain']); + $this->assertTrue($attemptCookies[0]['HostOnly']); + $this->assertTrue($attemptCookies[0]['Secure']); + $this->assertTrue($attemptCookies[0]['Discard']); + } + $this->assertSame($pendingCookies[0], $pendingCookies[1]); + $this->assertCount(2, $pendingCookies[0]); + $this->assertSame([], $request->cookies()); + } +} diff --git a/tests/Saloon/Unit/AuthenticatorTest.php b/tests/Saloon/Unit/AuthenticatorTest.php new file mode 100644 index 0000000000..1903aad3cb --- /dev/null +++ b/tests/Saloon/Unit/AuthenticatorTest.php @@ -0,0 +1,187 @@ +createPendingRequest($request); + + $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanProvideADefaultAuthenticatorOnTheConnector(): void + { + $request = new UserRequest; + $connector = new DefaultAuthenticatorConnector; + + $pendingRequest = $connector->createPendingRequest($request); + + $this->assertSame('Bearer yee-haw-connector', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanProvideADefaultAuthenticatorOnTheRequestAndItTakesPriorityOverTheConnector(): void + { + $request = new DefaultAuthenticatorRequest; + $connector = new DefaultAuthenticatorConnector; + + $pendingRequest = $connector->createPendingRequest($request); + + $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanProvideAnAuthenticatorOnTheFlyAndItWillTakePriorityOverAllDefaults(): void + { + $request = new DefaultAuthenticatorRequest; + $connector = new DefaultAuthenticatorConnector; + + $request->withToken('yee-haw-on-the-fly', 'PewPew'); + + $pendingRequest = $connector->createPendingRequest($request); + + $this->assertSame('PewPew yee-haw-on-the-fly', $pendingRequest->headers()['Authorization']); + } + + public function testTheRequiresAuthTraitWillThrowAnExceptionIfAnAuthenticatorIsNotFound(): void + { + $mockClient = new MockClient([ + MockResponse::make(), + ]); + + $this->expectException(MissingAuthenticatorException::class); + $this->expectExceptionMessageIs('The "Hypervel\Tests\Saloon\Fixtures\Requests\RequiresAuthRequest" request requires authentication.'); + + $request = new RequiresAuthRequest; + + (new TestConnector)->send($request, $mockClient); + } + + public function testYouCanUseYourOwnAuthenticators(): void + { + $request = new UserRequest; + $request->authenticate(new PizzaAuthenticator('Margherita', 'San Pellegrino')); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $headers = $pendingRequest->headers(); + + $this->assertSame('Margherita', $headers['X-Pizza']); + $this->assertSame('San Pellegrino', $headers['X-Drink']); + $this->assertTrue($pendingRequest->options()['debug']); + } + + public function testYouCanUseYourOwnAuthenticatorsAsDefault(): void + { + $request = new DefaultPizzaAuthenticatorRequest; + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $headers = $pendingRequest->headers(); + + $this->assertSame('BBQ Chicken', $headers['X-Pizza']); + $this->assertSame('Lemonade', $headers['X-Drink']); + $this->assertTrue($pendingRequest->options()['debug']); + } + + public function testYouCanCustomiseTheAuthenticatorInsideOfTheBootMethod(): void + { + $request = new BootAuthenticatorRequest; + + $this->assertNull($request->authenticator()); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $this->assertEquals(new TokenAuthenticator('howdy-partner'), $pendingRequest->authenticator()); + $this->assertSame('Bearer howdy-partner', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanCustomiseTheAuthenticatorInsideOfPlugins(): void + { + $request = new AuthenticatorPluginRequest; + + $this->assertNull($request->authenticator()); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $this->assertEquals(new TokenAuthenticator('plugin-auth'), $pendingRequest->authenticator()); + $this->assertSame('Bearer plugin-auth', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanCustomiseTheAuthenticatorInsideOfAMiddlewarePipeline(): void + { + $request = new UserRequest; + + $this->assertNull($request->authenticator()); + + $request->middleware() + ->onRequest(function (PendingRequest $pendingRequest): void { + $pendingRequest->withToken('ooh-this-is-cool'); + }); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $this->assertEquals(new TokenAuthenticator('ooh-this-is-cool'), $pendingRequest->authenticator()); + $this->assertSame('Bearer ooh-this-is-cool', $pendingRequest->headers()['Authorization']); + } + + public function testYouCanAddAnAuthenticatorInsideOfRequestMiddleware(): void + { + $request = new UserRequest; + + // The returned pending request is ignored; authenticate() applies to the one the middleware receives. + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): PendingRequest { + return $pendingRequest->withToken('yee-haw-request'); + }); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + + $this->assertSame('Bearer yee-haw-request', $pendingRequest->headers()['Authorization']); + } + + public function testIfYouUseTheAuthenticateMethodOnAFullyConstructedPendingRequestItWillAuthenticateRightAway(): void + { + $connector = new TestConnector; + $pendingRequest = $connector->createPendingRequest(new UserRequest); + + $this->assertSame([ + 'Accept' => 'application/json', + ], $pendingRequest->headers()); + + $pendingRequest->authenticate(new TokenAuthenticator('yee-haw-request')); + + $this->assertSame([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer yee-haw-request', + ], $pendingRequest->headers()); + } +} diff --git a/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php new file mode 100644 index 0000000000..97b1c37767 --- /dev/null +++ b/tests/Saloon/Unit/Body/ArrayBodyRepositoryTest.php @@ -0,0 +1,177 @@ +assertSame([], $body->all()); + } + + public function testTheStoreCanHaveAnArrayProvided(): void + { + $body = new FormBodyRepository([ + 'name' => 'Sam', + 'sidekick' => 'Mantas', + ]); + + $this->assertSame([ + 'name' => 'Sam', + 'sidekick' => 'Mantas', + ], $body->all()); + } + + public function testYouCanSetIt(): void + { + $body = new FormBodyRepository(['sidekick' => 'Mantas']); + + $body->set(['name' => 'Sam']); + + $this->assertSame(['name' => 'Sam'], $body->all()); + } + + public function testItWillThrowAnExceptionIfYouSetANonArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The body value must be an array.'); + + $body = new FormBodyRepository; + $body->set('Sam'); + } + + public function testYouCanAddAnItem(): void + { + $body = new FormBodyRepository; + + $body->add('name', 'Sam'); + + $this->assertSame(['name' => 'Sam'], $body->all()); + } + + public function testYouCanAddAnItemWithAnIntegerKey(): void + { + $body = new FormBodyRepository; + + $body->add(1, 'Sam'); + + $this->assertSame([1 => 'Sam'], $body->all()); + } + + public function testYouCanAddAnItemWithoutAKey(): void + { + $body = new FormBodyRepository; + + $body->add(null, 'Sam'); + + $this->assertSame(['Sam'], $body->all()); + + $body->add(5, 'Mantas'); + $body->add(value: 'Gareth'); + + $this->assertSame([0 => 'Sam', 5 => 'Mantas', 6 => 'Gareth'], $body->all()); + } + + public function testYouCanConditionallyAddItemsToTheArrayStore(): void + { + $body = new FormBodyRepository; + + $body->when(true, fn (FormBodyRepository $body): FormBodyRepository => $body->add('name', 'Gareth')); + $body->when(false, fn (FormBodyRepository $body): FormBodyRepository => $body->add('name', 'Sam')); + $body->when(true, fn (FormBodyRepository $body): FormBodyRepository => $body->add('sidekick', 'Mantas')); + $body->when(false, fn (FormBodyRepository $body): FormBodyRepository => $body->add('sidekick', 'Teo')); + + $this->assertSame(['name' => 'Gareth', 'sidekick' => 'Mantas'], $body->all()); + } + + public function testYouCanDeleteAnItem(): void + { + $body = new FormBodyRepository; + + $body->add('name', 'Sam'); + $body->remove('name'); + + $this->assertSame([], $body->all()); + } + + public function testYouCanDeleteAnItemWithAnIntegerKey(): void + { + $body = new FormBodyRepository; + + $body->add(1, 'Sam'); + $body->add(2, 'Gareth'); + $body->remove(1); + + $this->assertSame([2 => 'Gareth'], $body->all()); + } + + public function testYouCanGetAnItem(): void + { + $body = new FormBodyRepository; + + $body->add('name', 'Sam'); + + $this->assertSame('Sam', $body->get('name')); + $this->assertSame('fallback', $body->get('missing', 'fallback')); + + // When omitting the key it should act like `->all()` + $this->assertSame(['name' => 'Sam'], $body->get()); + } + + public function testYouCanGetAnItemWithAnIntegerKey(): void + { + $body = new FormBodyRepository; + + $body->add(2, 'Sam'); + + $this->assertSame('Sam', $body->get(2)); + } + + public function testYouCanGetAllItems(): void + { + $body = new FormBodyRepository; + + $body->add('name', 'Sam'); + $body->add('superhero', 'Iron Man'); + + $this->assertSame(['name' => 'Sam', 'superhero' => 'Iron Man'], $body->all()); + } + + public function testYouCanMergeItemsTogetherIntoTheBodyRepository(): void + { + $body = new FormBodyRepository; + + $this->assertInstanceOf(MergeableBody::class, $body); + + $body->add('name', 'Sam'); + $body->add('sidekick', 'Mantas'); + + $body->merge(['sidekick' => 'Gareth'], ['superhero' => 'Black Widow']); + + $this->assertSame(['name' => 'Sam', 'sidekick' => 'Gareth', 'superhero' => 'Black Widow'], $body->all()); + } + + public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void + { + $body = new FormBodyRepository; + + $this->assertTrue($body->isEmpty()); + $this->assertFalse($body->isNotEmpty()); + + $body->add('name', 'Sam'); + + $this->assertFalse($body->isEmpty()); + $this->assertTrue($body->isNotEmpty()); + } +} diff --git a/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php new file mode 100644 index 0000000000..02652de0d7 --- /dev/null +++ b/tests/Saloon/Unit/Body/MultipartBodyRepositoryTest.php @@ -0,0 +1,213 @@ +assertSame([], $body->all()); + } + + public function testTheStoreCanHaveAnArrayOfMultipartValuesProvided(): void + { + $body = new MultipartBodyRepository([ + new MultipartValue('name', 'Sam'), + new MultipartValue('sidekick', 'Mantas'), + ]); + + $this->assertEquals([ + new MultipartValue('name', 'Sam'), + new MultipartValue('sidekick', 'Mantas'), + ], $body->all()); + } + + public function testTheStoreWillThrowAnExceptionIfSetValueIsNotAnArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The multipart body value must be an array.'); + + $body = new MultipartBodyRepository; + $body->set('123'); + } + + public function testTheStoreWillThrowAnExceptionIfTheArrayDoesNotContainMultipartValues(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.'); + + new MultipartBodyRepository([ + 'name' => 'Sam', + 'sidekick' => new MultipartValue('username', 'Sammyjo20'), + ]); + } + + public function testYouCanSetIt(): void + { + $body = new MultipartBodyRepository([new MultipartValue('name', 'Sam')]); + + $body->set([ + new MultipartValue('username', 'Sammyjo20'), + ]); + + $this->assertEquals([ + new MultipartValue('username', 'Sammyjo20'), + ], $body->all()); + } + + public function testYouCanAddMultipleItems(): void + { + $body = new MultipartBodyRepository; + + $body->add('name', 'Sam', 'welcome.txt', ['a' => 'b']); + + $this->assertEquals([ + new MultipartValue('name', 'Sam', 'welcome.txt', ['a' => 'b']), + ], $body->all()); + + // Test it gets added to the array + $body->add('name', 'Charlotte', 'welcome.txt', ['a' => 'b']); + + $this->assertEquals([ + new MultipartValue('name', 'Sam', 'welcome.txt', ['a' => 'b']), + new MultipartValue('name', 'Charlotte', 'welcome.txt', ['a' => 'b']), + ], $body->all()); + } + + public function testYouCanConditionallyAddItemsToTheArrayStore(): void + { + $body = new MultipartBodyRepository; + + $body->when(true, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('name', 'Gareth')); + $body->when(false, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('name', 'Sam')); + $body->when(true, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('sidekick', 'Mantas')); + $body->when(false, fn (MultipartBodyRepository $body): MultipartBodyRepository => $body->add('sidekick', 'Teo')); + + $this->assertEquals([ + new MultipartValue('name', 'Gareth'), + new MultipartValue('sidekick', 'Mantas'), + ], $body->all()); + } + + public function testYouCanDeleteAnItem(): void + { + $body = new MultipartBodyRepository; + + $body->add('name', 'Sam'); + $body->add('name', 'Charlotte'); + $body->add('sidekick', 'Mantas'); + $body->remove('name'); + + $this->assertEquals([new MultipartValue('sidekick', 'Mantas')], $body->all()); + } + + public function testYouCanGetAnItem(): void + { + $body = new MultipartBodyRepository; + + $body->add('name', 'Sam'); + $body->add('friend', 'Chris'); + + $this->assertEquals(new MultipartValue('name', 'Sam'), $body->get('name')); + $this->assertEquals(new MultipartValue('friend', 'Chris'), $body->get('friend')); + $this->assertSame('fallback', $body->get('missing', 'fallback')); + } + + public function testYouCanGetMultipleItemsWithTheSameName(): void + { + $body = new MultipartBodyRepository; + + $body->add('name', 'Sam'); + $body->add('name', 'Alex'); + + $this->assertEquals([ + new MultipartValue('name', 'Sam'), + new MultipartValue('name', 'Alex'), + ], $body->get('name')); + } + + public function testYouCanGetAllItems(): void + { + $body = new MultipartBodyRepository; + + $body->add('name', 'Sam'); + $body->add('superhero', 'Iron Man'); + + $this->assertEquals([ + new MultipartValue('name', 'Sam'), + new MultipartValue('superhero', 'Iron Man'), + ], $body->all()); + } + + public function testYouCanMergeItemsTogetherIntoTheBodyRepository(): void + { + $body = new MultipartBodyRepository; + + $this->assertInstanceOf(MergeableBody::class, $body); + + $body->add('name', 'Sam'); + $body->add('sidekick', 'Mantas'); + + $body->merge([new MultipartValue('sidekick', 'Gareth')], [new MultipartValue('superhero', 'Black Widow')]); + + $this->assertEquals([ + new MultipartValue('name', 'Sam'), + new MultipartValue('sidekick', 'Mantas'), + new MultipartValue('sidekick', 'Gareth'), + new MultipartValue('superhero', 'Black Widow'), + ], $body->all()); + } + + public function testItWillThrowAnExceptionIfTheMergedItemsAreNotMultipartValueObjects(): void + { + $body = new MultipartBodyRepository; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The value array must only contain Hypervel\Saloon\Data\MultipartValue objects.'); + + $body->merge([new MultipartValue('sidekick', 'Gareth')], ['superhero' => 'Black Widow']); + } + + public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void + { + $body = new MultipartBodyRepository; + + $this->assertTrue($body->isEmpty()); + $this->assertFalse($body->isNotEmpty()); + + $body->add('name', 'Sam'); + + $this->assertFalse($body->isEmpty()); + $this->assertTrue($body->isNotEmpty()); + } + + public function testItCreatesOneMultipartStreamWithTheConfiguredBoundary(): void + { + $body = new MultipartBodyRepository([ + new MultipartValue('name', 'Sam'), + new MultipartValue('count', 12), + new MultipartValue('ratio', 1.5, 'ratio.txt', ['X-Part' => 'yes', 'X-Tags' => ['a', 'b']]), + ], 'saloon-boundary'); + + $contents = (string) $body->toStream(); + + $this->assertSame('saloon-boundary', $body->getBoundary()); + $this->assertStringContainsString('name="name"', $contents); + $this->assertStringContainsString("\r\n\r\nSam\r\n", $contents); + $this->assertStringContainsString("\r\n\r\n12\r\n", $contents); + $this->assertStringContainsString('filename="ratio.txt"', $contents); + $this->assertStringContainsString("X-Part: yes\r\n", $contents); + $this->assertStringContainsString("X-Tags: a, b\r\n", $contents); + $this->assertStringContainsString("\r\n\r\n1.5\r\n", $contents); + } +} diff --git a/tests/Saloon/Repositories/Body/SerializationTest.php b/tests/Saloon/Unit/Body/SerializationTest.php similarity index 71% rename from tests/Saloon/Repositories/Body/SerializationTest.php rename to tests/Saloon/Unit/Body/SerializationTest.php index 07eb696729..e13172a831 100644 --- a/tests/Saloon/Repositories/Body/SerializationTest.php +++ b/tests/Saloon/Unit/Body/SerializationTest.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Repositories\Body; +namespace Hypervel\Tests\Saloon\Unit\Body; use Hypervel\Saloon\Exceptions\BodyException; use Hypervel\Saloon\Repositories\Body\FormBodyRepository; use Hypervel\Saloon\Repositories\Body\JsonBodyRepository; +use Hypervel\Saloon\Repositories\Body\StringBodyRepository; use Hypervel\Support\Collection; use Hypervel\Support\Stringable; use Hypervel\Tests\TestCase; @@ -16,12 +17,42 @@ class SerializationTest extends TestCase { + public function testTheJsonBodyRepositoryCanBeEncodedIntoJson(): void + { + $body = new JsonBodyRepository([ + 'name' => 'Sam', + 'sidekick' => 'Mantas', + ]); + + $this->assertSame('{"name":"Sam","sidekick":"Mantas"}', (string) $body); + } + + public function testTheFormBodyRepositoryCanBeEncodedIntoAQueryList(): void + { + $body = new FormBodyRepository([ + 'name' => 'Sam', + 'sidekick' => 'Mantas', + ]); + + $this->assertSame('name=Sam&sidekick=Mantas', (string) $body); + } + + public function testTheStringBodyRepositoryCanBeEncodedIntoAString(): void + { + $body = new StringBodyRepository('name: Sam'); + + $this->assertSame('name: Sam', (string) $body); + } + public function testJsonBodyRepositoryEncodesSupportedNestedValuesOnce(): void { $body = new JsonBodyRepository([ 'name' => new Stringable('Sam'), 'metadata' => new Collection([ 'sidekick' => new class implements JsonSerializable { + /** + * Get the JSON representation of the value. + */ public function jsonSerialize(): string { return 'Mantas'; @@ -37,16 +68,6 @@ public function jsonSerialize(): string $this->assertSame((string) $body, (string) $body->toStream()); } - public function testJsonBodyRepositoryHonorsCustomEncodingFlags(): void - { - $body = new JsonBodyRepository(['url' => 'https://hypervel.org']); - - $body->setJsonFlags(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); - - $this->assertSame(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, $body->getJsonFlags()); - $this->assertSame('{"url":"https://hypervel.org"}', (string) $body); - } - public function testJsonBodyRepositoryTranslatesThrownEncodingErrors(): void { $body = new JsonBodyRepository(['invalid' => "\xB1\x31"]); diff --git a/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php b/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php new file mode 100644 index 0000000000..40092f5d41 --- /dev/null +++ b/tests/Saloon/Unit/Body/StreamBodyRepositoryTest.php @@ -0,0 +1,207 @@ +assertNull($body->all()); + $this->assertNull($body->get()); + } + + public function testTheStoreCanHaveADefaultStreamProvided(): void + { + $resource = tmpfile(); + + try { + $body = new StreamBodyRepository($resource); + + $this->assertSame($resource, $body->all()); + $this->assertSame($resource, $body->get()); + } finally { + fclose($resource); + } + } + + public function testYouCanSetIt(): void + { + $resourceA = fopen('php://memory', 'rw+'); + $resourceB = fopen('php://memory', 'rw+'); + + try { + fwrite($resourceA, 'Howdy'); + fwrite($resourceB, 'Yeehaw'); + rewind($resourceB); + + $body = new StreamBodyRepository($resourceA); + + $body->set($resourceB); + + $this->assertSame($resourceB, $body->get()); + $this->assertSame('Yeehaw', (string) $body->toStream()); + } finally { + // The repository closes resource A when it is replaced. + foreach ([$resourceA, $resourceB] as $resource) { + if (is_resource($resource)) { + fclose($resource); + } + } + } + } + + public function testSettingTheSameResourceKeepsItOpen(): void + { + $resource = fopen('php://memory', 'rw+'); + + try { + fwrite($resource, 'Howdy'); + + $body = new StreamBodyRepository($resource); + $body->toStream(); + $body->set($body->get()); + + $this->assertSame('Howdy', (string) $body->toStream()); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + public function testACopyKeepsTheResourceOpenWhenTheOriginalIsReleased(): void + { + $resource = fopen('php://memory', 'rw+'); + + try { + fwrite($resource, 'Howdy'); + + $body = new StreamBodyRepository($resource); + $copy = clone $body; + + $body->toStream(); + unset($body); + + $this->assertSame('Howdy', (string) $copy->toStream()); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + public function testYouCanSetAnInstanceOfStreamInterface(): void + { + $streamA = Utils::streamFor('Howdy!'); + $streamB = Utils::streamFor('Partner!'); + + $body = new StreamBodyRepository($streamA); + $body->set($streamB); + + $this->assertSame($streamB, $body->get()); + $this->assertSame($streamB, $body->toStream()); + } + + public function testYouCanConditionallySetOnTheStore(): void + { + $body = new StreamBodyRepository; + + $resourceA = fopen('php://memory', 'rw+'); + $resourceB = fopen('php://memory', 'rw+'); + + try { + fwrite($resourceA, 'Howdy'); + fwrite($resourceB, 'Yeehaw'); + + $body->when(true, fn (StreamBodyRepository $body): StreamBodyRepository => $body->set($resourceA)); + $body->when(false, fn (StreamBodyRepository $body): StreamBodyRepository => $body->set($resourceB)); + + $this->assertSame($resourceA, $body->get()); + } finally { + fclose($resourceA); + fclose($resourceB); + } + } + + public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void + { + $body = new StreamBodyRepository; + + $this->assertTrue($body->isEmpty()); + $this->assertFalse($body->isNotEmpty()); + + $resource = tmpfile(); + + try { + $body->set($resource); + + $this->assertFalse($body->isEmpty()); + $this->assertTrue($body->isNotEmpty()); + } finally { + fclose($resource); + } + } + + #[DataProvider('invalidValues')] + public function testItWillThrowAnExceptionIfTheValueIsNotAResourceOrStreamInterfaceWhenInstantiating(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + + new StreamBodyRepository($value); + } + + #[DataProvider('invalidValues')] + public function testItWillThrowAnExceptionIfTheValueIsNotAResourceOrStreamInterfaceWhenSetting(mixed $value): void + { + $body = new StreamBodyRepository; + + $this->expectException(InvalidArgumentException::class); + + $body->set($value); + } + + /** + * Get the values a stream body rejects. + * + * @return iterable + */ + public static function invalidValues(): iterable + { + yield 'string' => ['Howdy']; + yield 'integer' => [123]; + yield 'array' => [[]]; + yield 'boolean' => [false]; + } + + public function testItAllowsNullValues(): void + { + $body = new StreamBodyRepository(null); + + $this->assertNull($body->get()); + $this->assertTrue($body->isEmpty()); + + $body->set(Utils::streamFor('Howdy!')); + $body->set(null); + + $this->assertNull($body->get()); + $this->assertTrue($body->isEmpty()); + } + + public function testItCreatesAnEmptySeekableStreamForNull(): void + { + $stream = (new StreamBodyRepository)->toStream(); + + $this->assertTrue($stream->isSeekable()); + $this->assertSame('', (string) $stream); + } +} diff --git a/tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php b/tests/Saloon/Unit/Body/StringBodyRepositoryTest.php similarity index 59% rename from tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php rename to tests/Saloon/Unit/Body/StringBodyRepositoryTest.php index e1525b2c47..910791aace 100644 --- a/tests/Saloon/Repositories/Body/StringBodyRepositoryTest.php +++ b/tests/Saloon/Unit/Body/StringBodyRepositoryTest.php @@ -2,53 +2,67 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Repositories\Body; +namespace Hypervel\Tests\Saloon\Unit\Body; use Hypervel\Saloon\Repositories\Body\StringBodyRepository; use Hypervel\Tests\TestCase; class StringBodyRepositoryTest extends TestCase { - public function testItIsEmptyByDefault(): void + public function testTheStoreIsEmptyByDefault(): void { $body = new StringBodyRepository; $this->assertNull($body->all()); - $this->assertTrue($body->isEmpty()); - $this->assertFalse($body->isNotEmpty()); $this->assertSame('', (string) $body); $this->assertSame('', (string) $body->toStream()); } - public function testItStoresAndReplacesStringBodies(): void + public function testTheStoreCanHaveADefaultStringProvided(): void { - $body = new StringBodyRepository('Sam'); + $body = new StringBodyRepository('Yeehaw!'); + + $this->assertSame('Yeehaw!', $body->all()); + } - $this->assertSame('Sam', $body->all()); + public function testYouCanSetIt(): void + { + $body = new StringBodyRepository('Sam'); $body->set('Yeehaw!'); $this->assertSame('Yeehaw!', $body->all()); - $this->assertFalse($body->isEmpty()); - $this->assertTrue($body->isNotEmpty()); $this->assertSame('Yeehaw!', (string) $body->toStream()); } - public function testZeroIsNotAnEmptyBody(): void + public function testYouCanConditionallySetOnTheStore(): void { - $body = new StringBodyRepository('0'); + $body = new StringBodyRepository; - $this->assertFalse($body->isEmpty()); - $this->assertSame('0', (string) $body->toStream()); + $body->when(true, fn (StringBodyRepository $body): StringBodyRepository => $body->set('Gareth')); + $body->when(false, fn (StringBodyRepository $body): StringBodyRepository => $body->set('Sam')); + + $this->assertSame('Gareth', $body->all()); } - public function testItMayBeConditionallyChanged(): void + public function testYouCanCheckIfTheStoreIsEmptyOrNot(): void { $body = new StringBodyRepository; - $body->when(true, fn (StringBodyRepository $repository) => $repository->set('Gareth')); - $body->when(false, fn (StringBodyRepository $repository) => $repository->set('Sam')); + $this->assertTrue($body->isEmpty()); + $this->assertFalse($body->isNotEmpty()); - $this->assertSame('Gareth', $body->all()); + $body->set('Sam'); + + $this->assertFalse($body->isEmpty()); + $this->assertTrue($body->isNotEmpty()); + } + + public function testZeroIsNotAnEmptyBody(): void + { + $body = new StringBodyRepository('0'); + + $this->assertFalse($body->isEmpty()); + $this->assertSame('0', (string) $body->toStream()); } } diff --git a/tests/Saloon/Unit/BodyTraitTest.php b/tests/Saloon/Unit/BodyTraitTest.php new file mode 100644 index 0000000000..b84837a055 --- /dev/null +++ b/tests/Saloon/Unit/BodyTraitTest.php @@ -0,0 +1,189 @@ +withBody(new Stringable('{"name":"Sam"}')); + + $this->assertSame('{"name":"Sam"}', $request->body()); + $this->assertSame('application/json', $request->headers()['Content-Type']); + } + + public function testWithBodyUsesAStreamBodyForStreamsAndResources(): void + { + $stream = Utils::streamFor('Howdy'); + $resource = fopen('php://memory', 'rb'); + + try { + $streamRequest = (new UserRequest)->withBody($stream, 'text/plain'); + $resourceRequest = (new UserRequest)->withBody($resource, null); + + $this->assertSame($stream, $streamRequest->body()); + $this->assertSame('text/plain', $streamRequest->headers()['Content-Type']); + $this->assertSame($resource, $resourceRequest->body()); + $this->assertFalse($resourceRequest->hasHeader('Content-Type')); + } finally { + fclose($resource); + } + } + + public function testAsJsonAndAsFormReplaceTheBodyAndContentType(): void + { + $request = (new UserRequest)->asForm(['name' => 'Sam']); + + $this->assertSame(['name' => 'Sam'], $request->body()); + $this->assertSame('application/x-www-form-urlencoded', $request->headers()['Content-Type']); + + $request->asJson(['drink' => 'Moonshine']); + + $this->assertSame(['drink' => 'Moonshine'], $request->body()); + $this->assertSame('application/json', $request->headers()['Content-Type']); + } + + public function testWithDataMergesIntoAStructuredBody(): void + { + $request = (new HasFormBodyRequest)->withData(['name' => 'Gareth', 'drink' => 'Moonshine']); + + $this->assertSame([ + 'name' => 'Gareth', + 'catchphrase' => 'Yeehaw!', + 'drink' => 'Moonshine', + ], $request->body()); + } + + public function testWithDataStartsAJsonBodyWhenTheBodyIsNotStructured(): void + { + $request = (new HasStringBodyRequest)->withData(['name' => 'Sam']); + + $this->assertSame(['name' => 'Sam'], $request->body()); + $this->assertSame('application/json', $request->headers()['Content-Type']); + } + + public function testAttachReplacesABodyThatIsNotMultipart(): void + { + $request = (new UserRequest) + ->asJson(['name' => 'Sam']) + ->attach('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']); + + $this->assertEquals([ + new MultipartValue('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']), + ], $request->body()); + } + + public function testAttachAcceptsAnArrayOfParts(): void + { + $request = (new HasMultipartBodyRequest)->attach([ + ['avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']], + ['description', 'Profile picture'], + ]); + + $this->assertEquals([ + new MultipartValue('nickname', 'Sam', 'user.txt', ['X-Saloon' => 'Yee-haw!']), + new MultipartValue('avatar', 'contents', 'avatar.png', ['Content-Type' => 'image/png']), + new MultipartValue('description', 'Profile picture'), + ], $request->body()); + } + + public function testHasBodyReportsWhetherTheRequestHasABody(): void + { + $this->assertFalse((new UserRequest)->hasBody()); + $this->assertNull((new UserRequest)->body()); + $this->assertTrue((new UserRequest)->asMultipart()->hasBody()); + } + + public function testCustomBodyRepositoriesExposeTheirResolvedValue(): void + { + $this->assertSame('custom', (new CustomBodyRequestStub)->body()); + } +} + +class CustomBodyRequestStub extends Request +{ + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } + + /** + * Resolve the default body repository. + */ + protected function defaultBodyRepository(): BodyRepository + { + return new CustomBodyRepositoryStub('custom'); + } +} + +class CustomBodyRepositoryStub implements BodyRepository +{ + /** + * Create a custom body repository. + */ + public function __construct(protected mixed $value) + { + } + + /** + * Set the raw data in the repository. + */ + public function set(mixed $value): static + { + $this->value = $value; + + return $this; + } + + /** + * Get the raw data in the repository. + */ + public function all(): mixed + { + return $this->value; + } + + /** + * Determine if the repository is empty. + */ + public function isEmpty(): bool + { + return $this->value === null; + } + + /** + * Determine if the repository is not empty. + */ + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } + + /** + * Convert the body repository into a stream. + */ + public function toStream(): StreamInterface + { + return Utils::streamFor((string) $this->value); + } +} diff --git a/tests/Saloon/Unit/Concerns/HasApiVersionTest.php b/tests/Saloon/Unit/Concerns/HasApiVersionTest.php new file mode 100644 index 0000000000..0d3f68449c --- /dev/null +++ b/tests/Saloon/Unit/Concerns/HasApiVersionTest.php @@ -0,0 +1,378 @@ +sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com', VersionMode::Header, 'anthropic-version'), + $this->makeVersionedRequest(), + ); + + $this->assertSame('anthropic-version', $response->toPsrRequest()->getHeaderLine('api-version')); + } + + public function testQueryParamModeAppendsTheVersionToTheQueryString(): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com', VersionMode::QueryParam, '2026-10'), + $this->makeVersionedRequest(), + ); + + $this->assertSame('api-version=2026-10', $response->toPsrRequest()->getUri()->getQuery()); + } + + public function testUrlModeReplacesTheVersionPlaceholderInThePath(): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://generativelanguage.googleapis.com/{version}', VersionMode::Url, 'v1beta'), + $this->makeVersionedRequest(), + ); + + $this->assertSame('https://generativelanguage.googleapis.com/v1beta', (string) $response->pendingRequest()->uri()); + $this->assertSame('https://generativelanguage.googleapis.com/v1beta', (string) $response->toPsrRequest()->getUri()); + } + + public function testUrlModeReplacesTheVersionPlaceholderInTheHost(): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://{version}.api.provider.com', VersionMode::Url, 'v2'), + $this->makeVersionedRequest(), + ); + + $this->assertSame('https://v2.api.provider.com', (string) $response->pendingRequest()->uri()); + $this->assertSame('https://v2.api.provider.com', (string) $response->toPsrRequest()->getUri()); + } + + #[DataProvider('commonVersionFormats')] + public function testUrlModeAcceptsCommonVersionFormats(string $version): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, $version), + $this->makeVersionedRequest(), + ); + + $this->assertSame('https://api.provider.com/' . $version, (string) $response->pendingRequest()->uri()); + } + + /** + * Get common API version formats. + * + * @return array + */ + public static function commonVersionFormats(): array + { + return [['v1'], ['v1beta'], ['v2.1'], ['2026-10-01'], ['v1_alpha']]; + } + + #[DataProvider('unsafeVersions')] + public function testUrlModeRejectsVersionsThatCouldAlterTheSchemeHostOrPath(string $baseUrl, string $version): void + { + $mockClient = new MockClient([MockResponse::make()]); + + $connector = $this->makeVersionedConnector($baseUrl, VersionMode::Url, $version); + + try { + $connector->send($this->makeVersionedRequest(), $mockClient); + + $this->fail('The unsafe version was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame( + sprintf('The API version "%s" is not safe to use in a URL. Versions may only contain letters, numbers, dashes, underscores and single dots.', $version), + $exception->getMessage(), + ); + } + + $mockClient->assertNothingSent(); + } + + /** + * Get versions that could change the request target. + * + * @return array + */ + public static function unsafeVersions(): array + { + return [ + 'https scheme in path' => ['https://api.provider.com/{version}', 'https://evil.com'], + 'https scheme in subdomain' => ['https://{version}.api.provider.com', 'https://evil.com'], + 'file scheme' => ['{version}/api', 'file:///etc/passwd'], + 'bare scheme separator' => ['https://api.provider.com/{version}', '://evil.com'], + 'protocol relative host' => ['https://api.provider.com/{version}', '//evil.com'], + 'userinfo host takeover' => ['https://{version}.api.provider.com', 'evil.com@'], + 'path traversal' => ['https://api.provider.com/{version}', '..'], + 'nested path traversal' => ['https://api.provider.com/{version}', 'v1/../../admin'], + 'single dot' => ['https://api.provider.com/{version}', '.'], + 'query string' => ['https://api.provider.com/{version}', 'v1?admin=true'], + 'fragment' => ['https://api.provider.com/{version}', 'v1#admin'], + 'port' => ['https://{version}.api.provider.com', 'evil.com:8080'], + 'whitespace' => ['https://api.provider.com/{version}', 'v1 beta'], + 'crlf' => ['https://api.provider.com/{version}', "v1\r\nHost: evil.com"], + 'percent encoding' => ['https://api.provider.com/{version}', '%2e%2e'], + ]; + } + + // Upstream changes the connector's version with setApiVersion(). Connectors are read-only, so a connector + // supplies a different version by overriding getApiVersion(). + public function testTheConnectorCanOverrideTheApiVersion(): void + { + $connector = new class('https://api.provider.com', VersionMode::Header, 'anthropic-version') extends VersionedConnector { + /** + * Get the API version. + */ + public function getApiVersion(): ?string + { + return 'override-version'; + } + }; + + $response = $this->sendVersionedRequest($connector, $this->makeVersionedRequest()); + + $this->assertSame('override-version', $response->toPsrRequest()->getHeaderLine('api-version')); + } + + public function testTheRequestCanOverrideTheApiVersionAtRuntime(): void + { + $request = $this->makeVersionedRequest('override-version', VersionMode::Header); + + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com'), + $request, + ); + + $this->assertSame('override-version', $response->toPsrRequest()->getHeaderLine('api-version')); + } + + #[DataProvider('versionModes')] + public function testARequestVersionReplacesTheConnectorVersion(VersionMode $versionMode): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com/{version}', $versionMode, 'connector-version'), + $this->makeVersionedRequest('request-version', $versionMode), + ); + $psrRequest = $response->toPsrRequest(); + + match ($versionMode) { + VersionMode::Header => $this->assertSame(['request-version'], $psrRequest->getHeader('api-version')), + VersionMode::QueryParam => $this->assertSame('api-version=request-version', $psrRequest->getUri()->getQuery()), + VersionMode::Url => $this->assertSame('https://api.provider.com/request-version', (string) $psrRequest->getUri()), + }; + } + + public function testAVersionOfZeroIsApplied(): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com', VersionMode::Header, '0'), + $this->makeVersionedRequest(), + ); + + $this->assertSame('0', $response->toPsrRequest()->getHeaderLine('api-version')); + } + + public function testRemovingAQueryParameterKeepsAUrlVersion(): void + { + $request = $this->makeVersionedRequest()->withQueryParameters(['page' => 2]); + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + $pendingRequest->withoutQueryParameters('page'); + }); + + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com/{version}', VersionMode::Url, 'v1'), + $request, + ); + + $this->assertSame('https://api.provider.com/v1', (string) $response->toPsrRequest()->getUri()); + } + + #[DataProvider('cookieAuthenticatedVersions')] + public function testCookieAuthenticationUsesTheVersionedHost(string $connectorClass, ?string $requestVersion, string $url): void + { + $sent = null; + Http::fake(function (HttpRequest $request) use (&$sent): PromiseInterface { + $sent = $request; + + return Http::response(); + }); + + $connector = new $connectorClass('https://{version}.api.provider.com', VersionMode::Url, 'v1'); + $connector->send($this->makeVersionedRequest($requestVersion, VersionMode::Url)); + + $this->assertSame($url, $sent->url()); + $this->assertSame(['session=secret'], $sent->header('Cookie')); + } + + /** + * Get cookie-authenticated connectors and the request versions sent through them. + * + * @return array, null|string, string}> + */ + public static function cookieAuthenticatedVersions(): array + { + return [ + 'configured authenticator' => [CookieVersionedConnector::class, null, 'https://v1.api.provider.com'], + 'configured authenticator and request version' => [CookieVersionedConnector::class, 'v2', 'https://v2.api.provider.com'], + 'plugin authenticator and request version' => [PluginCookieVersionedConnector::class, 'v2', 'https://v2.api.provider.com'], + ]; + } + + #[DataProvider('versionModes')] + public function testWithoutAVersionItDoesNotAddAHeaderQueryParameterOrReplaceTheUrl(VersionMode $versionMode): void + { + $response = $this->sendVersionedRequest( + $this->makeVersionedConnector('https://api.provider.com/{version}', $versionMode), + $this->makeVersionedRequest(null, $versionMode), + ); + + $this->assertSame('', $response->toPsrRequest()->getHeaderLine('api-version')); + $this->assertStringNotContainsString('api-version', $response->toPsrRequest()->getUri()->getQuery()); + // URI parsing encodes the braces of the placeholder that was left in the path. + $this->assertSame('https://api.provider.com/%7Bversion%7D', (string) $response->pendingRequest()->uri()); + } + + /** + * Get the API version modes. + * + * @return array + */ + public static function versionModes(): array + { + return [ + 'header' => [VersionMode::Header], + 'query param' => [VersionMode::QueryParam], + 'url' => [VersionMode::Url], + ]; + } + + /** + * Create a connector that applies an API version. + */ + protected function makeVersionedConnector(string $baseUrl, VersionMode $versionMode = VersionMode::Header, ?string $apiVersion = null): Connector + { + return new VersionedConnector($baseUrl, $versionMode, $apiVersion); + } + + /** + * Create a request that applies an API version. + */ + protected function makeVersionedRequest(?string $apiVersion = null, VersionMode $versionMode = VersionMode::Header): Request + { + return new class($versionMode, $apiVersion) extends Request { + use HasApiVersion; + + protected Method $method = Method::GET; + + /** + * Create a new request instance. + */ + public function __construct( + VersionMode $versionMode, + ?string $apiVersion, + ) { + $this->versionMode = $versionMode; + $this->apiVersion = $apiVersion; + } + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return ''; + } + }; + } + + /** + * Send a versioned request through a mock client. + */ + protected function sendVersionedRequest(Connector $connector, Request $request): Response + { + return $connector->send($request, new MockClient([ + MockResponse::make(), + ])); + } +} + +class VersionedConnector extends Connector +{ + use HasApiVersion; + + /** + * Create a new connector instance. + */ + public function __construct( + private readonly string $baseUrl, + VersionMode $versionMode, + ?string $apiVersion, + ) { + $this->versionMode = $versionMode; + $this->apiVersion = $apiVersion; + } + + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return $this->baseUrl; + } +} + +class CookieVersionedConnector extends VersionedConnector +{ + /** + * Resolve the default authenticator. + */ + protected function defaultAuth(): ?Authenticator + { + return new CookieAuthenticator('session', 'secret'); + } +} + +trait AuthenticatesWithSessionCookie +{ + /** + * Authenticate the pending request with a session cookie. + */ + public function bootAuthenticatesWithSessionCookie(PendingRequest $pendingRequest): void + { + $pendingRequest->authenticate(new CookieAuthenticator('session', 'secret')); + } +} + +class PluginCookieVersionedConnector extends VersionedConnector +{ + use AuthenticatesWithSessionCookie; +} diff --git a/tests/Saloon/Unit/ConditionableTest.php b/tests/Saloon/Unit/ConditionableTest.php new file mode 100644 index 0000000000..58c767fc98 --- /dev/null +++ b/tests/Saloon/Unit/ConditionableTest.php @@ -0,0 +1,146 @@ +when(true, function (UserRequest $request): void { + $request->withHeader('X-Name', 'Sam'); + }); + + $request->when(false, function (UserRequest $request): void { + $request->withHeader('X-Name', 'Alex'); + }); + + $this->assertSame(['X-Name' => 'Sam'], $request->headers()); + } + + public function testYouCanUseTheUnlessMethodToInvokeACallbackWhenAGivenConditionIsFalsy(): void + { + $request = new UserRequest; + + $request->unless(true, function (UserRequest $request): void { + $request->withHeader('X-Name', 'Sam'); + }); + + $request->unless(false, function (UserRequest $request): void { + $request->withHeader('X-Name', 'Alex'); + }); + + $this->assertSame(['X-Name' => 'Alex'], $request->headers()); + } + + public function testYouCanProvideACallbackAsTheValueOfTheWhenCondition(): void + { + $request = new UserRequest; + + $request->when( + fn (): bool => true, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Sam'); + } + ); + + $this->assertSame(['X-Name' => 'Sam'], $request->headers()); + } + + public function testYouCanProvideACallbackAsTheValueOfTheUnlessCondition(): void + { + $request = new UserRequest; + + $request->unless( + fn (): bool => false, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Alex'); + } + ); + + $this->assertSame(['X-Name' => 'Alex'], $request->headers()); + } + + public function testYouCanProvideACallbackAsTheDefaultValueOfTheWhenCondition(): void + { + $request = new UserRequest; + + $request->when( + false, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Sam'); + }, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Alex'); + } + ); + + $this->assertSame(['X-Name' => 'Alex'], $request->headers()); + } + + public function testYouCanProvideACallbackAsTheDefaultValueOfTheUnlessCondition(): void + { + $request = new UserRequest; + + $request->unless( + true, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Sam'); + }, + function (UserRequest $request): void { + $request->withHeader('X-Name', 'Alex'); + } + ); + + $this->assertSame(['X-Name' => 'Alex'], $request->headers()); + } + + public function testItWillPassTheConditionValueAsTheSecondArgumentOfTheCallable(): void + { + $request = new UserRequest; + $values = []; + + // Upstream asserts inside the callbacks, which would also pass if they never ran. + $request->when(true, function (UserRequest $request, mixed $value) use (&$values): void { + $values[] = $value; + }); + + $request->unless(false, function (UserRequest $request, mixed $value) use (&$values): void { + $values[] = $value; + }); + + $this->assertSame([true, false], $values); + } + + public function testItWillPassTheConditionValueAsTheSecondArgumentOfTheDefaultCallable(): void + { + $request = new UserRequest; + $values = []; + + $request->when( + false, + function (UserRequest $request, mixed $value): void { + }, + function (UserRequest $request, mixed $value) use (&$values): void { + $values[] = $value; + } + ); + + $request->unless( + true, + function (UserRequest $request, mixed $value): void { + }, + function (UserRequest $request, mixed $value) use (&$values): void { + $values[] = $value; + } + ); + + $this->assertSame([false, true], $values); + } +} diff --git a/tests/Saloon/Unit/ConfigTest.php b/tests/Saloon/Unit/ConfigTest.php new file mode 100644 index 0000000000..2e5bb541d8 --- /dev/null +++ b/tests/Saloon/Unit/ConfigTest.php @@ -0,0 +1,96 @@ + 'Jake Owen - Beachin']), + ]); + + $count = 0; + + Saloon::middleware()->onRequest(function (PendingRequest $pendingRequest) use (&$count): void { + ++$count; + }); + + Saloon::middleware()->onResponse(function (Response $response) use (&$count): void { + ++$count; + }); + + TestConnector::make()->send(new UserRequest, $mockClient); + + $this->assertSame(2, $count); + } + + // Upstream swaps the default sender class. Saloon sends through the configured HTTP connection instead. + public function testYouCanChangeTheGlobalDefaultConnectionUsed(): void + { + Http::registerConnection('secondary', ['timeout' => 12]); + $timeouts = []; + Http::fake(function (HttpRequest $request, array $options) use (&$timeouts): PromiseInterface { + $timeouts[] = $options['timeout']; + + return Http::response(); + }); + + config(['saloon.connection.name' => 'secondary']); + TestConnector::make()->send(new UserRequest); + + config(['saloon.connection.name' => 'saloon']); + TestConnector::make()->send(new UserRequest); + + $this->assertSame([12, 30], $timeouts); + } + + // REMOVED: Config::setSenderResolver(). A connector selects another registered HTTP connection through + // resolveHttpConnection(); see Unit/SenderTest. + + // REMOVED: Config::setClock(), getClock() and now(). Saloon reads the framework clock, which tests control with + // Date::setTestNow(). + + public function testYouCanPreventStrayApiRequests(): void + { + Http::preventStrayRequests(); + + $this->expectException(StrayRequestException::class); + $this->expectExceptionMessageIs('Attempted request to [https://tests.saloon.dev/api/user] without a matching fake.'); + + TestConnector::make()->send(new UserRequest); + } + + // Allowing stray requests again sends a real request, so that case runs against the engine test server in + // tests/Integration/Saloon/Unit/ConfigTest. + + // REMOVED: Config::sleepUsing() and sleep(). Saloon sleeps through the framework Sleep class, which tests fake with + // Sleep::fake(); see the delay and retry tests. +} diff --git a/tests/Saloon/Unit/ConnectorTest.php b/tests/Saloon/Unit/ConnectorTest.php new file mode 100644 index 0000000000..2c4dc61d06 --- /dev/null +++ b/tests/Saloon/Unit/ConnectorTest.php @@ -0,0 +1,77 @@ +assertInstanceOf(TestConnector::class, $connectorA); + + $connectorB = RequestSelectionConnector::make('yee-haw-1-2-3'); + + $this->assertInstanceOf(RequestSelectionConnector::class, $connectorB); + $this->assertSame('yee-haw-1-2-3', $connectorB->apiKey); + } + + public function testTheSameConnectorInstanceIsKeptIfYouInstantiateItOnTheRequestWithHasConnector(): void + { + $request = new HasConnectorUserRequest; + $connector = $request->connector(); + + $this->assertInstanceOf(TestConnector::class, $connector); + $this->assertSame($connector, $request->connector()); + + $replacement = new TestConnector('https://other.example.com'); + + $this->assertSame($request, $request->setConnector($replacement)); + $this->assertSame($replacement, $request->connector()); + } + + public function testYouCanSendARequestThroughTheConnector(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']), + ]); + + $connector = new TestConnector; + $response = $connector->send(new UserRequest, $mockClient); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam'], $response->json()); + } + + // REMOVED: "you can send an asynchronous request through the connector". Coroutine pools replace promises and sendAsync(). + + public function testConnectorsAreMacroable(): void + { + TestConnector::macro('apiUrl', function (): string { + return $this->resolveBaseUrl(); + }); + + $this->assertSame(TestConnector::API_URL, (new TestConnector)->apiUrl()); + } +} diff --git a/tests/Saloon/Unit/FixtureDataTest.php b/tests/Saloon/Unit/FixtureDataTest.php new file mode 100644 index 0000000000..365fcbba23 --- /dev/null +++ b/tests/Saloon/Unit/FixtureDataTest.php @@ -0,0 +1,268 @@ + 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + ]; + + $fixtureData = RecordedResponse::fromFile(json_encode($data)); + + $this->assertSame($data['statusCode'], $fixtureData->statusCode); + $this->assertSame($data['headers'], $fixtureData->headers); + $this->assertSame($data['data'], $fixtureData->data); + } + + public function testYouCanCreateAMockResponseFromFixtureData(): void + { + $data = [ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + ]; + + $fixtureData = RecordedResponse::fromFile(json_encode($data)); + $mockResponse = $fixtureData->toMockResponse(); + + $this->assertEquals(new MockResponse($data['data'], $data['statusCode'], $data['headers']), $mockResponse); + } + + /** + * @param array $data + * @param null|array $expected + */ + #[DataProvider('fixtureFiles')] + public function testYouCanJsonSerializeTheFixtureDataOrConvertItIntoAFile(array $data, ?array $expected = null): void + { + $expected ??= $data; + + $fixtureData = RecordedResponse::fromFile(json_encode($data, JSON_PRETTY_PRINT)); + + $serialized = json_encode($fixtureData, JSON_PRETTY_PRINT); + + $this->assertSame(json_encode($expected, JSON_PRETTY_PRINT), $serialized); + $this->assertSame($serialized, $fixtureData->toFile()); + } + + /** + * Get fixture files with their expected serialization. + * + * @return array, 1?: array}> + */ + public static function fixtureFiles(): array + { + return [ + 'without context key' => [ + [ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + ], + [ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + 'context' => [], + ], + ], + 'with context key' => [ + [ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + 'context' => [], + ], + ], + 'with context data' => [ + [ + 'statusCode' => 200, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'data' => [ + 'name' => 'Sam', + ], + 'context' => [ + 'test' => 'you can json serialize the fixture data or convert it into a file', + ], + ], + ], + ]; + } + + public function testItRoundTripsTextAndContext(): void + { + $recorded = new RecordedResponse( + 201, + ['X-Trace' => ['abc']], + '{"name":"Taylor"}', + ['provider' => 'example'], + ); + + $restored = RecordedResponse::fromFile($recorded->toFile()); + + $this->assertSame(201, $restored->statusCode); + $this->assertSame(['X-Trace' => ['abc']], $restored->headers); + $this->assertSame('{"name":"Taylor"}', $restored->data); + $this->assertSame(['provider' => 'example'], $restored->context); + $this->assertSame('{"name":"Taylor"}', (string) $restored->toMockResponse()->createPsrResponse()->getBody()); + } + + public function testItRoundTripsBinaryResponseData(): void + { + $recorded = new RecordedResponse(200, data: "\xB1\x31"); + + $contents = $recorded->toFile(); + $restored = RecordedResponse::fromFile($contents); + + $this->assertStringContainsString('"encoding": "base64"', $contents); + $this->assertSame("\xB1\x31", $restored->data); + } + + public function testItRejectsInvalidBase64Data(): void + { + $this->expectException(FixtureException::class); + + RecordedResponse::fromFile('{"statusCode":200,"headers":[],"data":"%","encoding":"base64"}'); + } + + public function testArbitraryDataCanBeMergedInTheFixture(): void + { + $response = (new TestConnector)->send(new DTORequest, new MockClient([ + MockResponse::fixture('user')->merge([ + 'name' => 'Sam Carré', + ]), + ])); + + $user = $response->dto(); + + $this->assertSame('Sam Carré', $user->name); + $this->assertSame('Sam', $user->actualName); + $this->assertSame('@carre_sam', $user->twitter); + } + + public function testArbitraryDataUsingDotNotationCanBeMergedInTheFixture(): void + { + $response = (new TestConnector)->send(new DTORequest, new MockClient([ + MockResponse::fixture('users')->merge([ + 'data.0.twitter' => '@jon_doe', + ]), + ])); + + $users = $response->json('data'); + + $this->assertCount(2, $users); + $this->assertSame('@jon_doe', $users[0]['twitter']); + $this->assertSame('@janedoe', $users[1]['twitter']); + } + + public function testAClosureCanBeUsedToModifyTheMockResponseData(): void + { + $response = (new TestConnector)->send(new DTORequest, new MockClient([ + MockResponse::fixture('users')->through(fn (array $data): array => array_merge_recursive($data, [ + 'data' => [ + [ + 'name' => 'Sam', + 'actual_name' => 'Carré', + 'twitter' => '@carre_sam', + ], + ], + ])), + ])); + + $users = $response->json('data'); + + $this->assertCount(3, $users); + $this->assertSame('@jondoe', $users[0]['twitter']); + $this->assertSame('@janedoe', $users[1]['twitter']); + $this->assertSame('@carre_sam', $users[2]['twitter']); + } + + public function testMergingKeepsAnArrayFixtureBodyAsJson(): void + { + $files = new Filesystem; + $fixturePath = ParallelTesting::tempDir('SaloonFixtureDataTest'); + $files->deleteDirectory($fixturePath); + $files->ensureDirectoryExists($fixturePath); + Saloon::fixturePath($fixturePath); + + try { + $files->put($fixturePath . '/array.json', json_encode([ + 'statusCode' => 200, + 'headers' => ['Content-Type' => 'application/json'], + 'data' => ['name' => 'Sam'], + ], JSON_THROW_ON_ERROR)); + + $response = MockResponse::fixture('array') + ->merge(['twitter' => '@carre_sam']) + ->through(fn (array $data): array => [...$data, 'actual_name' => 'Sam']) + ->getMockResponse(); + + $this->assertInstanceOf(JsonBodyRepository::class, $response->body()); + $this->assertSame(['name' => 'Sam', 'twitter' => '@carre_sam', 'actual_name' => 'Sam'], $response->body()->all()); + } finally { + $files->deleteDirectory($fixturePath); + } + } +} diff --git a/tests/Saloon/Http/FixtureTest.php b/tests/Saloon/Unit/FixtureTest.php similarity index 76% rename from tests/Saloon/Http/FixtureTest.php rename to tests/Saloon/Unit/FixtureTest.php index 873529ea85..421edbe807 100644 --- a/tests/Saloon/Http/FixtureTest.php +++ b/tests/Saloon/Unit/FixtureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; @@ -31,7 +31,7 @@ public function testItStoresLoadsRedactsAndTransformsFixtures(): void 201, ['Authorization' => ['Bearer secret'], 'X-Trace' => ['abc']], '{"token":"secret","nested":{"token":"secret"},"message":"key=secret"}', - ['existing' => true], + ['existing' => true, 'provider' => 'recorded'], )); $this->assertTrue($this->files->exists($fixture->getFixturePath())); @@ -60,7 +60,43 @@ public function testItStoresLoadsRedactsAndTransformsFixtures(): void json_decode((string) $response->body()->all(), true, 512, JSON_THROW_ON_ERROR), ); $restored = RecordedResponse::fromFile($contents); - $this->assertSame(['existing' => true, 'provider' => 'example'], $restored->context); + $this->assertSame(['provider' => 'recorded', 'existing' => true], $restored->context); + $this->assertSame(['provider' => 'example'], $fixture->getContext()->all()); + } + + public function testHeaderRulesApplyToEachHeaderValue(): void + { + $fixture = new HeaderFixtureStub('headers', $this->files); + + $fixture->store(new RecordedResponse( + 200, + [ + 'Set-Cookie' => ['session=abc', 'token=def'], + 'server' => ['nginx'], + 'X-Api-Key' => 'abc', + ], + )); + + $recorded = RecordedResponse::fromFile($this->files->get($fixture->getFixturePath())); + + $this->assertSame(['session=[redacted]', 'token=[redacted]'], $recorded->headers['Set-Cookie']); + $this->assertSame(['secret'], $recorded->headers['server']); + $this->assertSame('[redacted]', $recorded->headers['X-Api-Key']); + } + + public function testArrayDataIsRedactedBeforeStorage(): void + { + $fixture = new RedactingFixtureStub('array-data', $this->files); + + $fixture->store(new RecordedResponse(200, [], ['token' => 'secret', 'message' => 'key=secret'])); + + $contents = $this->files->get($fixture->getFixturePath()); + + $this->assertStringNotContainsString('secret', $contents); + $this->assertSame( + ['token' => '[redacted]', 'message' => '[redacted]'], + json_decode(RecordedResponse::fromFile($contents)->data, true, 512, JSON_THROW_ON_ERROR), + ); } #[DataProvider('invalidFixtureNames')] @@ -71,6 +107,11 @@ public function testItRejectsUnsafeOrNonPortableFixtureNames(string $name): void (new Fixture($name, $this->files))->getFixturePath(); } + /** + * Get the fixture names that are unsafe or not portable. + * + * @return array + */ public static function invalidFixtureNames(): array { return [ @@ -177,6 +218,9 @@ public function testRedactionExecutionFailurePreventsFixturePublication(): void } } + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -188,6 +232,9 @@ protected function setUp(): void $this->bindManager(false); } + /** + * Clean up the test environment. + */ protected function tearDown(): void { $this->files->deleteDirectory($this->fixturePath); @@ -212,24 +259,51 @@ protected function bindManager(bool $throwOnMissing): void class RedactingFixtureStub extends Fixture { + /** + * Define sensitive response headers. + */ protected function defineSensitiveHeaders(): array { return ['authorization' => '[redacted]']; } + /** + * Define sensitive JSON attributes. + */ protected function defineSensitiveJsonParameters(): array { return ['token' => '[redacted]']; } + /** + * Define sensitive response body patterns. + */ protected function defineSensitiveRegexPatterns(): array { return ['/key=[^"}]*/' => '[redacted]']; } } +class HeaderFixtureStub extends Fixture +{ + /** + * Define sensitive response headers. + */ + protected function defineSensitiveHeaders(): array + { + return [ + 'set-cookie' => fn (string $value): string => strstr($value, '=', true) . '=[redacted]', + 'Server' => 'secret', + 'x-api-key' => '[redacted]', + ]; + } +} + class RegexFixtureStub extends Fixture { + /** + * Create a fixture that redacts the given pattern. + */ public function __construct( string $name, Filesystem $files, @@ -238,6 +312,9 @@ public function __construct( parent::__construct($name, $files); } + /** + * Define sensitive response body patterns. + */ protected function defineSensitiveRegexPatterns(): array { return [$this->pattern => '[redacted]']; @@ -246,16 +323,25 @@ protected function defineSensitiveRegexPatterns(): array class CallableNamedReplacementFixtureStub extends Fixture { + /** + * Define sensitive response headers. + */ protected function defineSensitiveHeaders(): array { return ['authorization' => 'trim']; } + /** + * Define sensitive JSON attributes. + */ protected function defineSensitiveJsonParameters(): array { return ['token' => 'trim']; } + /** + * Define sensitive response body patterns. + */ protected function defineSensitiveRegexPatterns(): array { return ['/key=[^"}]*/' => 'trim']; diff --git a/tests/Saloon/Unit/GlobalMockClientTest.php b/tests/Saloon/Unit/GlobalMockClientTest.php new file mode 100644 index 0000000000..42b9a4d645 --- /dev/null +++ b/tests/Saloon/Unit/GlobalMockClientTest.php @@ -0,0 +1,78 @@ + 'Sam']), + ]); + + $this->assertInstanceOf(MockClient::class, $mockClient); + $this->assertSame($mockClient, MockClient::getGlobal()); + $this->assertSame($mockClient, Saloon::mockClient()); + + $connector = new TestConnector; + $response = $connector->send(new UserRequest); + + $this->assertTrue($response->isMocked()); + $this->assertSame(['name' => 'Sam'], $response->json()); + + $mockClient->assertSent(UserRequest::class); + } + + public function testTheMockClientCanBeDestroyed(): void + { + $mockClient = MockClient::global(); + + $this->assertSame($mockClient, MockClient::getGlobal()); + + MockClient::destroyGlobal(); + + $this->assertNull(MockClient::getGlobal()); + } + + public function testALocalMockClientIsGivenPriorityOverTheGlobalMockClient(): void + { + MockClient::global([ + MockResponse::make(['name' => 'Sam']), + ]); + + $localMockClient = new MockClient([ + MockResponse::make(['name' => 'Taylor']), + ]); + + $request = (new UserRequest)->withMockClient($localMockClient); + + $response = (new TestConnector)->send($request); + + $this->assertTrue($response->isMocked()); + $this->assertSame(['name' => 'Taylor'], $response->json()); + + $localMockClient->assertSentCount(1); + MockClient::global()->assertNothingSent(); + } +} diff --git a/tests/Saloon/Unit/MiddlewarePipelineTest.php b/tests/Saloon/Unit/MiddlewarePipelineTest.php new file mode 100644 index 0000000000..998ad40319 --- /dev/null +++ b/tests/Saloon/Unit/MiddlewarePipelineTest.php @@ -0,0 +1,531 @@ +onRequest(function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + }) + ->onRequest(function (PendingRequest $request): void { + $request->withHeader('X-Pipe-Two', 'Howdy'); + }); + + $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest); + $pipeline->executeRequestPipeline($pendingRequest); + + $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']); + $this->assertSame('Howdy', $pendingRequest->headers()['X-Pipe-Two']); + } + + public function testRequestMiddlewareCanAddANamedPipe(): void + { + $pipeline = new MiddlewarePipeline; + + $pipeline->onRequest(function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + }, 'YeeHawPipe'); + + $pipe = $pipeline->requestPipeline()->pipes()[0]; + + $this->assertInstanceOf(Pipe::class, $pipe); + $this->assertSame('YeeHawPipe', $pipe->name); + $this->assertNull($pipe->order); + + $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest); + $pipeline->executeRequestPipeline($pendingRequest); + + $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']); + } + + public function testRequestMiddlewareNamedPipeMustBeUnique(): void + { + $pipeline = new MiddlewarePipeline; + + $pipeline->onRequest( + callable: function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + }, + name: 'YeeHawPipe', + ); + + $this->expectException(DuplicatePipeNameException::class); + $this->expectExceptionMessageIs('The "YeeHawPipe" pipe already exists on the pipeline.'); + + $pipeline->onRequest( + callable: function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + }, + name: 'YeeHawPipe', + ); + } + + // Upstream passes a returned pending request to the next pipe. The manager always sends the pending request it + // created, as upstream's own send path does, so a returned pending request is ignored. + public function testRequestMiddlewareIgnoresAReturnedPendingRequest(): void + { + $pipeline = new MiddlewarePipeline; + $connector = new TestConnector; + $errorRequest = $connector->createPendingRequest(new ErrorRequest); + $nextRequest = null; + + $pipeline + ->onRequest(function (PendingRequest $request) use ($errorRequest): PendingRequest { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + + return $errorRequest; + }) + ->onRequest(function (PendingRequest $request) use (&$nextRequest): void { + $nextRequest = $request; + }); + + $pendingRequest = $connector->createPendingRequest(new UserRequest); + $pipeline->executeRequestPipeline($pendingRequest); + + $this->assertSame($pendingRequest, $nextRequest); + $this->assertSame('Yee-Haw', $pendingRequest->headers()['X-Pipe-One']); + } + + public function testRequestMiddlewareRunsInOrderOfPipes(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onRequest(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onRequest(function () use (&$names): void { + $names[] = 'Taylor'; + }); + + $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest)); + + $this->assertSame(['Sam', 'Taylor'], $names); + } + + public function testRequestMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onRequest(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onRequest(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::FIRST) + ->onRequest(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest)); + + $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names); + } + + public function testRequestMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onRequest(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onRequest(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::LAST) + ->onRequest(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeRequestPipeline((new TestConnector)->createPendingRequest(new UserRequest)); + + $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names); + } + + public function testResponseMiddlewareCanAddANamedPipe(): void + { + $pipeline = new MiddlewarePipeline; + $count = 0; + + $pipeline->onResponse(function () use (&$count): void { + ++$count; + }, 'ResponsePipe'); + + $pipe = $pipeline->responsePipeline()->pipes()[0]; + + $this->assertInstanceOf(Pipe::class, $pipe); + $this->assertSame('ResponsePipe', $pipe->name); + $this->assertNull($pipe->order); + + $pipeline->executeResponsePipeline($this->response()); + + $this->assertSame(1, $count); + } + + public function testResponseMiddlewareNamedPipeMustBeUnique(): void + { + $pipeline = new MiddlewarePipeline; + + $pipeline->onResponse(function (): void { + }, 'ResponsePipe'); + + $this->expectException(DuplicatePipeNameException::class); + $this->expectExceptionMessageIs('The "ResponsePipe" pipe already exists on the pipeline.'); + + $pipeline->onResponse(function (): void { + }, 'ResponsePipe'); + } + + public function testResponseMiddlewareCanAddAPipe(): void + { + $pipeline = new MiddlewarePipeline; + $count = 0; + + $pipeline + ->onResponse(function (Response $response) use (&$count): void { + ++$count; + }) + ->onResponse(function (Response $response) use (&$count): void { + ++$count; + }); + + $response = $this->response(); + + $this->assertSame($response, $pipeline->executeResponsePipeline($response)); + $this->assertSame(2, $count); + } + + public function testResponseMiddlewareUsesAReturnedResponseInTheNextStep(): void + { + $mockClient = new MockClient([ + ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500), + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ]); + $connector = new TestConnector; + $pipeline = new MiddlewarePipeline; + + $errorResponse = $connector->send(new ErrorRequest, $mockClient); + + $pipeline->onResponse(fn (): Response => $errorResponse); + + $response = $pipeline->executeResponsePipeline($connector->send(new UserRequest, $mockClient)); + + $this->assertSame($errorResponse, $response); + } + + public function testResponseMiddlewareRunsInOrderOfPipes(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onResponse(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onResponse(function () use (&$names): void { + $names[] = 'Taylor'; + }); + + $pipeline->executeResponsePipeline($this->response()); + + $this->assertSame(['Sam', 'Taylor'], $names); + } + + public function testResponseMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onResponse(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onResponse(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::FIRST) + ->onResponse(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeResponsePipeline($this->response()); + + $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names); + } + + public function testResponseMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onResponse(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onResponse(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::LAST) + ->onResponse(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeResponsePipeline($this->response()); + + $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names); + } + + // Upstream sends to an unresolvable host and asserts inside a catch block, which also passes when nothing is + // thrown. The fatal pipeline only needs a fatal exception, so these cases build one directly. + public function testFatalMiddlewareCanAddAPipe(): void + { + $pipeline = new MiddlewarePipeline; + $exception = $this->fatalException(); + $received = []; + + $pipeline + ->onFatalException(function (FatalRequestException $exception) use (&$received): void { + $received[] = $exception; + }) + ->onFatalException(function (FatalRequestException $exception) use (&$received): void { + $received[] = $exception; + }); + + $pipeline->executeFatalPipeline($exception); + + $this->assertSame([$exception, $exception], $received); + } + + public function testFatalMiddlewareCanAddANamedPipe(): void + { + $pipeline = new MiddlewarePipeline; + $count = 0; + + $pipeline->onFatalException(function () use (&$count): void { + ++$count; + }, 'FatalPipe'); + + $pipe = $pipeline->fatalPipeline()->pipes()[0]; + + $this->assertInstanceOf(Pipe::class, $pipe); + $this->assertSame('FatalPipe', $pipe->name); + $this->assertNull($pipe->order); + + $pipeline->executeFatalPipeline($this->fatalException()); + + $this->assertSame(1, $count); + } + + public function testFatalMiddlewareNamedPipeMustBeUnique(): void + { + $pipeline = new MiddlewarePipeline; + + $pipeline->onFatalException( + callable: function (): void { + }, + name: 'YeeHawPipe', + ); + + $this->expectException(DuplicatePipeNameException::class); + $this->expectExceptionMessageIs('The "YeeHawPipe" pipe already exists on the pipeline.'); + + $pipeline->onFatalException( + callable: function (): void { + }, + name: 'YeeHawPipe', + ); + } + + public function testFatalMiddlewareRunsInOrderOfPipes(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onFatalException(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onFatalException(function () use (&$names): void { + $names[] = 'Taylor'; + }); + + $pipeline->executeFatalPipeline($this->fatalException()); + + $this->assertSame(['Sam', 'Taylor'], $names); + } + + public function testFatalMiddlewarePipeCanBeAddedToTheTopOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onFatalException(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onFatalException(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::FIRST) + ->onFatalException(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeFatalPipeline($this->fatalException()); + + $this->assertSame(['Taylor', 'Sam', 'Andrew'], $names); + } + + public function testFatalMiddlewarePipeCanBeAddedToTheBottomOfThePipeline(): void + { + $pipeline = new MiddlewarePipeline; + $names = []; + + $pipeline + ->onFatalException(function () use (&$names): void { + $names[] = 'Sam'; + }) + ->onFatalException(function () use (&$names): void { + $names[] = 'Taylor'; + }, order: PipeOrder::LAST) + ->onFatalException(function () use (&$names): void { + $names[] = 'Andrew'; + }); + + $pipeline->executeFatalPipeline($this->fatalException()); + + $this->assertSame(['Sam', 'Andrew', 'Taylor'], $names); + } + + public function testYouCanMergeAMiddlewarePipelineTogether(): void + { + $pipelineA = new MiddlewarePipeline; + $pipelineB = new MiddlewarePipeline; + + $pipelineA + ->onRequest(function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Yee-Haw'); + }) + ->onRequest(function (PendingRequest $request): void { + $request->withHeader('X-Pipe-One', 'Howdy'); + }) + ->onResponse(fn (Response $response): Response => $response->throw(), 'response'); + + $this->assertEmpty($pipelineB->requestPipeline()->pipes()); + $this->assertEmpty($pipelineB->responsePipeline()->pipes()); + + $pipelineB->merge($pipelineA); + + $this->assertCount(2, $pipelineB->requestPipeline()->pipes()); + $this->assertCount(1, $pipelineB->responsePipeline()->pipes()); + + // Merging rebuilds the pipes, so compare what each one holds. + $describe = static fn (Pipe $pipe): array => [$pipe->callable, $pipe->name, $pipe->order]; + + $this->assertSame( + array_map($describe, $pipelineA->requestPipeline()->pipes()), + array_map($describe, $pipelineB->requestPipeline()->pipes()), + ); + $this->assertSame( + array_map($describe, $pipelineA->responsePipeline()->pipes()), + array_map($describe, $pipelineB->responsePipeline()->pipes()), + ); + } + + public function testWhenMergingAMiddlewarePipelineTogetherIfTwoPipelinesExistWithTheSamePipeItThrowsAnException(): void + { + $pipelineA = new MiddlewarePipeline; + $pipelineB = new MiddlewarePipeline; + + $pipelineA->onRequest(fn (): null => null, 'howdy'); + $pipelineB->onRequest(fn (): null => null, 'howdy'); + + $this->expectException(DuplicatePipeNameException::class); + $this->expectExceptionMessageIs('The "howdy" pipe already exists on the pipeline.'); + + $pipelineA->merge($pipelineB); + } + + public function testAMiddlewarePipelineIsCorrectlyDestructedWhenFinished(): void + { + $pipeline = new MiddlewarePipeline; + $pipelineReference = WeakReference::create($pipeline); + + $pipeline + ->onRequest(function (): void { + }) + ->onResponse(function (): void { + }, order: PipeOrder::LAST) + ->onResponse(function (): void { + }) + ->onFatalException(function (): void { + }); + + $this->assertInstanceOf(Pipeline::class, $pipeline->requestPipeline()); + $this->assertCount(1, $pipeline->requestPipeline()->pipes()); + $this->assertInstanceOf(Pipeline::class, $pipeline->responsePipeline()); + $this->assertCount(2, $pipeline->responsePipeline()->pipes()); + $this->assertCount(1, $pipeline->fatalPipeline()->pipes()); + $this->assertSame($pipeline, $pipelineReference->get()); + + unset($pipeline); + + $this->assertNull($pipelineReference->get()); + } + + /** + * Send a mocked request and return its response. + */ + protected function response(): Response + { + return (new TestConnector)->send(new UserRequest, new MockClient([ + MockResponse::make(['name' => 'Sam']), + ])); + } + + /** + * Create a fatal request exception for a pending request. + */ + protected function fatalException(): FatalRequestException + { + return new FatalRequestException( + new Exception('Could not resolve host.'), + (new TestConnector)->createPendingRequest(new UserRequest), + ); + } +} diff --git a/tests/Saloon/Unit/MockClientAssertionsTest.php b/tests/Saloon/Unit/MockClientAssertionsTest.php new file mode 100644 index 0000000000..a3720746ab --- /dev/null +++ b/tests/Saloon/Unit/MockClientAssertionsTest.php @@ -0,0 +1,340 @@ + MockResponse::make(['name' => 'Sam']), + ]); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $mockClient->assertSent(UserRequest::class); + } + + public function testAssertSentWorksWithAClosure(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500), + ]); + + $originalRequest = new UserRequest; + $originalResponse = (new TestConnector)->send($originalRequest, $mockClient); + + $mockClient->assertSent(function (Request $request, Response $response) use ($originalRequest, $originalResponse): bool { + return $request === $originalRequest && $response === $originalResponse; + }); + + $newRequest = new ErrorRequest; + $newResponse = (new TestConnector)->send($newRequest, $mockClient); + + $mockClient->assertSent(function (Request $request, Response $response) use ($newRequest, $newResponse): bool { + return $request === $newRequest && $response === $newResponse; + }); + } + + public function testAssertSentWorksWithAUrl(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ]); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $mockClient->assertSent('saloon.dev/*'); + $mockClient->assertSent('/user'); + $mockClient->assertSent('api/user'); + } + + public function testAssertNotSentWorksWithARequest(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500), + ]); + + (new TestConnector)->send(new ErrorRequest, $mockClient); + + $mockClient->assertNotSent(UserRequest::class); + } + + public function testAssertNotSentWorksWithAClosure(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ErrorRequest::class => MockResponse::make(['error' => 'Server Error'], 500), + ]); + + (new TestConnector)->send(new ErrorRequest, $mockClient); + + $mockClient->assertNotSent(function (Request $request): bool { + return $request instanceof UserRequest; + }); + } + + public function testAssertNotSentWorksWithAUrl(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ]); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $mockClient->assertNotSent('google.com/*'); + $mockClient->assertNotSent('/error'); + } + + // REMOVED: assertSentJson tests - the deprecated assertSentJson() is not ported; use assertSent() with a closure. + + public function testAssertNothingSentWorksProperly(): void + { + $mockClient = new MockClient([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ]); + + $mockClient->assertNothingSent(); + } + + public function testAssertSentCountWorksProperly(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor']), + MockResponse::make(['name' => 'Marcel']), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + + $mockClient->assertSentCount(3); + } + + public function testCanAssertCountOfRequests(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor']), + MockResponse::make(['name' => 'Marcel']), + MockResponse::make(['message' => 'Error'], 500), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new ErrorRequest, $mockClient); + + $mockClient->assertSentCount(3, UserRequest::class); + $mockClient->assertSentCount(1, ErrorRequest::class); + } + + public function testAssertSentWithAClosureWorksWithMoreThanOneRequestInTheHistory(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor'], 201), + MockResponse::make(['name' => 'Marcel'], 204), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + + $mockClient->assertSent(function (Request $request, Response $response): bool { + return $response->json() === ['name' => 'Sam'] && $response->status() === 200; + }); + + $mockClient->assertSent(function (Request $request, Response $response): bool { + return $response->json() === ['name' => 'Taylor'] && $response->status() === 201; + }); + + $mockClient->assertSent(function (Request $request, Response $response): bool { + return $response->json() === ['name' => 'Marcel'] && $response->status() === 204; + }); + } + + public function testAssertSentWithATypedClosureSkipsNonMatchingRequestsAndStillFindsAMatch(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['message' => 'Error'], 500), + ]); + + $connector = new TestConnector; + + // Recorded responses are checked in send order, so the non-matching request goes first. + $connector->send(new ErrorRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $calls = 0; + + $mockClient->assertSent(function (UserRequest $request) use (&$calls): bool { + ++$calls; + + return $request->userId === null; + }); + + $this->assertSame(1, $calls); + } + + public function testAssertSentWithAUnionTypedClosureSkipsNonMatchingRequestsAndStillFindsAMatch(): void + { + $mockClient = new MockClient([ + MockResponse::make(['config' => true]), + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['message' => 'Error'], 500), + ]); + + $connector = new TestConnector; + + // The non-matching request goes first so both assertions must skip it. + $connector->send(new ConfigRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new ErrorRequest, $mockClient); + $calls = 0; + + $mockClient->assertSent(function (UserRequest|ErrorRequest $request) use (&$calls): bool { + ++$calls; + + return $request instanceof UserRequest && $request->userId === null; + }); + + $this->assertSame(1, $calls); + + $mockClient->assertSent(function (UserRequest|ErrorRequest $request) use (&$calls): bool { + ++$calls; + + return $request instanceof ErrorRequest; + }); + + $this->assertSame(3, $calls); + } + + public function testAssertSentWithATypedClosureFailsAssertionWhenNoMatchingRequestTypeWasSent(): void + { + $mockClient = new MockClient([ + ErrorRequest::class => MockResponse::make(['message' => 'Error'], 500), + ]); + + (new TestConnector)->send(new ErrorRequest, $mockClient); + + $this->expectException(ExpectationFailedException::class); + + $mockClient->assertSent(function (UserRequest $request): bool { + return true; + }); + } + + public function testTypedClosuresAcceptMixedObjectAndIntersectionTypes(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor']), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new MarkedUserRequest, $mockClient); + $intersectionCalls = 0; + + $mockClient->assertSent(fn (mixed $request): bool => $request instanceof MarkedUserRequest); + $mockClient->assertSent(fn (object $request): bool => $request instanceof MarkedUserRequest); + $mockClient->assertSent(function (UserRequest&MarkedRequest $request) use (&$intersectionCalls): bool { + ++$intersectionCalls; + + return true; + }); + $mockClient->assertNotSent(fn (string $request): bool => true); + + $this->assertSame(1, $intersectionCalls); + } + + public function testItCanAssertRequestsAreSentInASpecificOrder(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor'], 201), + MockResponse::make(['name' => 'Marcel'], 204), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + + $mockClient->assertSentInOrder([ + UserRequest::class, + function (UserRequest $request, Response $response): bool { + return $response->json() === ['name' => 'Taylor']; + }, + '/user', + ]); + } + + public function testItCanAssertRequestsAreSentInASpecificOrderFailure(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor'], 201), + MockResponse::make(['name' => 'Marcel'], 204), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest(userId: 2), $mockClient); + $connector->send(new UserRequest(userId: 1), $mockClient); + $connector->send(new UserRequest, $mockClient); + + $this->expectException(ExpectationFailedException::class); + + $mockClient->assertSentInOrder([ + UserRequest::class, + function (UserRequest $request): bool { + return $request->userId === 2; + }, + '/user', + ]); + } +} + +interface MarkedRequest +{ +} + +class MarkedUserRequest extends UserRequest implements MarkedRequest +{ +} diff --git a/tests/Saloon/Unit/MockClientTest.php b/tests/Saloon/Unit/MockClientTest.php new file mode 100644 index 0000000000..d2964bd71a --- /dev/null +++ b/tests/Saloon/Unit/MockClientTest.php @@ -0,0 +1,366 @@ + 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $mockClient = new MockClient([$responseA, $responseB]); + $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest); + + $this->assertSame($responseA, $mockClient->match($pendingRequest)); + $this->assertSame($responseB, $mockClient->match($pendingRequest)); + $this->assertTrue($mockClient->isEmpty()); + } + + public function testYouCanCreateConnectorMocks(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $connectorARequest = new UserRequest; + $connectorBRequest = new QueryParameterConnectorRequest; + + $mockClient = new MockClient([ + TestConnector::class => $responseA, + QueryParameterConnector::class => $responseB, + ]); + + $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($connectorARequest))); + $this->assertSame($responseB, $mockClient->match($connectorB->createPendingRequest($connectorBRequest))); + $this->assertFalse($mockClient->isEmpty()); + } + + public function testYouCanCreateRequestMocks(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new QueryParameterConnector; + + $requestA = new UserRequest; + $requestB = new QueryParameterConnectorRequest; + + $mockClient = new MockClient([ + UserRequest::class => $responseA, + QueryParameterConnectorRequest::class => $responseB, + ]); + + $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA))); + $this->assertSame($responseB, $mockClient->match($connectorB->createPendingRequest($requestB))); + $this->assertFalse($mockClient->isEmpty()); + } + + public function testYouCanCreateUrlMocks(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + $responseC = MockResponse::make(['name' => 'Sam Carré']); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + 'google.com/*' => $responseC, // Test Different Route, + ]); + + $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA))); + $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB))); + $this->assertSame($responseC, $mockClient->match($connectorB->createPendingRequest($requestC))); + } + + public function testYouCanCreateWildcardUrlMocks(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + $responseC = MockResponse::make(['name' => 'Sam Carré']); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + '*' => $responseC, + ]); + + $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA))); + $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB))); + $this->assertSame($responseC, $mockClient->match($connectorB->createPendingRequest($requestC))); + } + + public function testSaloonThrowsAnExceptionIfItCantWorkOutTheUrlResponse(): void + { + $responseA = MockResponse::make(['name' => 'Sammyjo20']); + $responseB = MockResponse::make(['name' => 'Alex']); + + $connectorA = new TestConnector; + $connectorB = new DifferentServiceConnector; + + $requestA = new UserRequest; + $requestB = new ErrorRequest; + $requestC = new DifferentServiceUserRequest; + + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user' => $responseA, // Test Exact Route + 'tests.saloon.dev/*' => $responseB, // Test Wildcard Routes + ]); + + $this->assertSame($responseA, $mockClient->match($connectorA->createPendingRequest($requestA))); + $this->assertSame($responseB, $mockClient->match($connectorA->createPendingRequest($requestB))); + + $this->expectException(NoMockResponseFoundException::class); + $this->expectExceptionMessageIs('Saloon was unable to guess a mock response for your request [https://google.com/user], consider using a wildcard url mock or a connector mock.'); + + $mockClient->match($connectorB->createPendingRequest($requestC)); + } + + public function testUrlPatternsMatchWithOrWithoutTheQueryString(): void + { + $mockClient = new MockClient([ + 'tests.saloon.dev/api/user?per_page=50' => MockResponse::make(['matched' => 'query']), + 'tests.saloon.dev/api/user' => MockResponse::make(['matched' => 'path']), + ]); + $connector = new TestConnector; + + $withQuery = $connector->send((new QueryParameterRequest)->withQueryParameters(['per_page' => 50]), $mockClient); + $otherQuery = $connector->send(new QueryParameterRequest, $mockClient); + + $this->assertSame(['matched' => 'query'], $withQuery->json()); + $this->assertSame(['matched' => 'path'], $otherQuery->json()); + $mockClient->assertSent('/user?per_page=50'); + $mockClient->assertSent('/user?per_page=100'); + $mockClient->assertNotSent('/user?per_page=25'); + } + + public function testRequestConnectorUrlAndSequenceResponsesUseTheirPrecedence(): void + { + $mockClient = new MockClient([ + MockResponse::make(['matched' => 'sequence']), + TestConnector::class => MockResponse::make(['matched' => 'connector']), + '*/error' => MockResponse::make(['matched' => 'url']), + UserRequest::class => MockResponse::make(['matched' => 'request']), + ]); + + $request = (new TestConnector)->send(new UserRequest, $mockClient); + $connector = (new TestConnector)->send(new ErrorRequest, $mockClient); + $url = (new DifferentServiceConnector)->send(new ErrorRequest, $mockClient); + $sequence = (new DifferentServiceConnector)->send(new DifferentServiceUserRequest, $mockClient); + + $this->assertSame(['matched' => 'request'], $request->json()); + $this->assertSame(['matched' => 'connector'], $connector->json()); + $this->assertSame(['matched' => 'url'], $url->json()); + $this->assertSame(['matched' => 'sequence'], $sequence->json()); + $this->assertFalse($mockClient->isEmpty()); + } + + public function testUnmatchedRequestsAreStrictUnlessTheirUrlIsAllowed(): void + { + Http::fake(['*' => Http::response('network')]); + $mockClient = (new MockClient)->allowStrayRequests(['tests.saloon.dev/api/user']); + + $allowed = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame('network', $allowed->body()); + $this->assertFalse($allowed->isMocked()); + $mockClient->assertSentCount(1); + + $this->expectException(NoMockResponseFoundException::class); + + (new TestConnector)->send(new ErrorRequest, $mockClient); + } + + public function testYouCanGetAnArrayOfTheRecordedRequests(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor']), + MockResponse::make(['name' => 'Marcel']), + ]); + + $connector = new TestConnector; + + $responseA = $connector->send(new UserRequest, $mockClient); + $responseB = $connector->send(new UserRequest, $mockClient); + $responseC = $connector->send(new UserRequest, $mockClient); + + $this->assertSame([$responseA, $responseB, $responseC], $mockClient->recorded()->all()); + $this->assertSame( + [$responseB, $responseC], + $mockClient->recorded(fn (Request $request, Response $response): bool => $response->json('name') !== 'Sam')->all(), + ); + } + + public function testYouCanGetTheLastRecordedRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Taylor']), + MockResponse::make(['name' => 'Marcel']), + ]); + + $connector = new TestConnector; + + $connector->send(new UserRequest, $mockClient); + $connector->send(new UserRequest, $mockClient); + $requestC = new UserRequest; + $responseC = $connector->send($requestC, $mockClient); + + $this->assertSame($responseC, $mockClient->lastResponse()); + $this->assertSame($requestC, $mockClient->lastRequest()); + $this->assertSame($responseC->pendingRequest(), $mockClient->lastPendingRequest()); + } + + public function testIfThereAreNoRecordedResponsesTheLastResponseWillReturnNull(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $this->assertNull($mockClient->lastResponse()); + } + + public function testIfThereAreNoRecordedResponsesTheLastRequestWillReturnNull(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $this->assertNull($mockClient->lastRequest()); + } + + public function testIfTheResponseIsNotTheLastResponseItWillUseTheLoopToFindIt(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['error' => 'Server Error'], 500), + ]); + + $responseA = (new TestConnector)->send(new ErrorRequest, $mockClient); + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame($responseB, $mockClient->lastResponse()); + + // Uses last response + + $this->assertSame($responseB, $mockClient->recorded(fn (Request $request): bool => $request instanceof UserRequest)->first()); + + // Does not use the last response + + $this->assertSame($responseA, $mockClient->recorded(fn (Request $request): bool => $request instanceof ErrorRequest)->first()); + } + + public function testItWillFindTheResponseByUrlIfItIsNotTheLastResponse(): void + { + $mockClient = new MockClient([ + '/user' => MockResponse::make(['name' => 'Sam']), + '/error' => MockResponse::make(['error' => 'Server Error'], 500), + ]); + + $responseA = (new TestConnector)->send(new ErrorRequest, $mockClient); + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame($responseB, $mockClient->lastResponse()); + $this->assertSame(['name' => 'Sam'], $responseB->json()); + $this->assertSame(['error' => 'Server Error'], $responseA->json()); + + // Uses last response + + $mockClient->assertSent('/user'); + + // Does not use the last response + + $mockClient->assertSent('/error'); + } + + public function testYouCanMockExceptionsWithAClosure(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Patrick'])->throw(fn (PendingRequest $pendingRequest): TestResponseException => new TestResponseException('Unable to connect!', $pendingRequest)), + ]); + + $okResponse = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(['name' => 'Sam'], $okResponse->json()); + + $this->expectException(TestResponseException::class); + $this->expectExceptionMessageIs('Unable to connect!'); + + (new TestConnector)->send(new UserRequest, $mockClient); + } + + public function testYouCanMockNormalExceptions(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael'])->throw(new Exception('Custom Exception!')), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessageIs('Custom Exception!'); + + (new TestConnector)->send(new UserRequest, $mockClient); + } + + public function testMockClientCanOptOutOfResponseCacheIntegration(): void + { + $client = new MockClient([]); + + $this->assertFalse($client->shouldBypassResponseCache()); + + $client->withoutCache(); + + $this->assertTrue($client->shouldBypassResponseCache()); + } +} diff --git a/tests/Saloon/Unit/MockConfigTest.php b/tests/Saloon/Unit/MockConfigTest.php new file mode 100644 index 0000000000..15f5c144b8 --- /dev/null +++ b/tests/Saloon/Unit/MockConfigTest.php @@ -0,0 +1,99 @@ +files = new Filesystem; + $this->fixturePath = ParallelTesting::tempDir('SaloonMockConfigTest'); + $this->files->deleteDirectory($this->fixturePath); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + $this->files->deleteDirectory($this->fixturePath); + + parent::tearDown(); + } + + public function testYouCanChangeTheDefaultFixturePath(): void + { + $this->assertSame(base_path('tests/Fixtures/Saloon'), Saloon::getFixturePath()); + + Saloon::fixturePath('saloon-requests/responses'); + + $this->assertSame('saloon-requests/responses', Saloon::getFixturePath()); + } + + public function testYouCanThrowAnExceptionIfTheFixtureDoesNotExist(): void + { + Saloon::fixturePath($this->fixturePath); + + $this->assertFalse(Saloon::throwsOnMissingFixtures()); + + Saloon::throwOnMissingFixtures(); + + $mockClient = new MockClient([ + MockResponse::fixture('example'), + ]); + + $this->expectException(FixtureMissingException::class); + $this->expectExceptionMessageIsOrContains('example.json" could not be found in storage.'); + + (new TestConnector)->send(new UserRequest, $mockClient); + } + + public function testIfTheFixturePathDoesNotExistItWillBeCreatedWhenAFixtureIsRecorded(): void + { + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + Saloon::fixturePath($this->fixturePath . '/OtherFixturePath'); + + $mockClient = new MockClient([ + MockResponse::fixture('example'), + ]); + + $this->assertDirectoryDoesNotExist($this->fixturePath . '/OtherFixturePath'); + + (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFileExists($this->fixturePath . '/OtherFixturePath/example.json'); + } +} diff --git a/tests/Saloon/Unit/MockResponseTest.php b/tests/Saloon/Unit/MockResponseTest.php new file mode 100644 index 0000000000..6f0daa24d0 --- /dev/null +++ b/tests/Saloon/Unit/MockResponseTest.php @@ -0,0 +1,97 @@ +createPendingRequest(new UserRequest); + + $this->assertSame($responseA->status(), $mockClient->match($pendingRequest)->status()); + $this->assertSame($responseB->status(), $mockClient->match($pendingRequest)->status()); + $this->assertSame($responseC->status(), $mockClient->match($pendingRequest)->status()); + $this->assertTrue($mockClient->isEmpty()); + } + + public function testAMockResponseCanHaveRawBodyData(): void + { + $response = MockResponse::make('xml', 200, ['Content-Type' => 'application/json']); + + $this->assertSame(['Content-Type' => 'application/json'], $response->headers()); + $this->assertSame(200, $response->status()); + $this->assertInstanceOf(StringBodyRepository::class, $response->body()); + $this->assertSame('xml', $response->body()->all()); + } + + public function testAResponseCanBeACustomResponseClass(): void + { + $mockClient = new MockClient([MockResponse::make(['foo' => 'bar'])]); + $request = new UserRequestWithCustomResponse; + + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertInstanceOf(UserResponse::class, $response); + $this->assertInstanceOf(UserData::class, $response->customCastMethod()); + $this->assertSame('bar', $response->foo()); + } + + public function testItCreatesJsonAndStringResponses(): void + { + $json = new FakeResponse(['name' => 'Taylor'], 201, ['X-Count' => 2]); + $text = new FakeResponse('complete'); + + $this->assertInstanceOf(JsonBodyRepository::class, $json->body()); + $this->assertSame('{"name":"Taylor"}', (string) $json->createPsrResponse()->getBody()); + $this->assertSame(201, $json->status()); + $this->assertSame(['2'], $json->createPsrResponse()->getHeader('X-Count')); + $this->assertInstanceOf(StringBodyRepository::class, $text->body()); + $this->assertSame('complete', (string) $text->createPsrResponse()->getBody()); + } + + public function testItResolvesConfiguredExceptionsAgainstThePendingRequest(): void + { + $pendingRequest = m::mock(PendingRequest::class); + $exception = new RuntimeException('Failed.'); + $response = (new FakeResponse)->throw( + fn (PendingRequest $request): RuntimeException => $request === $pendingRequest + ? $exception + : new RuntimeException('Wrong request.'), + ); + + $this->assertSame($exception, $response->getException($pendingRequest)); + $this->assertNull((new FakeResponse)->getException($pendingRequest)); + } +} diff --git a/tests/Saloon/Unit/MocksRequestsTest.php b/tests/Saloon/Unit/MocksRequestsTest.php new file mode 100644 index 0000000000..099e29e6bd --- /dev/null +++ b/tests/Saloon/Unit/MocksRequestsTest.php @@ -0,0 +1,85 @@ + 'Sam']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + + $responseA = $connector->send(new UserRequest); + $responseB = $connector->send(new UserRequest); + + $this->assertTrue($responseA->isMocked()); + $this->assertTrue($responseB->isMocked()); + } + + public function testYouCanProvideAMockClientOnARequestAndAllRequestsWillBeMocked(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $request = new UserRequest; + $request->withMockClient($mockClient); + + $response = (new TestConnector)->send($request); + + $this->assertTrue($response->isMocked()); + } + + public function testRequestMockClientsAreAlwaysPrioritized(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + ]); + + $mockClientB = new MockClient([ + MockResponse::make(['name' => 'Mantas']), + ]); + + $request = new UserRequest; + $request->withMockClient($mockClientB); + + $response = (new TestConnector)->send($request); + + $this->assertTrue($response->isMocked()); + $this->assertSame(['name' => 'Mantas'], $response->json()); + } + + public function testAMockClientPassedToSendTakesPrecedenceOverTheRequestClient(): void + { + $requestClient = new MockClient([MockResponse::make(['client' => 'request'])]); + $explicitClient = new MockClient([MockResponse::make(['client' => 'explicit'])]); + $request = (new UserRequest)->withMockClient($requestClient); + + $this->assertSame(['client' => 'explicit'], (new TestConnector)->send($request, $explicitClient)->json()); + $this->assertSame(['client' => 'request'], (new TestConnector)->send($request)->json()); + } +} diff --git a/tests/Saloon/Unit/MultipartValueTest.php b/tests/Saloon/Unit/MultipartValueTest.php new file mode 100644 index 0000000000..d82247c4f8 --- /dev/null +++ b/tests/Saloon/Unit/MultipartValueTest.php @@ -0,0 +1,79 @@ +assertSame($value, $multipartValue->value); + } finally { + if (is_resource($value)) { + fclose($value); + } + } + } + + /** + * Get the values a multipart value accepts. + * + * @return iterable + */ + public static function validValues(): iterable + { + yield 'stream' => [Utils::streamFor('hello')]; + yield 'resource' => [fopen(sprintf('data://text/plain,%s', 'hello'), 'rb')]; + yield 'string' => ['hello']; + yield 'integer' => [123]; + yield 'float' => [123.50]; + // Upstream rejects these. Hypervel accepts Guzzle's field values so withData() can add fields to a multipart body. + yield 'boolean' => [false]; + yield 'null' => [null]; + yield 'empty array' => [[]]; + yield 'array' => [['admin', 'profile' => ['city' => 'London']]]; + } + + #[DataProvider('invalidValues')] + public function testItWillThrowAnExceptionOnInvalidValues(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('The value property must be either a Psr\Http\Message\StreamInterface, resource, string, finite number, boolean, null, or array.'); + + new MultipartValue('test', $value); + } + + /** + * Get the values a multipart value rejects. + * + * @return iterable + */ + public static function invalidValues(): iterable + { + yield 'object' => [new UserRequest]; + yield 'infinite float' => [INF]; + } + + #[TestWith(['roles.txt', []])] + #[TestWith([null, ['X-Role' => 'admin']])] + public function testAnArrayValueCannotHaveAFilenameOrHeaders(?string $filename, array $headers): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs('An array value cannot have a filename or headers.'); + + new MultipartValue('roles', ['admin'], $filename, $headers); + } +} diff --git a/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php b/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php new file mode 100644 index 0000000000..3c918038c0 --- /dev/null +++ b/tests/Saloon/Unit/Oauth2/AccessTokenAuthenticatorTest.php @@ -0,0 +1,99 @@ +subMinutes(5)->toDateTimeImmutable(); + + $authenticator = new AccessTokenAuthenticator($accessToken, $refreshToken, $expiresAt); + + $this->assertTrue($authenticator->isRefreshable()); + $this->assertFalse($authenticator->isNotRefreshable()); + $this->assertTrue($authenticator->hasExpired()); + $this->assertFalse($authenticator->hasNotExpired()); + } + + public function testCanBeConstructedWithoutARefreshTokenOrExpiry(): void + { + $authenticator = new AccessTokenAuthenticator('access'); + + $this->assertSame('access', $authenticator->getAccessToken()); + $this->assertNull($authenticator->getRefreshToken()); + $this->assertNull($authenticator->getExpiresAt()); + $this->assertFalse($authenticator->isRefreshable()); + $this->assertTrue($authenticator->isNotRefreshable()); + } + + public function testCanBeConstructedWithJustAnAccessTokenAndExpiry(): void + { + $expiresAt = Date::now()->subMinutes(5)->toDateTimeImmutable(); + + $authenticator = new AccessTokenAuthenticator('access', null, $expiresAt); + + $this->assertTrue($authenticator->hasExpired()); + $this->assertFalse($authenticator->hasNotExpired()); + } + + public function testItAllowsExpiresInToBeOptional(): void + { + $authenticator = new AccessTokenAuthenticator('access', 'refresh', null); + + $this->assertNull($authenticator->getExpiresAt()); + $this->assertTrue($authenticator->isRefreshable()); + $this->assertFalse($authenticator->isNotRefreshable()); + } + + public function testItCanUseTheGlobalClockForExpiryChecks(): void + { + $expiresAt = new DateTimeImmutable('2026-01-01T01:00:00+00:00'); + + Date::setTestNow('2026-01-01T02:00:00+00:00'); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh', $expiresAt); + + $this->assertTrue($authenticator->hasExpired()); + $this->assertFalse($authenticator->hasNotExpired()); + } + + public function testTheAuthorizationHeaderUsesTheAccessTokenGetter(): void + { + $authenticator = new class('stored') extends AccessTokenAuthenticator { + /** + * Get the access token. + */ + public function getAccessToken(): string + { + return 'resolved'; + } + }; + + $pendingRequest = (new TestConnector)->createPendingRequest(UserRequest::make()->authenticate($authenticator)); + + $this->assertSame('Bearer resolved', $pendingRequest->headers()['Authorization']); + } +} diff --git a/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php new file mode 100644 index 0000000000..1e210bfb82 --- /dev/null +++ b/tests/Saloon/Unit/Oauth2/AuthCodeFlowConnectorTest.php @@ -0,0 +1,104 @@ +oauthConfig(); + + $this->assertInstanceOf(OAuthConfig::class, $config); + $this->assertSame('client-id', $config->clientId); + $this->assertSame('client-secret', $config->clientSecret); + $this->assertSame('https://my-app.saloon.dev/auth/callback', $config->redirectUri); + } + + public function testTheOauthConfigIsValidatedWhenGeneratingAnAuthorizationUrl(): void + { + $connector = new OAuth2Connector(['clientId' => '']); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $connector->authorizationUrl(); + } + + public function testTheOauthConfigIsValidatedWhenCreatingAccessTokens(): void + { + $connector = new OAuth2Connector(['clientId' => '']); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $connector->getAccessToken('code'); + } + + public function testTheOauthConfigIsValidatedWhenRefreshingAccessTokens(): void + { + $connector = new OAuth2Connector(['clientId' => '']); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $connector->refreshAccessToken(''); + } + + public function testTheOldRefreshTokenIsCarriedOverIfAResponseDoesNotIncludeANewRefreshToken(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $authenticator = new AccessTokenAuthenticator('access', 'refresh-old', Date::now()->addSeconds(3600)->toDateTimeImmutable()); + + $newAuthenticator = $connector->refreshAccessToken($authenticator); + + $this->assertSame('refresh-old', $newAuthenticator->getRefreshToken()); + } + + public function testTheOldRefreshTokenIsCarriedOverIfAResponseDoesNotIncludeANewRefreshTokenAndTheRefreshIsAString(): void + { + $mockClient = new MockClient([ + MockResponse::make(['access_token' => 'access-new', 'expires_in' => 3600]), + ]); + + $connector = new OAuth2Connector; + + Saloon::fake($mockClient); + + $newAuthenticator = $connector->refreshAccessToken('refresh-old'); + + $this->assertSame('refresh-old', $newAuthenticator->getRefreshToken()); + } +} diff --git a/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php new file mode 100644 index 0000000000..6e7edfbffa --- /dev/null +++ b/tests/Saloon/Unit/Oauth2/OAuthConfigTest.php @@ -0,0 +1,112 @@ +assertSame('', $config->redirectUri); + $this->assertSame('authorize', $config->authorizeEndpoint); + $this->assertSame('token', $config->tokenEndpoint); + $this->assertNull($config->refreshEndpoint); + $this->assertSame('user', $config->userEndpoint); + $this->assertSame([], $config->defaultScopes); + $this->assertNull($config->requestModifier); + $this->assertFalse($config->allowBaseUrlOverride); + + $config = new OAuthConfig( + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://my-app.saloon.dev/auth/callback', + authorizeEndpoint: 'auth/authorize', + tokenEndpoint: 'auth/token', + refreshEndpoint: 'auth/refresh', + userEndpoint: 'auth/user', + defaultScopes: ['profile'], + allowBaseUrlOverride: true, + ); + + $this->assertSame('client-id', $config->clientId); + $this->assertSame('client-secret', $config->clientSecret); + $this->assertSame('https://my-app.saloon.dev/auth/callback', $config->redirectUri); + $this->assertSame('auth/authorize', $config->authorizeEndpoint); + $this->assertSame('auth/token', $config->tokenEndpoint); + $this->assertSame('auth/refresh', $config->refreshEndpoint); + $this->assertSame('auth/user', $config->userEndpoint); + $this->assertSame(['profile'], $config->defaultScopes); + $this->assertTrue($config->allowBaseUrlOverride); + } + + // REMOVED: make method creates an instance of OAuthConfig. The configuration is built with its constructor. + + public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientId(): void + { + $config = new OAuthConfig('', 'client-secret', 'https://my-app.saloon.dev/auth/callback'); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client ID is empty or has not been provided.'); + + $config->validate(); + } + + public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheClientSecret(): void + { + $config = new OAuthConfig('client-id', '', 'https://my-app.saloon.dev/auth/callback'); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Client Secret is empty or has not been provided.'); + + $config->validate(); + } + + public function testItWillThrowAnExceptionIfYouDoNotSpecifyTheRedirectUri(): void + { + $config = new OAuthConfig('client-id', 'client-secret'); + + $this->expectException(OAuthConfigValidationException::class); + $this->expectExceptionMessageIs('The Redirect URI is empty or has not been provided.'); + + $config->validate(); + } + + public function testValidationAcceptsNonEmptyValuesAndOptionalRedirectUri(): void + { + $this->assertTrue((new OAuthConfig('0', '0', '0'))->validate()); + $this->assertTrue((new OAuthConfig('client-id', 'client-secret'))->validate(withRedirectUrl: false)); + } + + public function testTheRequestModifierChangesARequest(): void + { + $config = new OAuthConfig( + clientId: 'client-id', + clientSecret: 'client-secret', + requestModifier: fn (Request $request): Request => $request->withHeader('X-OAuth', 'configured'), + ); + $request = new UserRequest; + + $this->assertSame($request, $config->modify($request)); + $this->assertSame(['X-OAuth' => 'configured'], $request->headers()); + } + + public function testAuthorizationUrlKeepsTheUrlAndStateTogether(): void + { + $authorization = new AuthorizationUrl('https://provider.example.com/authorize', 'state'); + + $this->assertSame('https://provider.example.com/authorize', (string) $authorization); + $this->assertSame('state', $authorization->state); + } +} diff --git a/tests/Saloon/Http/OAuthRequestTest.php b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php similarity index 61% rename from tests/Saloon/Http/OAuthRequestTest.php rename to tests/Saloon/Unit/Oauth2/OAuthRequestTest.php index c1d24b2308..9510bda196 100644 --- a/tests/Saloon/Http/OAuthRequestTest.php +++ b/tests/Saloon/Unit/Oauth2/OAuthRequestTest.php @@ -2,21 +2,17 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit\Oauth2; -use Hypervel\Contracts\Cache\Factory as CacheFactory; -use Hypervel\RateLimiter\RateLimiter; use Hypervel\Saloon\Data\OAuthConfig; -use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\OAuth2\GetAccessTokenRequest; use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenBasicAuthRequest; use Hypervel\Saloon\Http\OAuth2\GetClientCredentialsTokenRequest; use Hypervel\Saloon\Http\OAuth2\GetRefreshTokenRequest; -use Hypervel\Saloon\Http\OAuth2\GetUserRequest; use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Request; +use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; use Hypervel\Tests\TestCase; -use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; class OAuthRequestTest extends TestCase @@ -24,7 +20,7 @@ class OAuthRequestTest extends TestCase #[DataProvider('requestBodies')] public function testTokenRequestsUseTheExpectedFormBody(Request $request, array $expected): void { - $pendingRequest = $this->pendingRequest($request) + $pendingRequest = (new PendingRequest(new TestConnector('https://api.example.com'), $request)) ->bootPlugins() ->applyAuthentication() ->finalizeUri() @@ -34,6 +30,11 @@ public function testTokenRequestsUseTheExpectedFormBody(Request $request, array $this->assertSame('application/x-www-form-urlencoded', $pendingRequest->headers()['Content-Type']); } + /** + * Get each OAuth token request with its expected form body. + * + * @return array}> + */ public static function requestBodies(): array { $config = static::config(); @@ -78,32 +79,6 @@ public static function requestBodies(): array ]; } - public function testBasicAuthRequestKeepsCredentialsOutOfTheBody(): void - { - $pendingRequest = $this->pendingRequest( - new GetClientCredentialsTokenBasicAuthRequest(static::config()), - )->applyAuthentication(); - - $this->assertSame(['client', 'secret'], $pendingRequest->transportAuthentication()); - } - - public function testTrustedAbsoluteOAuthEndpointsUseTheSharedUrlPolicy(): void - { - $config = new OAuthConfig( - 'client', - 'secret', - tokenEndpoint: 'https://oauth.example.net/token', - userEndpoint: 'https://oauth.example.net/user', - allowBaseUrlOverride: true, - ); - - $token = $this->pendingRequest(new GetClientCredentialsTokenRequest($config))->finalizeUri(); - $user = $this->pendingRequest(new GetUserRequest($config))->finalizeUri(); - - $this->assertSame('https://oauth.example.net/token', (string) $token->uri()); - $this->assertSame('https://oauth.example.net/user', (string) $user->uri()); - } - /** * Create the OAuth configuration used by request tests. */ @@ -116,25 +91,4 @@ protected static function config(): OAuthConfig defaultScopes: ['openid'], ); } - - /** - * Create a pending request with isolated framework dependencies. - */ - protected function pendingRequest(Request $request): PendingRequest - { - return new PendingRequest( - new OAuthConnectorStub, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); - } -} - -class OAuthConnectorStub extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.example.com'; - } } diff --git a/tests/Saloon/Http/PendingRequestTest.php b/tests/Saloon/Unit/PendingRequestTest.php similarity index 56% rename from tests/Saloon/Http/PendingRequestTest.php rename to tests/Saloon/Unit/PendingRequestTest.php index e67ed8da4c..2ac2d16022 100644 --- a/tests/Saloon/Http/PendingRequestTest.php +++ b/tests/Saloon/Unit/PendingRequestTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; -use Hypervel\Contracts\Cache\Factory as CacheFactory; -use Hypervel\RateLimiter\RateLimiter; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Http\Client\Request as HttpRequest; use Hypervel\Saloon\Contracts\Body\BodyRepository; use Hypervel\Saloon\Enums\Method; +use Hypervel\Saloon\Events\SendingSaloonRequest; +use Hypervel\Saloon\Exceptions\InvalidHeaderException; use Hypervel\Saloon\Exceptions\MissingAuthenticatorException; use Hypervel\Saloon\Exceptions\PendingRequestException; use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; @@ -19,16 +21,157 @@ use Hypervel\Saloon\Http\PendingRequest; use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Repositories\Body\StringBodyRepository; +use Hypervel\Saloon\SaloonServiceProvider; use Hypervel\Saloon\Traits\Auth\RequiresAuth; use Hypervel\Saloon\Traits\Body\HasJsonBody; use Hypervel\Saloon\Traits\Body\HasStringBody; -use Hypervel\Tests\TestCase; -use Mockery as m; +use Hypervel\Support\Facades\Event; +use Hypervel\Support\Facades\Http; +use Hypervel\Support\Stringable; +use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; +use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest; use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\StreamInterface; +use stdClass; class PendingRequestTest extends TestCase { + public function testYouCanOverwriteTheUrlAndTheMethodOfThePendingRequest(): void + { + Http::fake(['*' => Http::response(['name' => 'Sam'])]); + + $connector = new TestConnector; + $request = new UserRequest; + + // Connectors are read-only, so the middleware is registered on the request instead of the connector. + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + $pendingRequest->withUrl('https://other-endpoint.co.uk' . $pendingRequest->request()->resolveEndpoint()); + $pendingRequest->withMethod(Method::POST); + }); + + $this->assertSame(Method::GET, $request->method()); + + $response = $connector->send($request); + $pendingRequest = $response->pendingRequest(); + + $this->assertSame('https://other-endpoint.co.uk/user', (string) $pendingRequest->uri()); + $this->assertSame(Method::POST, $pendingRequest->method()); + $this->assertSame(Method::GET, $request->method()); + $this->assertNull($request->url()); + Http::assertSent(fn (HttpRequest $sent): bool => $sent->method() === 'POST' + && $sent->url() === 'https://other-endpoint.co.uk/user'); + } + + public function testThePendingRequestIsMacroable(): void + { + PendingRequest::macro('yee', fn (): string => 'haw'); + + $pendingRequest = (new TestConnector)->createPendingRequest(new UserRequest); + + $this->assertSame('haw', $pendingRequest->yee()); + } + + public function testThePendingRequestValidatesProperlyFormedHeaders(): void + { + $request = (new UserRequest)->withHeaders([ + 'Content-Type: application/json', + ]); + + $this->expectException(InvalidHeaderException::class); + $this->expectExceptionMessageIs('One or more of the headers are invalid. Make sure to use the header name as the key. For example: [\'Content-Type\' => \'application/json\'].'); + + (new TestConnector)->createPendingRequest($request); + } + + public function testHeadersWrittenAsAListAreRejectedBeforePluginsMatchHeaderNames(): void + { + $request = (new PendingRequestRequestStub)->withHeaders(['Accept: application/json']); + + $this->expectException(InvalidHeaderException::class); + + (new PendingRequestConnectorStub)->createPendingRequest($request); + } + + public function testHeaderValuesAreNormalizedForTheOutgoingRequest(): void + { + $pendingRequest = (new TestConnector)->createPendingRequest((new UserRequest)->withHeaders([ + 'X-Null' => null, + 'X-Empty' => [], + 'X-List' => ['one', 2, true], + 'X-Stringable' => new Stringable('value'), + 'X-Float' => 1.5, + 'X-Not-A-Number' => NAN, + 'X-Infinite' => -INF, + ])); + + $headers = $pendingRequest->toPsrRequest()->getHeaders(); + + $this->assertSame([''], $headers['X-Null']); + $this->assertSame([''], $headers['X-Empty']); + $this->assertSame(['one', '2', '1'], $headers['X-List']); + $this->assertSame(['value'], $headers['X-Stringable']); + $this->assertSame(['1.5'], $headers['X-Float']); + $this->assertSame(['NAN'], $headers['X-Not-A-Number']); + $this->assertSame(['-INF'], $headers['X-Infinite']); + } + + public function testHeaderValuesMustBeScalarNullOrStringable(): void + { + $pendingRequest = (new TestConnector)->createPendingRequest( + (new UserRequest)->withHeader('X-Object', new stdClass), + ); + + $this->expectException(InvalidHeaderException::class); + $this->expectExceptionMessageIs('HTTP header values must be scalar, null, Hypervel Stringable, or arrays of those values.'); + + $pendingRequest->toPsrRequest(); + } + + public function testPreparingARequestRunsItsHooksAndMiddlewareWithoutSendingIt(): void + { + Http::fake(); + Event::fake([SendingSaloonRequest::class]); + + $request = (new PendingRequestRequestStub)->withData(['name' => 'Taylor']); + $request->middleware()->onRequest(function (PendingRequest $pendingRequest): void { + ++PendingRequestRequestStub::$middlewareCalls; + }); + + $pendingRequest = (new PendingRequestConnectorStub)->createPendingRequest($request); + + $this->assertSame(1, PendingRequestConnectorStub::$bootCalls); + $this->assertSame(1, PendingRequestRequestStub::$bootCalls); + $this->assertSame(1, PendingRequestRequestStub::$middlewareCalls); + $this->assertSame('application/json', $pendingRequest->headers()['Content-Type']); + $this->assertNull($pendingRequest->preparedBody()); + Event::assertNotDispatched(SendingSaloonRequest::class); + Http::assertNothingSent(); + } + + public function testUrlParametersAreExpandedWithoutChangingTheRequest(): void + { + $request = (new PendingRequestRequestStub) + ->withUrl('https://{region}.example.com/{version}/users') + ->withUrlParameters(['region' => 'eu', 'version' => 'v1']); + $pendingRequest = new PendingRequest(new PendingRequestConnectorWithoutBodyStub, $request); + + $this->assertSame('https://eu.example.com/v1/users', (string) $pendingRequest->finalizeUri()->uri()); + + $pendingRequest->withUrlParameters(['version' => 'v2']); + + $this->assertSame('https://eu.example.com/v2/users', (string) $pendingRequest->uri()); + $this->assertSame(['region' => 'eu', 'version' => 'v1'], $request->urlParameters()); + } + + public function testUrlParametersAreExpandedInTheBaseUrlAndEndpoint(): void + { + $request = (new PendingTemplateRequestStub)->withUrlParameters(['tenant' => 'acme', 'id' => 'a b/c']); + $pendingRequest = new PendingRequest(new PendingTemplateConnectorStub, $request); + + $this->assertSame('https://acme.example.com/v1/users/a%20b%2Fc', (string) $pendingRequest->uri()); + } + public function testConstructionOnlySnapshotsOperationState(): void { $connector = new PendingRequestConnectorStub; @@ -42,7 +185,7 @@ public function testConstructionOnlySnapshotsOperationState(): void ++PendingRequestRequestStub::$middlewareCalls; }); - $pendingRequest = $this->pendingRequest($connector, $request); + $pendingRequest = new PendingRequest($connector, $request); $this->assertSame(0, PendingRequestConnectorStub::$bootCalls); $this->assertSame(0, PendingRequestRequestStub::$bootCalls); @@ -65,9 +208,9 @@ public function testUriAndBodyAreFinalizedAfterRequestMiddleware(): void ->withQueryParameters(['page' => 3]) ->withData(['middleware' => true]); }); - $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request); - - $pendingRequest->executeRequestPipeline()->finalizeUri()->prepareBody(); + $pendingRequest = (new PendingRequestConnectorStub)->createPendingRequest($request) + ->finalizeUri() + ->prepareBody(); $this->assertSame('https://api.example.com/v1/users?version=1&page=3', (string) $pendingRequest->uri()); $this->assertSame( @@ -79,7 +222,7 @@ public function testUriAndBodyAreFinalizedAfterRequestMiddleware(): void public function testRawQuerySnapshotsDefaultsAndReplacesBothUrlQueries(): void { $request = new PendingRawQueryRequestStub; - $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, $request); + $pendingRequest = new PendingRequest(new PendingRawQueryConnectorStub, $request); $request->withQueryString('changed=after-snapshot'); $pendingRequest->finalizeUri(); @@ -99,7 +242,7 @@ public function testRawQueryReplacementRetainsArrayAndAuthenticationOverlays(): $request = (new PendingRawQueryRequestStub) ->withQueryString('tag=a&tag=b&token=old&token=older') ->withQueryParameters(['limit' => 10]); - $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request); + $pendingRequest = new PendingRequest(new PendingRequestConnectorStub, $request); $pendingRequest->authenticate(new QueryAuthenticator('token', 'secret'))->finalizeUri(); @@ -114,7 +257,7 @@ public function testRawQueryReplacementRetainsArrayAndAuthenticationOverlays(): public function testNullRawQueryKeepsTheOriginalUrlQuery(): void { - $pendingRequest = $this->pendingRequest(new PendingRawQueryConnectorStub, new PendingRequestRequestStub); + $pendingRequest = new PendingRequest(new PendingRawQueryConnectorStub, new PendingRequestRequestStub); $this->assertNull($pendingRequest->queryString()); $this->assertSame('base=old', $pendingRequest->uri()->getQuery()); @@ -125,7 +268,7 @@ public function testFullUrlOverrideIsVisibleToAuthenticationAndKeepsQueryOverlay $request = (new PendingRequestRequestStub) ->withUrl('https://uploads.example.com/files?tag=a&tag=b&token=old') ->withQueryParameters(['limit' => 10]); - $pendingRequest = $this->pendingRequest(new PendingRequestConnectorStub, $request); + $pendingRequest = new PendingRequest(new PendingRequestConnectorStub, $request); $pendingRequest ->authenticate(new CookieAuthenticator('session', 'secret')) @@ -144,7 +287,7 @@ public function testFullUrlOverrideIsVisibleToAuthenticationAndKeepsQueryOverlay #[DataProvider('invalidFullUrls')] public function testFullUrlOverrideRequiresAnAbsoluteHttpUrl(string $url): void { - $pendingRequest = $this->pendingRequest( + $pendingRequest = new PendingRequest( new PendingRequestConnectorStub, (new PendingRequestRequestStub)->withUrl($url), ); @@ -167,13 +310,13 @@ public function testConnectorAndRequestBodyTypesMustMatch(): void $this->expectException(PendingRequestException::class); $this->expectExceptionMessageIs('Connector and request body types must be the same.'); - $this->pendingRequest(new PendingRequestConnectorStub, new PendingRequestStringBodyStub); + new PendingRequest(new PendingRequestConnectorStub, new PendingRequestStringBodyStub); } public function testLogicalPsrRequestReusesAnAlreadyPreparedBody(): void { CountingBodyRepository::$streamCalls = 0; - $pendingRequest = $this->pendingRequest( + $pendingRequest = new PendingRequest( new PendingRequestConnectorWithoutBodyStub, new PendingRequestCountingBodyStub, ); @@ -187,7 +330,7 @@ public function testLogicalPsrRequestReusesAnAlreadyPreparedBody(): void public function testRequiresAuthRetainsItsProtectedMessageHook(): void { - $pendingRequest = $this->pendingRequest( + $pendingRequest = new PendingRequest( new PendingRequestConnectorWithoutBodyStub, new CustomRequiresAuthRequestStub, ); @@ -200,7 +343,7 @@ public function testRequiresAuthRetainsItsProtectedMessageHook(): void public function testAuthenticatorsReplaceLogicalHeadersRegardlessOfCase(): void { - $pendingRequest = $this->pendingRequest( + $pendingRequest = new PendingRequest( new PendingRequestConnectorWithoutBodyStub, new PendingRequestRequestStub, ); @@ -222,6 +365,17 @@ public function testAuthenticatorsReplaceLogicalHeadersRegardlessOfCase(): void ], $pendingRequest->headers()); } + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return [SaloonServiceProvider::class]; + } + + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); @@ -230,19 +384,6 @@ protected function setUp(): void PendingRequestRequestStub::$bootCalls = 0; PendingRequestRequestStub::$middlewareCalls = 0; } - - /** - * Create a pending request with isolated framework dependencies. - */ - protected function pendingRequest(Connector $connector, Request $request): PendingRequest - { - return new PendingRequest( - $connector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); - } } class PendingRequestConnectorStub extends Connector @@ -251,31 +392,49 @@ class PendingRequestConnectorStub extends Connector public static int $bootCalls = 0; + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com/v1'; } + /** + * Configure a pending request for this resource. + */ public function boot(PendingRequest $pendingRequest): void { ++static::$bootCalls; } + /** + * Resolve the default headers. + */ protected function defaultHeaders(): array { return ['X-Connector' => 'connector']; } + /** + * Resolve the default query parameters. + */ protected function defaultQuery(): array { return ['version' => 1]; } + /** + * Resolve the default request options. + */ protected function defaultOptions(): array { return ['allow_redirects' => ['strict' => true]]; } + /** + * Resolve the default JSON body. + */ protected function defaultBody(): array { return ['connector' => true]; @@ -293,6 +452,30 @@ public function resolveBaseUrl(): string } } +class PendingTemplateConnectorStub extends Connector +{ + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://{tenant}.example.com/v1'; + } +} + +class PendingTemplateRequestStub extends Request +{ + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users/{id}'; + } +} + class PendingRawQueryRequestStub extends Request { protected Method $method = Method::GET; @@ -324,11 +507,17 @@ class PendingRequestRequestStub extends Request protected Method $method = Method::POST; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; } + /** + * Configure a pending request for this resource. + */ public function boot(PendingRequest $pendingRequest): void { ++static::$bootCalls; @@ -341,6 +530,9 @@ class PendingRequestStringBodyStub extends Request protected Method $method = Method::POST; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; @@ -349,6 +541,9 @@ public function resolveEndpoint(): string class PendingRequestConnectorWithoutBodyStub extends Connector { + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; @@ -359,11 +554,17 @@ class PendingRequestCountingBodyStub extends Request { protected Method $method = Method::POST; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; } + /** + * Resolve the default body repository. + */ protected function defaultBodyRepository(): ?BodyRepository { return new CountingBodyRepository('prepared'); @@ -374,6 +575,9 @@ class CountingBodyRepository extends StringBodyRepository { public static int $streamCalls = 0; + /** + * Convert the body repository into a stream. + */ public function toStream(): StreamInterface { ++static::$streamCalls; @@ -388,11 +592,17 @@ class CustomRequiresAuthRequestStub extends Request protected Method $method = Method::GET; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; } + /** + * Get the missing authenticator message. + */ protected function getRequiresAuthMessage(PendingRequest $pendingRequest): string { return 'Custom authentication is required.'; diff --git a/tests/Saloon/Http/PipelineTest.php b/tests/Saloon/Unit/PipelineTest.php similarity index 81% rename from tests/Saloon/Http/PipelineTest.php rename to tests/Saloon/Unit/PipelineTest.php index a5bf181807..253a1d2f9a 100644 --- a/tests/Saloon/Http/PipelineTest.php +++ b/tests/Saloon/Unit/PipelineTest.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; +use Hypervel\Saloon\Data\Pipe; use Hypervel\Saloon\Enums\PipeOrder; use Hypervel\Saloon\Exceptions\DuplicatePipeNameException; use Hypervel\Saloon\Http\Pipeline; @@ -11,7 +12,7 @@ class PipelineTest extends TestCase { - public function testPipelineCanBeExecuted(): void + public function testAPipelineCanBeExecuted(): void { $pipeline = (new Pipeline) ->pipe(fn (int $number): int => $number + 5) @@ -26,8 +27,8 @@ public function testPipesAreStoredInExecutionOrder(): void { $pipeline = (new Pipeline) ->pipe(fn (array $values): array => [...$values, 'default-one'], 'default-one') - ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::Last) - ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::First) + ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::LAST) + ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::FIRST) ->pipe(fn (array $values): array => [...$values, 'default-two'], 'default-two'); $this->assertSame( @@ -36,24 +37,24 @@ public function testPipesAreStoredInExecutionOrder(): void ); $this->assertSame( ['first', 'default-one', 'default-two', 'last'], - array_map(static fn ($pipe): ?string => $pipe->name, $pipeline->pipes()), + array_map(static fn (Pipe $pipe): ?string => $pipe->name, $pipeline->pipes()), ); } public function testDuplicateNamedPipeIsRejectedAcrossBuckets(): void { - $pipeline = (new Pipeline)->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::First); + $pipeline = (new Pipeline)->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::FIRST); $this->expectException(DuplicatePipeNameException::class); - $pipeline->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::Last); + $pipeline->pipe(fn (mixed $payload): mixed => $payload, 'duplicate', PipeOrder::LAST); } public function testPipesCanBeCopiedIntoAnotherPipeline(): void { $source = (new Pipeline) - ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::Last) - ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::First); + ->pipe(fn (array $values): array => [...$values, 'last'], 'last', PipeOrder::LAST) + ->pipe(fn (array $values): array => [...$values, 'first'], 'first', PipeOrder::FIRST); $target = (new Pipeline)->setPipes($source->pipes()); diff --git a/tests/Saloon/Unit/PluginTest.php b/tests/Saloon/Unit/PluginTest.php new file mode 100644 index 0000000000..56e048c742 --- /dev/null +++ b/tests/Saloon/Unit/PluginTest.php @@ -0,0 +1,45 @@ +createPendingRequest($request); + $headers = $pendingRequest->headers(); + + $this->assertSame(1, $headers['X-Plugin-User-Id']); + $this->assertSame(2, $headers['X-Plugin-Group-Id']); + } + + public function testSubRequestDoesNotNeedToUsePlugins(): void + { + $request = new SubRequest(1, 2); + + $pendingRequest = (new TestConnector)->createPendingRequest($request); + $headers = $pendingRequest->headers(); + + $this->assertSame(1, $headers['X-Plugin-User-Id']); + $this->assertSame(2, $headers['X-Plugin-Group-Id']); + } +} diff --git a/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php b/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php new file mode 100644 index 0000000000..8dd9c81fc2 --- /dev/null +++ b/tests/Saloon/Unit/Plugins/AlwaysThrowOnErrorsPluginTest.php @@ -0,0 +1,35 @@ +expectException(ServerException::class); + + (new TestConnector)->send(new AlwaysThrowRequest, new MockClient([ + MockResponse::make(['message' => 'Server Error'], 500), + ])); + } +} diff --git a/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php b/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php new file mode 100644 index 0000000000..4d562b0d35 --- /dev/null +++ b/tests/Saloon/Unit/Plugins/CastsToDtoPluginTest.php @@ -0,0 +1,98 @@ + 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']), + ]); + + $response = (new TestConnector)->send(new DTORequest, $mockClient); + $dto = $response->dto(); + $json = $response->json(); + + $this->assertTrue($response->isMocked()); + $this->assertInstanceOf(User::class, $dto); + $this->assertSame($json['name'], $dto->name); + $this->assertSame($json['actual_name'], $dto->actualName); + $this->assertSame($json['twitter'], $dto->twitter); + } + + public function testItCanCastToADtoThatIsDefinedOnAConnector(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']), + ]); + + $connector = new DtoConnector; + + $response = $connector->send(new UserRequest, $mockClient); + $dto = $response->dto(); + + $this->assertInstanceOf(ApiResponse::class, $dto); + $this->assertSame($response->json(), $dto->data); + } + + public function testTheRequestDtoWillBeReturnedAsAHigherPriorityThanTheConnectorDto(): void + { + $mockClient = new MockClient([ + new MockResponse(['name' => 'Sammyjo20', 'actual_name' => 'Sam Carré', 'twitter' => '@carre_sam']), + ]); + + $connector = new DtoConnector; + + $response = $connector->send(new DTORequest, $mockClient); + $dto = $response->dto(); + $json = $response->json(); + + $this->assertInstanceOf(User::class, $dto); + $this->assertSame($json['name'], $dto->name); + $this->assertSame($json['actual_name'], $dto->actualName); + $this->assertSame($json['twitter'], $dto->twitter); + } + + public function testYouCanUseTheDtoOrFailMethodToThrowAnExceptionIfTheResponseHasFailed(): void + { + $mockClient = new MockClient([ + new MockResponse(['message' => 'Server Error'], 500), + ]); + + $response = (new TestConnector)->send(new DTORequest, $mockClient); + + try { + $response->dtoOrFail(); + + $this->fail('A failed response was converted into a data transfer object.'); + } catch (LogicException $exception) { + $this->assertSame('Unable to create data transfer object as the response has failed.', $exception->getMessage()); + $this->assertInstanceOf(InternalServerErrorException::class, $exception->getPrevious()); + } + } +} diff --git a/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php b/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php new file mode 100644 index 0000000000..4d313a6e7a --- /dev/null +++ b/tests/Saloon/Unit/Plugins/HasTimeoutPluginTest.php @@ -0,0 +1,208 @@ +captureOptions(); + + // Hypervel's defaults come from the Saloon HTTP connection rather than the plugin. + (new TestConnector)->send(UserRequest::make()); + + $this->assertSame(10, $options->value['connect_timeout']); + $this->assertSame(30, $options->value['timeout']); + } + + public function testARequestCanSetATimeoutAndConnectTimeout(): void + { + $request = new TimeoutRequest; + $pendingRequest = (new TestConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $options = $pendingRequest->options(); + + $this->assertSame(1.0, $options['connect_timeout']); + $this->assertSame(2.0, $options['timeout']); + } + + public function testAConnectorIsGivenADefaultTimeoutAndConnectTimeout(): void + { + $connector = new TimeoutConnector; + + $pendingRequest = $connector + ->send(new UserRequest, new MockClient([MockResponse::make()])) + ->pendingRequest(); + + $options = $pendingRequest->options(); + + $this->assertSame(10.0, $options['connect_timeout']); + $this->assertSame(5.0, $options['timeout']); + + $transportOptions = $this->captureOptions(); + + $connector->send(new UserRequest); + + $this->assertSame(10.0, $transportOptions->value['connect_timeout']); + $this->assertSame(5.0, $transportOptions->value['timeout']); + } + + public function testRequestOptionsOverrideTheConnectorTimeouts(): void + { + $request = (new UserRequest)->timeout(1)->connectTimeout(0); + + $options = (new TimeoutConnector) + ->send($request, new MockClient([MockResponse::make()])) + ->pendingRequest() + ->options(); + + $this->assertSame(0, $options['connect_timeout']); + $this->assertSame(1, $options['timeout']); + } + + public function testRequestOptionsOverrideTheRequestTimeouts(): void + { + $options = (new TestConnector) + ->send((new TimeoutRequest)->timeout(0), new MockClient([MockResponse::make()])) + ->pendingRequest() + ->options(); + + $this->assertSame(1.0, $options['connect_timeout']); + $this->assertSame(0, $options['timeout']); + } + + public function testRequestTimeoutsOverrideTheConnectorTimeouts(): void + { + $options = (new TimeoutConnector) + ->send(new TimeoutRequest, new MockClient([MockResponse::make()])) + ->pendingRequest() + ->options(); + + $this->assertSame(1.0, $options['connect_timeout']); + $this->assertSame(2.0, $options['timeout']); + } + + public function testConnectorOptionsOverrideOnlyTheConnectorTimeouts(): void + { + $connector = new ConfiguredTimeoutConnectorStub; + + $connectorOptions = $connector + ->send(new UserRequest, new MockClient([MockResponse::make()])) + ->pendingRequest() + ->options(); + $requestOptions = $connector + ->send(new TimeoutRequest, new MockClient([MockResponse::make()])) + ->pendingRequest() + ->options(); + + $this->assertSame(20, $connectorOptions['timeout']); + $this->assertSame(2.0, $requestOptions['timeout']); + } + + public function testUndeclaredTimeoutsKeepTheHttpConnectionDefaults(): void + { + $this->app->make(Factory::class)->registerConnection('slow', ['connect_timeout' => 15, 'timeout' => 90]); + $options = $this->captureOptions(); + + $pendingRequest = (new ConnectionTimeoutConnectorStub)->send(new UserRequest)->pendingRequest(); + + $this->assertSame([], $pendingRequest->options()); + $this->assertSame(15, $options->value['connect_timeout']); + $this->assertSame(90, $options->value['timeout']); + } + + public function testTheTimeoutGettersMayBeOverridden(): void + { + $this->app->make(Factory::class)->registerConnection('slow', ['connect_timeout' => 15, 'timeout' => 90]); + $options = $this->captureOptions(); + + (new OverriddenTimeoutConnectorStub)->send(new UserRequest); + + $this->assertSame(15, $options->value['connect_timeout']); + $this->assertSame(12.5, $options->value['timeout']); + } + + /** + * Capture the transport options of the next faked request. + * + * @return object{value: array} + */ + protected function captureOptions(): object + { + $captured = new class { + public array $value = []; + }; + + Http::fake(function (HttpRequest $request, array $options) use ($captured): PromiseInterface { + $captured->value = $options; + + return Http::response(); + }); + + return $captured; + } +} + +class ConnectionTimeoutConnectorStub extends TestConnector +{ + use HasTimeout; + + /** + * Resolve the HTTP connection used by this connector. + */ + public function resolveHttpConnection(): ?string + { + return 'slow'; + } +} + +class OverriddenTimeoutConnectorStub extends ConnectionTimeoutConnectorStub +{ + /** + * Get the request timeout. + */ + public function getRequestTimeout(): ?float + { + return 12.5; + } +} + +class ConfiguredTimeoutConnectorStub extends TimeoutConnector +{ + /** + * Resolve the default request options. + * + * @return array + */ + protected function defaultOptions(): array + { + return ['timeout' => 20]; + } +} diff --git a/tests/Saloon/Http/PoolNonCoroutineTest.php b/tests/Saloon/Unit/PoolNonCoroutineTest.php similarity index 89% rename from tests/Saloon/Http/PoolNonCoroutineTest.php rename to tests/Saloon/Unit/PoolNonCoroutineTest.php index ed6bf6ef65..483f8a8cce 100644 --- a/tests/Saloon/Http/PoolNonCoroutineTest.php +++ b/tests/Saloon/Unit/PoolNonCoroutineTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Config\Repository as ConfigRepository; @@ -64,15 +64,24 @@ protected function manager(): SaloonManager class PoolNonCoroutineConnectorStub extends Connector { + /** + * Create a connector that sends through the given manager. + */ public function __construct(protected SaloonManager $manager) { } + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Send a request through the isolated manager. + */ public function send(Request $request, ?MockClient $mockClient = null): Response { return $this->manager->send($this, $request, $mockClient); @@ -83,6 +92,9 @@ class PoolNonCoroutineRequestStub extends Request { protected Method $method = Method::GET; + /** + * Define the endpoint for the request. + */ public function resolveEndpoint(): string { return '/users'; diff --git a/tests/Saloon/Http/PoolTest.php b/tests/Saloon/Unit/PoolTest.php similarity index 56% rename from tests/Saloon/Http/PoolTest.php rename to tests/Saloon/Unit/PoolTest.php index 056d56bda0..07c3ae526b 100644 --- a/tests/Saloon/Http/PoolTest.php +++ b/tests/Saloon/Unit/PoolTest.php @@ -2,15 +2,18 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; use DateInterval; use DateTimeInterface; +use Generator; +use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Cache\ArrayStore; use Hypervel\Cache\Repository; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ChildCancellationException; use Hypervel\Engine\Channel; @@ -28,6 +31,7 @@ use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Exceptions\InvalidPoolItemException; use Hypervel\Saloon\Exceptions\PoolException; +use Hypervel\Saloon\Facades\Saloon; use Hypervel\Saloon\Http\Connector; use Hypervel\Saloon\Http\Faking\MockClient; use Hypervel\Saloon\Http\Faking\MockResponse; @@ -37,7 +41,11 @@ use Hypervel\Saloon\Http\Sender; use Hypervel\Saloon\RateLimit\Traits\HasRateLimits; use Hypervel\Saloon\SaloonManager; -use Hypervel\Tests\TestCase; +use Hypervel\Saloon\SaloonServiceProvider; +use Hypervel\Support\Collection; +use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; +use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest; use InvalidArgumentException; use Mockery as m; use RuntimeException; @@ -46,8 +54,386 @@ use function Hypervel\Coroutine\parallel; +// Pools send through coroutines, so send() returns the successful responses instead of a promise to wait on, and +// handlers record what they receive for assertions afterwards. Connectors take no mock client, so the global fake +// stands in for upstream's connector client. +// REMOVED: Unit/AsyncRequestTest - sendAsync() is not ported. Its success, error-response and connection-error cases +// map to Feature/PoolTest, and its then() chaining cases only test promises. class PoolTest extends TestCase { + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return [SaloonServiceProvider::class]; + } + + public function testAcceptsAnArrayForRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = [ + new UserRequest, + new UserRequest, + new UserRequest, + ]; + + $pool = $connector->pool($requests); + + $pool->setConcurrency(5); + + $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void { + $handled[$index] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertSame($requests, $handled); + } + + public function testAcceptsAnArrayForAliasedRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = [ + 'a' => new UserRequest, + 'b' => new UserRequest, + 'c' => new UserRequest, + ]; + + $pool = $connector->pool($requests); + + $pool->setConcurrency(5); + + $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void { + $handled[$name] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertSame($requests, $handled); + } + + public function testAcceptsAGeneratorForRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = new Collection; + + $generatorCallback = function () use ($requests): Generator { + for ($i = 0; $i < 3; ++$i) { + $request = new UserRequest; + $requests->put($i, $request); + + yield $i => $request; + } + }; + + $this->assertIsCallable($generatorCallback); + $this->assertInstanceOf(Generator::class, $generatorCallback()); + + $pool = $connector->pool($generatorCallback()); + $pool->setConcurrency(5); + $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void { + $handled[$index] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertCount(3, $handled); + $this->assertSame($requests->all(), $handled); + } + + public function testAcceptsAGeneratorForAliasedRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = new Collection; + + $generatorCallback = function () use ($requests): Generator { + foreach (['a', 'b', 'c'] as $name) { + $request = new UserRequest; + $requests->put($name, $request); + + yield $name => $request; + } + }; + + $this->assertIsCallable($generatorCallback); + $this->assertInstanceOf(Generator::class, $generatorCallback()); + + $pool = $connector->pool($generatorCallback()); + $pool->setConcurrency(5); + $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void { + $handled[$name] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertSame(['a', 'b', 'c'], $requests->keys()->all()); + $this->assertSame($requests->all(), $handled); + } + + // Request callbacks run when the pool is sent rather than when it is created, so handlers read the requests the + // callback builds by reference. + public function testAcceptsACallbackThatReturnsAnArrayForRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = new Collection; + + $arrayCallback = function () use (&$requests): array { + $requests = $requests->merge([ + new UserRequest, + new UserRequest, + new UserRequest, + ]); + + return $requests->all(); + }; + + $this->assertIsCallable($arrayCallback); + $this->assertIsArray($requests->all()); + + $pool = $connector->pool($arrayCallback); + + $pool->setConcurrency(5); + + $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void { + $handled[$index] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertCount(3, $handled); + $this->assertSame($requests->all(), $handled); + } + + public function testAcceptsACallbackThatReturnsAnArrayForAliasedRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + $callbackConnector = null; + + $requests = new Collection; + + $arrayCallback = function (Connector $received) use (&$requests, &$callbackConnector): array { + $callbackConnector = $received; + + $requests = $requests->merge([ + 'a' => new UserRequest, + 'b' => new UserRequest, + 'c' => new UserRequest, + ]); + + return $requests->all(); + }; + + $this->assertIsCallable($arrayCallback); + $this->assertIsArray($requests->all()); + + $pool = $connector->pool($arrayCallback); + $pool->setConcurrency(5); + $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void { + $handled[$name] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertSame($connector, $callbackConnector); + $this->assertSame(['a', 'b', 'c'], $requests->keys()->all()); + $this->assertSame($requests->all(), $handled); + } + + public function testAcceptsACallbackThatReturnsAGeneratorForRequests(): void + { + Saloon::fake([ + UserRequest::class => MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = new Collection; + + $generatorCallback = function () use ($requests): Generator { + for ($i = 0; $i < 3; ++$i) { + $request = new UserRequest; + $requests->put($i, $request); + + yield $i => $request; + } + }; + + $this->assertIsCallable($generatorCallback); + $this->assertInstanceOf(Generator::class, $generatorCallback()); + + $pool = $connector->pool($generatorCallback); + $pool->setConcurrency(5); + $pool->withResponseHandler(function (Response $response, int $index) use (&$handled): void { + $handled[$index] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertCount(3, $handled); + $this->assertSame($requests->all(), $handled); + + // The callback produces a fresh generator on every send, so the pool can be sent again. + $firstRequests = $handled; + $handled = []; + + $pool->send(); + + ksort($handled); + + $this->assertCount(3, $handled); + $this->assertSame($requests->all(), $handled); + $this->assertNotSame($firstRequests, $handled); + } + + public function testAcceptsACallbackThatReturnsAGeneratorForAliasedRequests(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + MockResponse::make(['name' => 'Mantas']), + ]); + + $connector = new TestConnector; + $handled = []; + + $requests = new Collection; + + $generatorCallback = function () use ($requests): Generator { + foreach (['a', 'b', 'c'] as $name) { + $request = new UserRequest; + $requests->put($name, $request); + + yield $name => $request; + } + }; + + $this->assertIsCallable($generatorCallback); + $this->assertInstanceOf(Generator::class, $generatorCallback()); + + $pool = $connector->pool($generatorCallback); + $pool->setConcurrency(5); + $pool->withResponseHandler(function (Response $response, string $name) use (&$handled): void { + $handled[$name] = $response->request(); + }); + + $pool->send(); + + ksort($handled); + + $this->assertSame(['a', 'b', 'c'], $requests->keys()->all()); + $this->assertSame($requests->all(), $handled); + } + + // Invalid items stop scheduling and are reported after the requests already started have settled. + public function testThrowsAnExceptionIfAnInvalidItemIsPassedIntoTheIterator(): void + { + Saloon::fake([ + MockResponse::make(['name' => 'Sam']), + MockResponse::make(['name' => 'Charlotte']), + ]); + + $connector = new TestConnector; + + $pool = $connector->pool([ + new UserRequest, + new UserRequest, + new TestConnector, + ]); + + try { + $pool->send(); + $this->fail('The invalid pool item was not rejected.'); + } catch (PoolException $exception) { + $this->assertInstanceOf(InvalidPoolItemException::class, $exception->orchestrationFailure()); + $this->assertSame([0, 1], array_keys($exception->responses())); + } + } + + public function testYouCanGetTheRequestsProvidedIntoThePool(): void + { + $connector = new TestConnector; + + $requests = [ + new UserRequest, + new UserRequest, + new TestConnector, + ]; + + $pool = $connector->pool($requests); + $iterable = $pool->requests(); + + $this->assertIsIterable($iterable); + + foreach ($iterable as $index => $request) { + $this->assertSame($requests[$index], $request); + } + + $this->assertSame(2, $index); + } + public function testPoolBoundsConcurrencyPreservesInputOrderAndPropagatesContext(): void { CoroutineContext::set('pool-tenant', 'tenant-a'); @@ -95,77 +481,28 @@ public function testPoolBoundsConcurrencyPreservesInputOrderAndPropagatesContext ], $handlerContexts); } - public function testHandledRequestFailuresAreOmittedAfterEveryChildSettles(): void - { - $manager = $this->manager(); - $manager->fake([ - PoolFailingRequestStub::class => MockResponse::make()->throw(new RuntimeException('failed')), - PoolRequestStub::class => MockResponse::make(['ok' => true]), - ]); - $handled = []; - $connector = new PoolConnectorStub($manager); - - $responses = $connector->pool([ - 'failed' => new PoolFailingRequestStub, - 'successful' => new PoolRequestStub(1), - ])->withExceptionHandler(function (RuntimeException $exception, string $key) use (&$handled): void { - $handled[$key] = $exception->getMessage(); - })->send(); - - $this->assertSame(['failed' => 'failed'], $handled); - $this->assertSame(['successful'], array_keys($responses)); - } - - public function testPoolExceptionPreservesRequestCallbackAndPartialResults(): void + public function testProducerFailuresTakePrecedenceAndWaitForStartedChildren(): void { + $sendFailure = new RuntimeException('send failed'); + $producerFailure = new RuntimeException('producer failed'); $manager = $this->manager(); $manager->fake([ - PoolFailingRequestStub::class => MockResponse::make()->throw(new RuntimeException('send failed')), + PoolFailingRequestStub::class => MockResponse::make()->throw($sendFailure), PoolRequestStub::class => MockResponse::make(['ok' => true]), ]); $connector = new PoolConnectorStub($manager); try { - $connector->pool([ - 'failed' => new PoolFailingRequestStub, - 'successful' => new PoolRequestStub(1), - ])->withResponseHandler(function (): void { - throw new RuntimeException('callback failed'); - })->send(); - $this->fail('The pool exception was not thrown.'); - } catch (PoolException $exception) { - $this->assertSame('send failed', $exception->failures()['failed']->getMessage()); - $this->assertSame('callback failed', $exception->callbackFailures()['successful']->getMessage()); - $this->assertSame(['successful'], array_keys($exception->responses())); - $this->assertNull($exception->orchestrationFailure()); - } - } - - public function testInvalidItemsAndProducerFailuresWaitForStartedChildren(): void - { - $manager = $this->manager(); - $manager->fake([PoolRequestStub::class => MockResponse::make(['ok' => true])]); - $connector = new PoolConnectorStub($manager); - - try { - $connector->pool((function (): iterable { + $connector->pool(function () use ($producerFailure): iterable { + yield 'failed' => new PoolFailingRequestStub; yield 'started' => new PoolRequestStub(1); - yield 'invalid' => new PoolConnectorStub($this->manager()); - })())->send(); - $this->fail('The invalid pool item was not rejected.'); - } catch (PoolException $exception) { - $this->assertInstanceOf(InvalidPoolItemException::class, $exception->orchestrationFailure()); - $this->assertSame(['started'], array_keys($exception->responses())); - } - - try { - $connector->pool(function (): iterable { - yield 'started' => new PoolRequestStub(1); - throw new RuntimeException('producer failed'); + throw $producerFailure; })->send(); $this->fail('The producer failure was not returned.'); } catch (PoolException $exception) { - $this->assertSame('producer failed', $exception->orchestrationFailure()?->getMessage()); + $this->assertSame($producerFailure, $exception->orchestrationFailure()); + $this->assertSame($producerFailure, $exception->getPrevious()); + $this->assertSame(['failed' => $sendFailure], $exception->failures()); $this->assertSame(['started'], array_keys($exception->responses())); } } @@ -365,7 +702,7 @@ public function testConcurrentPoolParentsKeepCacheAndRateScopesInTheirChildren() { $http = new Factory; $http->registerConnection('saloon'); - $http->fake(function () { + $http->fake(function (): PromiseInterface { usleep(5000); return Factory::response(['ok' => true]); @@ -463,15 +800,24 @@ protected function manager(): SaloonManager class PoolConnectorStub extends Connector { + /** + * Create a connector that sends through the given manager. + */ public function __construct(protected SaloonManager $manager) { } + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Send a request through the isolated manager. + */ public function send(Request $request, ?MockClient $mockClient = null): Response { return $this->manager->send($this, $request, $mockClient); @@ -482,11 +828,17 @@ class PoolRequestStub extends Request { protected Method $method = Method::GET; + /** + * Create a request identified by a query parameter. + */ public function __construct(int $id) { $this->withQueryParameters(['id' => $id]); } + /** + * Define the endpoint for the request. + */ public function resolveEndpoint(): string { return '/users'; @@ -497,6 +849,9 @@ class PoolFailingRequestStub extends Request { protected Method $method = Method::GET; + /** + * Define the endpoint for the request. + */ public function resolveEndpoint(): string { return '/failure'; @@ -508,12 +863,19 @@ class ScopedPoolRequestStub extends PoolRequestStub implements Cacheable use HasCaching; use HasRateLimits; + /** + * Define the cache lifetime in seconds. + */ public function cacheFor(): DateInterval|DateTimeInterface|int { return 60; } - /** @return list */ + /** + * Resolve the request's rate limits. + * + * @return list + */ protected function resolveRateLimits(PendingRequest $pendingRequest): array { return [Limit::perMinute(10)->by((string) $pendingRequest->request()->queryParameters()['id'])]; diff --git a/tests/Saloon/Unit/PsrTest.php b/tests/Saloon/Unit/PsrTest.php new file mode 100644 index 0000000000..3bd10b94ae --- /dev/null +++ b/tests/Saloon/Unit/PsrTest.php @@ -0,0 +1,93 @@ +createPendingRequest($request); + $request = $pendingRequest->createPsrRequest(); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertInstanceOf(UriInterface::class, $request->getUri()); + $this->assertSame('https://tests.saloon.dev/api/user', (string) $request->getUri()); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame([ + 'Host' => ['tests.saloon.dev'], + 'Accept' => ['application/json'], + ], $request->getHeaders()); + + $this->assertSame('1.1', $request->getProtocolVersion()); + } + + public function testIfRequestBodyIsPresentThenItWillBeOnThePsr7Request(): void + { + $connector = new TestConnector; + $request = new HasJsonBodyRequest; + + $pendingRequest = $connector->createPendingRequest($request); + $request = $pendingRequest->createPsrRequest(); + + $body = $request->getBody(); + + $this->assertInstanceOf(StreamInterface::class, $body); + $this->assertSame('{"name":"Sam","catchphrase":"Yeehaw!"}', $body->getContents()); + } + + public function testYouCanGenerateAUriFromThePendingRequest(): void + { + $connector = new QueryParameterConnector; + $request = new QueryParameterRequest('/user?include=hats#fragment-123'); + + $pendingRequest = $connector->createPendingRequest($request); + $uri = $pendingRequest->uri(); + + $this->assertInstanceOf(UriInterface::class, $uri); + + $this->assertSame('https://tests.saloon.dev/api/user?include=hats&sort=first_name&per_page=100#fragment-123', (string) $uri); + $this->assertSame('https', $uri->getScheme()); + $this->assertSame('tests.saloon.dev', $uri->getHost()); + $this->assertSame('/api/user', $uri->getPath()); + $this->assertSame('include=hats&sort=first_name&per_page=100', $uri->getQuery()); + $this->assertSame('fragment-123', $uri->getFragment()); + } + + public function testWhenUsingTheUrlForQueryParametersYouCanUseDotsAndValueLessParameters(): void + { + $connector = new TestConnector; + $request = new QueryParameterRequest('/user?account.id=1&checked&name=sam'); + + $pendingRequest = $connector->createPendingRequest($request); + $uri = $pendingRequest->uri(); + + // Query pairs in the endpoint are kept as written, so the value-less parameter is not rewritten as + // upstream's "checked=". + $this->assertSame('account.id=1&checked&name=sam&per_page=100', $uri->getQuery()); + } +} diff --git a/tests/Saloon/Unit/QueryParameterTest.php b/tests/Saloon/Unit/QueryParameterTest.php new file mode 100644 index 0000000000..3cde264ef7 --- /dev/null +++ b/tests/Saloon/Unit/QueryParameterTest.php @@ -0,0 +1,121 @@ +withQueryParameters(['sort' => '-created_at']); + + $response = $this->send(new TestConnector, $request); + $query = $response->pendingRequest()->queryParameters(); + + $this->assertSame(100, $query['per_page']); // Test Default + $this->assertSame('-created_at', $query['sort']); // Test Adding Data After + $this->assertSame('per_page=100&sort=-created_at', $response->toPsrRequest()->getUri()->getQuery()); + } + + public function testIfSetQueryIsUsedAllOtherDefaultQueryParamsWontBeIncluded(): void + { + $request = new QueryParameterConnectorRequest; + + // Upstream's set() replaces every parameter; removing the defaults before adding is the equivalent. + $request->withoutQueryParameters(array_keys($request->queryParameters()))->withQueryParameters([ + 'sort' => 'nickname', + ]); + + $query = $this->send(new TestConnector, $request)->pendingRequest()->queryParameters(); + + $this->assertSame(['sort' => 'nickname'], $query); + } + + public function testAConnectorCanHaveQueryThatIsSet(): void + { + $request = new QueryParameterConnectorRequest; + + $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters(); + + $this->assertSame('first_name', $query['sort']); // Added by connector + $this->assertSame('user', $query['include']); // Added by request + } + + public function testARequestQueryParameterCanOverwriteAConnectorsParameter(): void + { + $request = new OverwrittenQueryParameterConnectorRequest; + + // Upstream sends this through a connector without query parameters, so nothing is overwritten. + $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters(); + + $this->assertSame(['sort' => 'date_of_birth'], $query); + } + + public function testManuallyOverwritingQueryParameterInRuntimeCanOverwriteConnectorParameter(): void + { + $request = new QueryParameterConnectorBlankRequest; + + $request->withQueryParameters(['sort' => 'custom_field']); + + // Upstream sends this through a connector without query parameters, so nothing is overwritten. + $query = $this->send(new QueryParameterConnector, $request)->pendingRequest()->queryParameters(); + + $this->assertSame(['sort' => 'custom_field'], $query); + } + + public function testManuallyOverwritingQueryParameterInRuntimeCanOverwriteRequestParameter(): void + { + $request = new QueryParameterRequest; + + $request->withQueryParameters(['per_page' => 500]); + + $query = $this->send(new TestConnector, $request)->pendingRequest()->queryParameters(); + + $this->assertSame(['per_page' => 500], $query); + } + + public function testWhenNotSendingQueryParametersTheQueryOptionIsNotSet(): void + { + $request = new UserRequest; + + $response = $this->send(new TestConnector, $request); + + $this->assertSame([], $response->pendingRequest()->queryParameters()); + $this->assertSame('', $response->toPsrRequest()->getUri()->getQuery()); + } + + /** + * Send a request with a mocked response. + */ + protected function send(Connector $connector, Request $request): Response + { + return $connector->send($request, new MockClient([MockResponse::make()])); + } +} diff --git a/tests/Saloon/Unit/RequestCloneTest.php b/tests/Saloon/Unit/RequestCloneTest.php new file mode 100644 index 0000000000..34a5fd6f08 --- /dev/null +++ b/tests/Saloon/Unit/RequestCloneTest.php @@ -0,0 +1,179 @@ +withQueryParameters(['key' => 'value']); + + $a = clone $original; + $b = clone $original; + + $a->withQueryParameters(['page' => 1]); + $b->withQueryParameters(['page' => 2]); + + $this->assertSame(1, $a->queryParameters()['page']); + $this->assertSame(2, $b->queryParameters()['page']); + $this->assertArrayNotHasKey('page', $original->queryParameters()); + $this->assertSame('value', $original->queryParameters()['key']); + } + + public function testCloningARequestAfterHeadersConfigAndDelayAreInitializedGivesIndependentStores(): void + { + $original = (new UserRequest) + ->withHeader('X-Test', 'one') + ->withOptions(['timeout' => 10]) + ->delay(5); + + $clone = clone $original; + + // Upstream's header store replaces a value on add; Hypervel's withHeader appends, so the clone replaces it. + $clone + ->replaceHeaders(['X-Test' => 'two']) + ->withOptions(['timeout' => 20]) + ->delay(15); + + $this->assertSame('one', $original->headers()['X-Test']); + $this->assertSame('two', $clone->headers()['X-Test']); + $this->assertSame(10, $original->options()['timeout']); + $this->assertSame(20, $clone->options()['timeout']); + $this->assertSame(5, $original->delayMilliseconds()); + $this->assertSame(15, $clone->delayMilliseconds()); + } + + public function testCloningARequestAfterMiddlewareIsInitializedGivesIndependentPipelines(): void + { + $original = new UserRequest; + $original->middleware()->onResponse(static fn (Response $response): Response => $response); + + $clone = clone $original; + + $this->assertNotSame($original->middleware(), $clone->middleware()); + } + + public function testConcurrentPoolSendsWithClonedRequestsDoNotShareQueryMutation(): void + { + $sequence = []; + + for ($i = 0; $i < 10; ++$i) { + $sequence[] = MockResponse::make(['ok' => true]); + } + + // Connectors are read-only, so the shared mock client is attached to the request that is cloned. + $base = (new UserRequest) + ->withMockClient(new MockClient($sequence)) + ->withQueryParameters(['key' => 'value']); + + $requests = []; + + for ($i = 1; $i <= 10; ++$i) { + $requests[] = (clone $base)->withQueryParameters(['page' => $i]); + } + + $pagesSeen = []; + + $pool = (new TestConnector)->pool($requests, 5); + $pool->withResponseHandler(function (Response $response) use (&$pagesSeen): void { + $pagesSeen[] = (int) $response->request()->queryParameters()['page']; + }); + + $pool->send(); + + $this->assertCount(10, $pagesSeen); + $this->assertCount(10, array_unique($pagesSeen)); + } + + public function testCloneOwnsIndependentInitializedRequestState(): void + { + $this->assertNull((new CloneRequestStub)->url()); + $request = (new CloneRequestStub) + ->withUrl('https://api.example.test/users/1') + ->withHeader('X-Original', 'yes') + ->withQueryParameters(['page' => 1]) + ->withUrlParameters(['version' => 'v1']) + ->withOptions(['verify' => true]) + ->delay(10) + ->withCookies(['original' => 'yes'], '.example.test') + ->retry([10]) + ->withData(['original' => true]) + ->disableCaching(); + $request->middleware()->onRequest(static fn (PendingRequest $pendingRequest): PendingRequest => $pendingRequest, 'original'); + + $clone = clone $request; + $clone + ->withUrl('https://api.example.test/users/2') + ->withHeader('X-Clone', 'yes') + ->withQueryParameters(['page' => 2]) + ->withUrlParameters(['version' => 'v2']) + ->withOptions(['verify' => false]) + ->delay(20) + ->withCookies(['clone' => 'yes'], 'api.example.test') + ->retry(3, 20) + ->withData(['clone' => true]) + ->enableCaching() + ->invalidateCache(); + $clone->middleware()->onRequest(static fn (PendingRequest $pendingRequest): PendingRequest => $pendingRequest, 'clone'); + + $this->assertSame('https://api.example.test/users/1', $request->url()); + $this->assertSame('https://api.example.test/users/2', $clone->url()); + $this->assertSame('yes', $request->headers()['X-Original']); + $this->assertSame('application/json', $request->headers()['Content-Type']); + $this->assertSame(['page' => 1], $request->queryParameters()); + $this->assertSame(['version' => 'v1'], $request->urlParameters()); + $this->assertTrue($request->options()['verify']); + $this->assertSame(10, $request->delayMilliseconds()); + $this->assertCount(1, $request->middleware()->requestPipeline()->pipes()); + $this->assertSame(['original' => true], $request->body()); + $this->assertSame(CookieJar::fromArray(['original' => 'yes'], '.example.test')->toArray(), $request->cookies()); + $this->assertSame([10], $request->retryPolicy()->times); + $this->assertFalse($request->cachingEnabled()); + $this->assertFalse($request->shouldInvalidateCache()); + + $this->assertSame(['original' => true, 'clone' => true], $clone->body()); + $this->assertCount(2, $clone->cookies()); + $this->assertSame(3, $clone->retryPolicy()->times); + $this->assertTrue($clone->cachingEnabled()); + $this->assertTrue($clone->shouldInvalidateCache()); + } +} + +class CloneRequestStub extends Request +{ + use HasCaching; + + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return 'users'; + } +} diff --git a/tests/Saloon/Unit/RequestExceptionTest.php b/tests/Saloon/Unit/RequestExceptionTest.php new file mode 100644 index 0000000000..ade6345f18 --- /dev/null +++ b/tests/Saloon/Unit/RequestExceptionTest.php @@ -0,0 +1,125 @@ + 'Oh yee-naw!'], $status), + ]); + + $response = TestConnector::make()->send(new UserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertInstanceOf($expectedException, $exception); + $this->assertSame(static::message($response), $exception->getMessage()); + } + + /** + * Get the statuses and the exceptions they produce. + * + * @return array}> + */ + public static function statusExceptions(): array + { + return [ + [400, BadRequestException::class], + [401, UnauthorizedException::class], + [402, PaymentRequiredException::class], + [403, ForbiddenException::class], + [404, NotFoundException::class], + [405, MethodNotAllowedException::class], + [408, RequestTimeOutException::class], + [409, ConflictException::class], + [422, UnprocessableEntityException::class], + [429, TooManyRequestsException::class], + [500, InternalServerErrorException::class], + [502, BadGatewayException::class], + [503, ServiceUnavailableException::class], + [504, GatewayTimeoutException::class], + [418, ClientException::class], + [411, ClientException::class], + [501, ServerException::class], + ]; + } + + #[DataProvider('customFailureExceptions')] + public function testWhenTheFailedMethodIsCustomisedTheResponseWillReturnOkRequestExceptions(int $status, string $expectedException): void + { + $mockClient = new MockClient([ + MockResponse::make(['message' => 'Oh yee-naw!'], $status), + ]); + + $response = TestConnector::make()->send(new AlwaysHasFailureRequest, $mockClient); + $exception = $response->toException(); + + $this->assertSame($expectedException, $exception::class); + $this->assertSame(static::message($response), $exception->getMessage()); + } + + /** + * Get the non-error statuses that a custom failure check turns into exceptions. + * + * @return array}> + */ + public static function customFailureExceptions(): array + { + return [ + [302, RequestException::class], + [200, RequestException::class], + [201, RequestException::class], + [100, RequestException::class], + ]; + } + + /** + * Get the message the HTTP client's request exception gives a response. + */ + protected static function message(Response $response): string + { + return sprintf("HTTP request returned status code %d:\n%s\n", $response->status(), $response->body()); + } +} diff --git a/tests/Saloon/Http/RequestOptionValidatorTest.php b/tests/Saloon/Unit/RequestOptionValidatorTest.php similarity index 97% rename from tests/Saloon/Http/RequestOptionValidatorTest.php rename to tests/Saloon/Unit/RequestOptionValidatorTest.php index 9da3471413..4b7a475f54 100644 --- a/tests/Saloon/Http/RequestOptionValidatorTest.php +++ b/tests/Saloon/Unit/RequestOptionValidatorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; use Hypervel\Saloon\Exceptions\PendingRequestException; use Hypervel\Saloon\Http\RequestOptionValidator; diff --git a/tests/Saloon/Unit/RequestProperties/ConfigTest.php b/tests/Saloon/Unit/RequestProperties/ConfigTest.php new file mode 100644 index 0000000000..074e6ac555 --- /dev/null +++ b/tests/Saloon/Unit/RequestProperties/ConfigTest.php @@ -0,0 +1,142 @@ +assertSame(['debug' => false], $request->options()); + } + + public function testConfigCanBeManagedOnARequest(): void + { + $request = new ConfigRequest; + + $this->assertSame($request, $request->withOptions(['timeout' => 60])); + $this->assertSame($request, $request->withOptions(['name' => 'Sam', 'category' => 'Cowboy', 'connect_timeout' => 200])); + $this->assertSame($request, $request->withoutOptions('category')); + + $this->assertEquals([ + 'timeout' => 60, + 'name' => 'Sam', + 'connect_timeout' => 200, + 'debug' => false, + ], $request->options()); + $this->assertSame(60, $request->options()['timeout']); + + // Options have no replace-all method, so upstream's set() becomes removing every option and adding the new one. + $request->withoutOptions(array_keys($request->options()))->withOptions(['debug' => true]); + + $this->assertSame(['debug' => true], $request->options()); + } + + public function testConfigCanBeManagedOnAConnector(): void + { + // Connectors are read-only and may be shared between coroutines, so their default options are managed on + // the pending request that merges them. + $connector = new ConfigConnector; + $pendingRequest = new PendingRequest($connector, new UserRequest); + + $pendingRequest->withOptions(['timeout' => 60]); + $pendingRequest->withOptions(['name' => 'Sam', 'category' => 'Cowboy', 'connect_timeout' => 200]); + $pendingRequest->withoutOptions('category'); + + $this->assertEquals([ + 'timeout' => 60, + 'name' => 'Sam', + 'connect_timeout' => 200, + 'debug' => false, + ], $pendingRequest->options()); + $this->assertSame(60, $pendingRequest->options()['timeout']); + + $pendingRequest->withoutOptions(array_keys($pendingRequest->options()))->withOptions(['debug' => true]); + + $this->assertSame(['debug' => true], $pendingRequest->options()); + $this->assertSame(['debug' => false], $connector->options()); + } + + public function testOptionsAreMergedRecursively(): void + { + $request = (new ConfigRequest) + ->withOptions(['allow_redirects' => ['max' => 3]]) + ->withOptions(['allow_redirects' => ['strict' => true]]); + + $this->assertSame(['max' => 3, 'strict' => true], $request->options()['allow_redirects']); + } + + public function testMaxRedirectsPreservesArraySettings(): void + { + $request = $this->request() + ->withOptions(['allow_redirects' => ['strict' => true]]) + ->maxRedirects(3); + + $this->assertSame(['strict' => true, 'max' => 3], $request->options()['allow_redirects']); + } + + public function testMaxRedirectsReenablesRedirectsWithoutDeprecation(): void + { + set_error_handler(static function (int $severity, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $severity, $file, $line); + }, E_DEPRECATED); + + try { + $request = $this->request() + ->withoutRedirecting() + ->maxRedirects(3); + + $this->assertSame(['max' => 3], $request->options()['allow_redirects']); + } finally { + restore_error_handler(); + } + } + + public function testWithoutRedirectingDisablesAConfiguredRedirectLimit(): void + { + $request = $this->request() + ->maxRedirects(3) + ->withoutRedirecting(); + + $this->assertFalse($request->options()['allow_redirects']); + } + + public function testMaxRedirectsReplacesTheBooleanEnabledForm(): void + { + $request = $this->request() + ->withOptions(['allow_redirects' => true]) + ->maxRedirects(3); + + $this->assertSame(['max' => 3], $request->options()['allow_redirects']); + } + + public function testWithoutVerifyingReplacesACertificateBundle(): void + { + $request = $this->request() + ->withOptions(['verify' => '/etc/ssl/certs/api.pem']) + ->withoutVerifying(); + + $this->assertFalse($request->options()['verify']); + } + + /** + * Create an object with operation-owned request options. + */ + protected function request(): object + { + return new class { + use HasOptions; + }; + } +} diff --git a/tests/Saloon/Unit/RequestProperties/HeadersTest.php b/tests/Saloon/Unit/RequestProperties/HeadersTest.php new file mode 100644 index 0000000000..41f844a26f --- /dev/null +++ b/tests/Saloon/Unit/RequestProperties/HeadersTest.php @@ -0,0 +1,166 @@ +assertSame(['X-Custom-Header' => 'Howdy'], $request->headers()); + } + + public function testHeadersCanBeManagedOnARequest(): void + { + $request = new HeaderRequest; + + $this->assertSame($request, $request->withHeader('Content-Type', 'custom/saloon')); + + // Upstream's merge() replaces existing values; withHeaders() appends, so replaceHeaders() is the equivalent. + $this->assertSame($request, $request->replaceHeaders([ + 'X-Merge-A' => 'Hello', + 'Complex' => ['A', 'B'], + 'X-Merge-B' => 'Goodbye', + 'Content-Type' => 'overwritten', + ])); + $this->assertSame($request, $request->withoutHeader('X-Merge-B')); + + $this->assertEquals([ + 'X-Custom-Header' => 'Howdy', + 'Content-Type' => 'overwritten', + 'X-Merge-A' => 'Hello', + 'Complex' => ['A', 'B'], + ], $request->headers()); + $this->assertSame('Howdy', $request->headers()['X-Custom-Header']); + $this->assertSame(['A', 'B'], $request->headers()['Complex']); + + // Upstream's set() replaces every header; there is no replace-all method, so remove the headers before adding. + $request->withoutHeaders(array_keys($request->headers()))->withHeader('X-Different', 'Yo'); + + $this->assertSame(['X-Different' => 'Yo'], $request->headers()); + $this->assertNotEmpty($request->headers()); + } + + public function testHeadersCanBeManagedOnAConnector(): void + { + // Connectors are read-only and may be shared between coroutines, so their default headers are managed on + // the pending request that merges them. + $connector = new HeaderConnector; + $pendingRequest = new PendingRequest($connector, new UserRequest); + + $pendingRequest->withHeader('Content-Type', 'custom/saloon'); + $pendingRequest->replaceHeaders([ + 'X-Merge-A' => 'Hello', + 'Complex' => ['A', 'B'], + 'X-Merge-B' => 'Goodbye', + 'Content-Type' => 'overwritten', + ]); + $pendingRequest->withoutHeader('X-Merge-B'); + + $this->assertEquals([ + 'X-Connector-Header' => 'Sam', + 'Content-Type' => 'overwritten', + 'X-Merge-A' => 'Hello', + 'Complex' => ['A', 'B'], + ], $pendingRequest->headers()); + $this->assertSame('Sam', $pendingRequest->headers()['X-Connector-Header']); + $this->assertSame(['A', 'B'], $pendingRequest->headers()['Complex']); + + $pendingRequest->withoutHeaders(array_keys($pendingRequest->headers()))->withHeader('X-Different', 'Yo'); + + $this->assertSame(['X-Different' => 'Yo'], $pendingRequest->headers()); + $this->assertSame(['X-Connector-Header' => 'Sam'], $connector->headers()); + } + + public function testRemovingAHeaderFromARequestDoesNotRemoveConnectorDefaults(): void + { + $request = (new HeaderRequest)->withoutHeader('X-Connector-Header'); + $pendingRequest = new PendingRequest(new HeaderConnector, $request); + + $this->assertSame('Sam', $pendingRequest->headers()['X-Connector-Header']); + + $pendingRequest->withoutHeader('x-connector-header'); + + $this->assertSame(['X-Custom-Header' => 'Howdy'], $pendingRequest->headers()); + } + + public function testAddedHeadersAppendWhileReplacementsAndRemovalsIgnoreCase(): void + { + $request = (new HeaderRequest)->withHeader('X-Custom-Header', 'Partner'); + + $this->assertSame(['X-Custom-Header' => ['Howdy', 'Partner']], $request->headers()); + + $request->replaceHeaders(['x-custom-header' => 'Yeehaw']); + + $this->assertSame(['x-custom-header' => 'Yeehaw'], $request->headers()); + + $request->withoutHeaders(['X-CUSTOM-HEADER']); + + $this->assertSame([], $request->headers()); + $this->assertFalse($request->hasHeader('X-Custom-Header')); + } + + public function testReplaceHeadersMatchesNamesCaseInsensitively(): void + { + $request = (new UserRequest) + ->withHeaders([ + 'Authorization' => 'Bearer old', + 'X-Keep' => 'yes', + ]) + ->replaceHeaders([ + 'authorization' => 'Bearer new', + 'AUTHORIZATION' => 'Bearer newest', + ]); + + $this->assertSame([ + 'X-Keep' => 'yes', + 'AUTHORIZATION' => 'Bearer newest', + ], $request->headers()); + } + + public function testIncomingHeaderWinsWhenAnExistingCaseVariantFollowsItsExactName(): void + { + $request = (new UserRequest) + ->withHeaders([ + 'Authorization' => 'Bearer stale exact', + 'authorization' => 'Bearer stale variant', + 'X-Keep' => 'yes', + ]) + ->replaceHeaders([ + 'Authorization' => 'Bearer new', + ]); + + $this->assertSame([ + 'X-Keep' => 'yes', + 'Authorization' => 'Bearer new', + ], $request->headers()); + } + + public function testAcceptReplacesTheExistingAcceptHeader(): void + { + $request = (new UserRequest) + ->accept('text/plain') + ->acceptJson(); + + $this->assertSame('application/json', $request->headers()['Accept']); + } + + public function testWithUserAgentReplacesTheExistingUserAgentHeader(): void + { + $request = (new UserRequest) + ->withHeaders(['user-agent' => 'Legacy']) + ->withUserAgent(' Hypervel '); + + $this->assertSame(['User-Agent' => 'Hypervel'], $request->headers()); + $this->assertSame('', $request->withUserAgent(false)->headers()['User-Agent']); + } +} diff --git a/tests/Saloon/Unit/RequestProperties/QueryTest.php b/tests/Saloon/Unit/RequestProperties/QueryTest.php new file mode 100644 index 0000000000..95c921a729 --- /dev/null +++ b/tests/Saloon/Unit/RequestProperties/QueryTest.php @@ -0,0 +1,152 @@ +assertSame(['per_page' => 100], $request->queryParameters()); + } + + public function testQueryParametersCanBeManagedOnARequest(): void + { + $request = new QueryParameterRequest; + + $this->assertSame($request, $request->withQueryParameters(['page' => 1])); + $this->assertSame($request, $request->withQueryParameters(['search' => 'Sam', 'category' => 'Cowboy', 'per_page' => 200])); + $this->assertSame($request, $request->withoutQueryParameters('category')); + + $this->assertSame([ + 'per_page' => 200, + 'page' => 1, + 'search' => 'Sam', + ], $request->queryParameters()); + $this->assertSame(1, $request->queryParameters()['page']); + + // Upstream's set() replaces every parameter; there is no replace-all method, so remove the parameters first. + $request->withoutQueryParameters(array_keys($request->queryParameters()))->withQueryParameters(['debug' => true]); + + $this->assertSame(['debug' => true], $request->queryParameters()); + } + + public function testQueryParametersCanBeManagedOnAConnector(): void + { + // Connectors are read-only and may be shared between coroutines, so their default query parameters are + // managed on the pending request that merges them. + $connector = new QueryParameterConnector; + $pendingRequest = new PendingRequest($connector, new QueryParameterConnectorBlankRequest); + + $pendingRequest->withQueryParameters(['page' => 1]); + $pendingRequest->withQueryParameters(['search' => 'Sam', 'category' => 'Cowboy', 'sort' => 'last_name']); + $pendingRequest->withoutQueryParameters('category'); + + $this->assertSame([ + 'sort' => 'last_name', + 'page' => 1, + 'search' => 'Sam', + ], $pendingRequest->queryParameters()); + $this->assertSame(1, $pendingRequest->queryParameters()['page']); + + $pendingRequest + ->withoutQueryParameters(array_keys($pendingRequest->queryParameters())) + ->withQueryParameters(['debug' => true]); + + $this->assertSame(['debug' => true], $pendingRequest->queryParameters()); + $this->assertSame(['sort' => 'first_name'], $connector->queryParameters()); + } + + public function testRemovingAQueryParameterFromARequestDoesNotRemoveConnectorDefaults(): void + { + $request = (new QueryParameterConnectorRequest)->withoutQueryParameters('sort'); + $pendingRequest = new PendingRequest(new QueryParameterConnector, $request); + + $this->assertSame(['sort' => 'first_name', 'include' => 'user'], $pendingRequest->queryParameters()); + + $pendingRequest->withoutQueryParameters(['sort']); + + $this->assertSame(['include' => 'user'], $pendingRequest->queryParameters()); + } + + public function testRemovingAQueryParameterInvalidatesTheFinalizedUri(): void + { + $pendingRequest = (new PendingRequest(new QueryParameterConnector, new QueryParameterConnectorRequest)) + ->finalizeUri(); + + $this->assertSame('sort=first_name&include=user', $pendingRequest->uri()->getQuery()); + + $pendingRequest->withoutQueryParameters('sort'); + + $this->assertSame('include=user', $pendingRequest->uri()->getQuery()); + + $pendingRequest->finalizeUri()->withQueryParameters(['sort' => 'nickname']); + + $this->assertSame('include=user&sort=nickname', $pendingRequest->uri()->getQuery()); + } + + public function testRemovingAQueryParameterKeepsValuesWrittenInTheEndpointOrQueryString(): void + { + $pendingRequest = new PendingRequest( + new QueryParameterConnector, + new QueryParameterRequest('/user?sort=endpoint'), + ); + + $this->assertSame('sort=first_name&per_page=100', $pendingRequest->uri()->getQuery()); + + $pendingRequest->withoutQueryParameters('sort'); + + $this->assertSame('sort=endpoint&per_page=100', $pendingRequest->uri()->getQuery()); + + $pendingRequest->withQueryString('sort=raw')->withoutQueryParameters('per_page'); + + $this->assertSame('sort=raw', $pendingRequest->uri()->getQuery()); + } + + public function testRawQueryDefaultsOverridesAndCloneIsolation(): void + { + $this->assertNull((new QueryParameterRequest)->queryString()); + $request = new RawQueryRequestStub; + $this->assertSame('tag=a&tag=b', $request->queryString()); + $request->withQueryString('cursor=a%2Fb')->withQueryParameters(['limit' => 10]); + $clone = clone $request; + + $this->assertSame($clone, $clone->withQueryString('')); + $this->assertSame('', $clone->queryString()); + $this->assertSame('cursor=a%2Fb', $request->queryString()); + $this->assertSame(['limit' => 10], $clone->queryParameters()); + } +} + +class RawQueryRequestStub extends Request +{ + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return 'users'; + } + + /** + * Resolve the default raw query string override. + */ + protected function defaultQueryString(): ?string + { + return 'tag=a&tag=b'; + } +} diff --git a/tests/Saloon/Unit/RequestPropertiesTest.php b/tests/Saloon/Unit/RequestPropertiesTest.php new file mode 100644 index 0000000000..7e9834975a --- /dev/null +++ b/tests/Saloon/Unit/RequestPropertiesTest.php @@ -0,0 +1,33 @@ +assertSame([], $request->headers()); + $this->assertSame([], $request->queryParameters()); + $this->assertSame([], $request->options()); + $this->assertInstanceOf(MiddlewarePipeline::class, $request->middleware()); + } + + public function testAllOfTheRequestPropertiesCanHaveDefaultProperties(): void + { + $request = new DefaultPropertiesRequest; + + $this->assertSame(['X-Favourite-Artist' => 'Luke Combs'], $request->headers()); + $this->assertSame(['format' => 'json'], $request->queryParameters()); + $this->assertSame(['debug' => true], $request->options()); + } +} diff --git a/tests/Saloon/Unit/RequestTest.php b/tests/Saloon/Unit/RequestTest.php new file mode 100644 index 0000000000..8e2ea35f06 --- /dev/null +++ b/tests/Saloon/Unit/RequestTest.php @@ -0,0 +1,293 @@ +assertFalse($request->hasMockClient()); + $this->assertFalse((new TestConnector)->send($request)->isMocked()); + } + + public function testYouCanPassAMockClientToTheSaloonRequestAndItWillBeInMockMode(): void + { + $request = new UserRequest; + $mockClient = new MockClient([MockResponse::make([])]); + + $request->withMockClient($mockClient); + + $this->assertTrue($request->hasMockClient()); + $this->assertSame($mockClient, $request->mockClient()); + $this->assertTrue((new TestConnector)->send($request)->isMocked()); + } + + public function testYouCantSendARequestWithAMockClientWithoutAnyResponses(): void + { + $mockClient = new MockClient; + $request = new UserRequest; + + $this->expectException(NoMockResponseFoundException::class); + + (new TestConnector)->send($request, $mockClient); + } + + public function testSaloonWorksWithACustomResponseClassInConnector(): void + { + $connector = new CustomResponseConnector; + + // Hypervel passes the base response so the class may depend on it. + $this->assertSame(CustomResponse::class, $connector->resolveResponseClass($this->response())); + } + + public function testSaloonCanHandleWithCustomResponseInConnector(): void + { + $request = new CustomResponseConnectorRequest; + $response = (new CustomResponseConnector)->send($request, new MockClient([MockResponse::make([])])); + + $this->assertInstanceOf(CustomResponse::class, $response); + } + + public function testSaloonCanHandleWithCustomResponseInRequest(): void + { + $request = new UserRequestWithCustomResponse; + + $this->assertSame(UserResponse::class, $request->resolveResponseClass($this->response())); + } + + public function testSaloonThrowsAnExceptionIfTheCustomResponseIsNotAResponseClass(): void + { + $invalidConnectorClassRequest = new InvalidResponseClass; + + $this->expectException(InvalidResponseClassException::class); + + $connector = new TestConnector; + + $connector->send($invalidConnectorClassRequest, new MockClient([ + InvalidResponseClass::class => MockResponse::make([], 200), + ])); + } + + public function testDefineEndpointMethodMayBeBlankInRequestClassToUseTheBaseUrl(): void + { + $pendingRequest = (new TestConnector)->createPendingRequest(new DefaultEndpointRequest); + + $this->assertSame(TestConnector::API_URL, (string) $pendingRequest->uri()); + } + + public function testARequestClassCanBeInstantiatedUsingTheMakeMethod(): void + { + $requestA = UserRequest::make(); + + $this->assertInstanceOf(UserRequest::class, $requestA); + $this->assertNull($requestA->userId); + $this->assertNull($requestA->groupId); + + $requestB = UserRequest::make(1, 2); + + $this->assertInstanceOf(UserRequest::class, $requestB); + $this->assertSame(1, $requestB->userId); + $this->assertSame(2, $requestB->groupId); + + $this->assertSame('users/1', RequiredArgumentRequestStub::make('users/1')->resolveEndpoint()); + } + + #[DataProvider('urlsToJoin')] + public function testYouCanJoinVariousUrlsTogether(string $baseUrl, string $endpoint, string $expected): void + { + $connector = new CustomBaseUrlConnector($baseUrl); + $request = new CustomEndpointRequest; + + $request->setEndpoint($endpoint); + + $this->assertSame($expected, (string) $connector->createPendingRequest($request)->uri()); + } + + /** + * Get the base URLs and endpoints to join. + * + * @return array + */ + public static function urlsToJoin(): array + { + return [ + ['https://google.com', '/search', 'https://google.com/search'], + ['https://google.com', 'search', 'https://google.com/search'], + ['https://google.com/', '/search', 'https://google.com/search'], + ['https://google.com/', 'search', 'https://google.com/search'], + ['https://google.com//', '//search', 'https://google.com/search'], + ]; + } + + public function testARelativeEndpointCannotBeJoinedToAnEmptyBaseUrl(): void + { + // Upstream's last join case returns the unsendable relative URL "/google.com/search". + $request = (new CustomEndpointRequest)->setEndpoint('google.com/search'); + $pendingRequest = (new CustomBaseUrlConnector)->createPendingRequest($request); + + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIs('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.'); + + $pendingRequest->uri(); + } + + public function testItThrowsAnExceptionIfYouForgetToAddAMethod(): void + { + $connector = new TestConnector; + $request = new MissingMethodRequest; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Your request is missing an HTTP method. Add a method property such as [protected Method $method = Method::GET].'); + + $connector->send($request); + } + + public function testContainerResolutionAlwaysReturnsAFreshRequestWithItsDependencies(): void + { + $first = $this->app->make(ContainerRequestStub::class); + $second = $this->app->make(ContainerRequestStub::class); + + $this->assertNotSame($first, $second); + $this->assertSame($this->app->make('config'), $first->config); + } + + public function testContainerResolutionPassesRequestParameters(): void + { + $request = $this->app->make(RequiredArgumentRequestStub::class, ['endpoint' => 'users/1']); + + $this->assertSame('users/1', $request->resolveEndpoint()); + } + + #[DataProvider('invalidCookies')] + public function testWithCookieRejectsInvalidCookies(array $cookie, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs($message); + + (new UserRequest)->withCookie(new SetCookie($cookie)); + } + + /** + * Provide cookies that cannot be sent with a request. + */ + public static function invalidCookies(): array + { + return [ + 'null domain' => [ + ['Name' => 'locale', 'Value' => 'en'], + 'An outgoing cookie must have a domain.', + ], + 'empty domain' => [ + ['Name' => 'locale', 'Value' => 'en', 'Domain' => ''], + 'Invalid cookie: The cookie domain must not be empty', + ], + 'null value' => [ + ['Name' => 'locale', 'Domain' => 'api.example.com'], + 'Invalid cookie: The cookie value must not be empty', + ], + ]; + } + + public function testRequestsAreMacroable(): void + { + UserRequest::macro('endpoint', function (): string { + return $this->resolveEndpoint(); + }); + + $this->assertSame('/user', (new UserRequest)->endpoint()); + } + + /** + * Send a request through a mock client and return its response. + */ + protected function response(): Response + { + return (new TestConnector)->send(new UserRequest, new MockClient([MockResponse::make([])])); + } +} + +class ContainerRequestStub extends Request +{ + protected Method $method = Method::GET; + + /** + * Create a new request instance. + */ + public function __construct(public readonly Repository $config) + { + } + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return 'users'; + } +} + +class RequiredArgumentRequestStub extends Request +{ + protected Method $method = Method::GET; + + /** + * Create a new request instance. + */ + public function __construct(protected string $endpoint) + { + } + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return $this->endpoint; + } +} diff --git a/tests/Saloon/Unit/ResponseTest.php b/tests/Saloon/Unit/ResponseTest.php new file mode 100644 index 0000000000..2855690fb1 --- /dev/null +++ b/tests/Saloon/Unit/ResponseTest.php @@ -0,0 +1,857 @@ + 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $pendingRequest = $response->pendingRequest(); + + $this->assertInstanceOf(PendingRequest::class, $pendingRequest); + $this->assertInstanceOf(UserRequest::class, $pendingRequest->request()); + } + + public function testYouCanGetTheConnector(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $request = new UserRequest; + $connector = new TestConnector; + $response = $connector->send($request, $mockClient); + + $this->assertSame($connector, $response->connector()); + } + + public function testYouCanGetTheOriginalRequest(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $request = new UserRequest; + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertSame($request, $response->request()); + } + + public function testYouCanGetThePsr7Request(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $request = new UserRequest; + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertInstanceOf(RequestInterface::class, $response->toPsrRequest()); + } + + public function testItWillThrowAnExceptionWhenYouUseTheThrowMethod(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->expectException(RequestException::class); + + $response->throw(); + } + + public function testItWontThrowAnExceptionIfTheRequestDidNotFail(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 200), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame($response, $response->throw()); + } + + public function testToExceptionWillReturnASaloonRequestException(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertInstanceOf(RequestException::class, $exception); + } + + public function testToExceptionWontReturnAnythingIfTheRequestDidNotFail(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 200), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $exception = $response->toException(); + + $this->assertNull($exception); + } + + public function testTheOnErrorMethodWillRunACustomClosure(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $count = 0; + + $response->onError(function () use (&$count): void { + ++$count; + }); + + $this->assertSame(1, $count); + } + + public function testTheObjectMethodWillReturnAnObject(): void + { + $data = ['name' => 'Sam', 'work' => 'Codepotato']; + + $mockClient = new MockClient([ + MockResponse::make($data, 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $dataAsObject = (object) $data; + + $this->assertEquals($dataAsObject, $response->object()); + } + + // REMOVED: object() with a dot-notation key. Saloon responses keep the HTTP client's object() method, which takes + // decoding flags; use data_get($response->object(), 'contacts.1.name') for keyed access. + + public function testTheCollectMethodWillReturnACollection(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $collection = $response->collect(); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertCount(2, $collection); + $this->assertSame('Sam', $collection['name']); + $this->assertSame('Codepotato', $collection['work']); + + $this->assertSame(['Sam'], $response->collect('name')->all()); + $this->assertEmpty($response->collect('age')); + } + + // Upstream returns an empty array. Saloon responses keep the HTTP client's json() method, which returns null. + public function testTheJsonMethodWillReturnNullIfBodyIsEmpty(): void + { + $mockClient = new MockClient([ + MockResponse::make('', 404), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertNull($response->json()); + } + + public function testTheToPsrResponseMethodWillReturnAGuzzleResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertInstanceOf(PsrResponse::class, $response->toPsrResponse()); + } + + public function testYouCanGetAnIndividualHeaderFromTheResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame('Howdy', $response->header('X-Greeting')); + $this->assertSame('', $response->header('X-Missing')); + } + + public function testItWillConvertTheBodyToStringIfTheCastIsUsed(): void + { + $data = ['name' => 'Sam', 'work' => 'Codepotato']; + + $mockClient = new MockClient([ + MockResponse::make($data, 200, ['X-Greeting' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(json_encode($data), (string) $response); + } + + public function testItChecksStatusesCorrectly(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']), + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 500, ['X-Greeting' => 'Howdy']), + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 302, ['X-Greeting' => 'Howdy']), + ]); + + $responseA = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($responseA->successful()); + $this->assertTrue($responseA->ok()); + $this->assertFalse($responseA->redirect()); + $this->assertFalse($responseA->failed()); + $this->assertFalse($responseA->serverError()); + + $responseB = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseB->successful()); + $this->assertFalse($responseB->ok()); + $this->assertFalse($responseB->redirect()); + $this->assertTrue($responseB->failed()); + $this->assertTrue($responseB->serverError()); + + $responseC = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertFalse($responseC->successful()); + $this->assertFalse($responseC->ok()); + $this->assertTrue($responseC->redirect()); + $this->assertFalse($responseC->failed()); + $this->assertFalse($responseC->serverError()); + } + + public function testTheXmlMethodWillReturnXmlAsAnArray(): void + { + $mockClient = new MockClient([ + new MockResponse('1168255false', 200), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $simpleXml = $response->xml(); + + $this->assertInstanceOf(SimpleXMLElement::class, $simpleXml); + } + + // REMOVED: xmlReader(). It only wraps XML Wrangler, which does not fit Hypervel; see the package README. + + // Upstream returns an ArrayStore. Saloon responses keep the HTTP client's headers() method. + public function testTheHeadersMethodReturnsAnArray(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(['X-Greeting' => ['Howdy']], $response->headers()); + } + + // Upstream returns a single value as a string and multiple values as an array. Saloon responses keep the HTTP + // client's methods: headers() lists every value and header() joins them. + public function testHeadersWithMultipleValuesAreListedAndJoined(): void + { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam', 'work' => 'Codepotato'], 200, ['X-Greeting' => 'Howdy', 'X-Farewell' => ['Goodbye', 'Sam']]), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(['Howdy'], $response->headers()['X-Greeting']); + $this->assertSame(['Goodbye', 'Sam'], $response->headers()['X-Farewell']); + + $this->assertSame('Howdy', $response->header('X-Greeting')); + $this->assertSame('Goodbye, Sam', $response->header('X-Farewell')); + } + + public function testTheDomMethodWillReturnACrawlerInstance(): void + { + $dom = '

Howdy Partner

'; + + $mockClient = new MockClient([ + new MockResponse($dom), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertInstanceOf(Crawler::class, $response->dom()); + $this->assertEquals(new Crawler($dom), $response->dom()); + } + + public function testWhenUsingTheBodyMethodsTheStreamIsRewoundBackToTheStart(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(['foo' => 'bar'], $response->json()); + $this->assertSame(['foo' => 'bar'], $response->array()); + $this->assertSame('{"foo":"bar"}', $response->body()); + $this->assertSame('{"foo":"bar"}', stream_get_contents($response->getRawStream())); + $this->assertEquals((object) ['foo' => 'bar'], $response->object()); + } + + public function testItCanConvertTheResponseToADataUrl(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json;encoding=utf-8']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame('data:application/json;encoding=utf-8;base64,eyJmb28iOiJiYXIifQ==', $response->dataUrl()); + } + + public function testIfAResponseIsChangedThroughMiddlewareTheNewInstanceIsUsed(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + // Connectors are read-only, so the middleware is registered on the request instead of the connector. + $request = new UserRequest; + + $request->middleware()->onResponse(function (Response $response): Response { + // Let's modify the body while sending! + $psrResponse = $response->toPsrResponse(); + $newPsrResponse = $psrResponse->withBody(Utils::streamFor('Hello World!')); + + return $response::fromResponse(new HttpResponse($newPsrResponse), $response->pendingRequest(), $response->toPsrRequest()); + }); + + $response = (new TestConnector)->send($request, $mockClient); + + $this->assertSame('Hello World!', $response->body()); + $this->assertSame(['X-Custom-Header' => ['Howdy']], $response->headers()); + } + + // The case that reads a real response as a raw resource runs against the engine test server in + // tests/Integration/Saloon/Unit/ResponseTest. + + public function testYouCanGetTheResponseStreamAsARawResourceWithAMockResponse(): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $resource = $response->getRawStream(); + + $this->assertIsResource($resource); + + $this->assertSame('{"foo":"bar"}', stream_get_contents($resource)); + } + + #[DataProvider('saveDestinations')] + public function testYouCanGetSaveTheResponseToAFile(bool $usePath): void + { + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $path = $this->tempDir . '/streamToFile.json'; + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + $response->saveBodyToFile($usePath ? $path : fopen($path, 'wb+')); + + $this->assertSame('{"foo":"bar"}', file_get_contents($path)); + } + + /** + * Get whether the body is saved to a path or a resource. + * + * @return array + */ + public static function saveDestinations(): array + { + return [ + 'path' => [true], + 'resource' => [false], + ]; + } + + public function testTheResponseIsMacroable(): void + { + Response::macro('yee', fn (): string => 'haw'); + + $mockClient = new MockClient([ + MockResponse::make(['foo' => 'bar'], 200, ['X-Custom-Header' => 'Howdy']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame('haw', $response->yee()); + } + + public function testCanDetermineIfResponseIsJson(): void + { + $mockClient = new MockClient([ + // JSON content type + MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json']), + // JSON with charset + MockResponse::make(['foo' => 'bar'], 200, ['Content-Type' => 'application/json; charset=utf-8']), + // JSON with lowercase header (e.g. FastAPI, HTTP/2) + MockResponse::make(['foo' => 'bar'], 200, ['content-type' => 'application/json']), + // Non-JSON content type + MockResponse::make('plain text', 200, ['Content-Type' => 'text/plain']), + // No content type + MockResponse::make('no content type', 200, []), + ]); + + $connector = new TestConnector; + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isJson()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isJson()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isJson()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertFalse($response->isJson()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertFalse($response->isJson()); + } + + public function testCanDetermineIfResponseIsXml(): void + { + $mockClient = new MockClient([ + // XML content type + MockResponse::make('', 200, ['Content-Type' => 'application/xml']), + // XML with charset + MockResponse::make('', 200, ['Content-Type' => 'text/xml; charset=utf-8']), + // XML with lowercase header (e.g. FastAPI, HTTP/2) + MockResponse::make('', 200, ['content-type' => 'application/xml']), + // Non-XML content type + MockResponse::make('plain text', 200, ['Content-Type' => 'text/plain']), + // No content type + MockResponse::make('no content type', 200, []), + ]); + + $connector = new TestConnector; + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isXml()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isXml()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertTrue($response->isXml()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertFalse($response->isXml()); + + $response = $connector->send(new UserRequest, $mockClient); + $this->assertFalse($response->isXml()); + } + + public function testHeaderLookupIsCaseInsensitivePerHttpRfc(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 200, ['x-my-custom-header' => 'custom-value']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame('custom-value', $response->header('X-My-Custom-Header')); + $this->assertSame('custom-value', $response->header('x-my-custom-header')); + } + + public function testIsJsonLookupCanUseCaseInsensitiveHeaders(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 200, ['content-type' => 'application/JSON']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($response->isJson()); + $this->assertFalse($response->isXml()); + } + + public function testIsXmlLookupCanUseCaseInsensitiveHeaders(): void + { + $mockClient = new MockClient([ + MockResponse::make([], 200, ['content-type' => 'text/xml']), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertTrue($response->isXml()); + $this->assertFalse($response->isJson()); + } + + public function testArrayAcceptsAnIntegerKey(): void + { + $mockClient = new MockClient([ + MockResponse::make([['name' => 'Sam'], ['name' => 'Taylor']]), + ]); + + $response = (new TestConnector)->send(new UserRequest, $mockClient); + + $this->assertSame(['name' => 'Taylor'], $response->array(1)); + $this->assertSame('Taylor', $response->array('1.name')); + $this->assertSame('missing', $response->array(2, 'missing')); + } + + public function testBuiltInExceptionTypesAndTruncationArePreserved(): void + { + $clientResponse = $this->response(422)->truncateExceptionsAt(37); + $serverResponse = $this->response(500)->truncateExceptionsAt(38); + $request = new ResponseRequestStub; + $request->throwVerdict = true; + $generalResponse = $this->response(200, request: $request)->truncateExceptionsAt(39); + + $clientException = $clientResponse->toException(); + $serverException = $serverResponse->toException(); + $generalException = $generalResponse->toException(); + + $this->assertInstanceOf(ClientException::class, $clientException); + $this->assertSame(37, $clientException->truncateExceptionsAt); + $this->assertInstanceOf(ServerException::class, $serverException); + $this->assertSame(38, $serverException->truncateExceptionsAt); + $this->assertInstanceOf(RequestException::class, $generalException); + $this->assertSame(39, $generalException->truncateExceptionsAt); + } + + public function testRequestFailureAndExceptionVerdictsTakePriorityOverConnectorVerdicts(): void + { + $connector = new ResponseConnectorStub; + $connector->failureVerdict = true; + $connector->customException = true; + + $request = new ResponseRequestStub; + $request->failureVerdict = false; + $request->throwVerdict = true; + $request->customException = true; + + $response = $this->response(500, connector: $connector, request: $request); + + $this->assertFalse($response->failed()); + $this->assertInstanceOf(ResponseRequestExceptionStub::class, $response->toException()); + } + + public function testFailurePoliciesDriveOnErrorAndFailureNamedThrowingHelpers(): void + { + $connector = new ResponseConnectorStub; + $connector->failureVerdict = true; + $response = $this->response(200, connector: $connector); + $called = false; + + $response->onError(function () use (&$called): void { + $called = true; + }); + + $this->assertTrue($response->successful()); + $this->assertTrue($response->failed()); + $this->assertTrue($called); + + $request = new ResponseRequestStub; + $request->failureVerdict = false; + $suppressed = $this->response(404, connector: $connector, request: $request); + + $this->assertSame($suppressed, $suppressed->throwIfClientError()); + + $this->expectException(ClientException::class); + $suppressed->throwIfStatus(404); + } + + public function testThrowPolicyCanSuppressAResponseThatStillReportsFailed(): void + { + $connector = new ResponseConnectorStub; + $connector->throwVerdict = false; + $request = new ResponseRequestStub; + $request->throwVerdict = false; + $response = $this->response(500, connector: $connector, request: $request); + + $this->assertTrue($response->failed()); + $this->assertNull($response->toException()); + $this->assertSame($response, $response->throw()); + } + + public function testNonSeekableBodyIsBufferedOnce(): void + { + $pendingRequest = new PendingRequest(new ResponseConnectorStub, new ResponseRequestStub); + $psrRequest = new PsrRequest('GET', 'https://api.example.com/users'); + $httpResponse = new HttpResponse(new PsrResponse( + body: new NoSeekStream(Utils::streamFor('response body')), + )); + $response = Response::fromResponse($httpResponse, $pendingRequest, $psrRequest); + + $this->assertSame('response body', $response->body()); + $this->assertSame('response body', $response->body()); + $this->assertTrue($response->stream()->isSeekable()); + $this->assertSame($psrRequest, $response->toPsrRequest()); + } + + public function testNonSeekableBodyConsumptionLeavesLinesAtTheEnd(): void + { + $response = $this->response(200, body: new NoSeekStream(Utils::streamFor("one\ntwo\n"))); + + $this->assertSame("one\ntwo\n", $response->body()); + $this->assertSame(8, $response->stream()->tell()); + $this->assertSame([], iterator_to_array($response->lines())); + + $response->stream()->rewind(); + + $this->assertSame(['one', 'two'], iterator_to_array($response->lines())); + } + + public function testJsonLinesReadANonSeekableResponseFromItsCurrentPosition(): void + { + $stream = new NoSeekStream(Utils::streamFor("skip\n{\"id\":1}\n{\"id\":2}\n")); + $stream->read(5); + $response = $this->response(200, body: $stream); + + $this->assertSame([['id' => 1], ['id' => 2]], iterator_to_array($response->jsonLines())); + $this->assertSame($stream, $response->stream()); + } + + public function testBodyExportsPreservePositionsAndCallerOwnedResources(): void + { + $response = $this->response(200, body: 'response body'); + $response->stream()->seek(4); + $resource = fopen('php://temp', 'wb+'); + $this->assertIsResource($resource); + fwrite($resource, 'stale contents'); + + $response->saveBodyToFile($resource, false); + + $this->assertIsResource($resource); + $this->assertSame(4, $response->stream()->tell()); + $this->assertSame('response body', stream_get_contents($resource)); + fclose($resource); + + $rawStream = $response->getRawStream(); + $this->assertIsResource($rawStream); + $this->assertSame('response body', stream_get_contents($rawStream)); + fclose($rawStream); + } + + public function testFailedBodyExportDoesNotCloseACallerOwnedResource(): void + { + $source = m::mock(StreamInterface::class); + $source->shouldReceive('isSeekable')->once()->andReturn(false); + $source->shouldReceive('eof')->once()->andReturn(false); + $source->shouldReceive('read')->once()->andThrow(new RuntimeException('read failed')); + $response = $this->response(200, body: $source); + $resource = fopen('php://temp', 'wb+'); + $this->assertIsResource($resource); + + try { + $response->saveBodyToFile($resource, false); + $this->fail('The response source should fail while being copied.'); + } catch (RuntimeException $exception) { + $this->assertSame('read failed', $exception->getMessage()); + $this->assertIsResource($resource); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array + { + return [SaloonServiceProvider::class]; + } + + /** + * Set up the test environment. + */ + protected function setUp(): void + { + parent::setUp(); + + $this->tempDir = ParallelTesting::tempDir('SaloonResponseTest'); + (new Filesystem)->deleteDirectory($this->tempDir); + mkdir($this->tempDir, 0777, true); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + /** + * Create a Saloon response for the given operation. + */ + protected function response( + int $status, + StreamInterface|string $body = 'response body', + ?Connector $connector = null, + ?Request $request = null, + ?PendingRequest $pendingRequest = null, + ): Response { + $connector ??= new ResponseConnectorStub; + $request ??= new ResponseRequestStub; + $pendingRequest ??= new PendingRequest($connector, $request); + $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users'); + + return Response::fromResponse( + new HttpResponse(new PsrResponse($status, body: $body)), + $pendingRequest, + $psrRequest, + ); + } +} + +class ResponseConnectorStub extends Connector +{ + public ?bool $failureVerdict = null; + + public ?bool $throwVerdict = null; + + public bool $customException = false; + + /** + * Resolve the integration base URL. + */ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } + + /** + * Determine if the request has failed. + */ + public function hasRequestFailed(Response $response): ?bool + { + return $this->failureVerdict; + } + + /** + * Determine if the response should throw a request exception. + */ + public function shouldThrowRequestException(Response $response): bool + { + return $this->throwVerdict ?? $response->failed(); + } + + /** + * Get the custom request exception. + */ + public function getRequestException(Response $response): ?RequestException + { + return $this->customException ? new ResponseConnectorExceptionStub($response) : null; + } +} + +class ResponseRequestStub extends Request +{ + public ?bool $failureVerdict = null; + + public ?bool $throwVerdict = null; + + public bool $customException = false; + + protected Method $method = Method::GET; + + /** + * Resolve the request endpoint. + */ + public function resolveEndpoint(): string + { + return '/users'; + } + + /** + * Determine if the request has failed. + */ + public function hasRequestFailed(Response $response): ?bool + { + return $this->failureVerdict; + } + + /** + * Determine if the response should throw a request exception. + */ + public function shouldThrowRequestException(Response $response): bool + { + return $this->throwVerdict ?? $response->failed(); + } + + /** + * Get the custom request exception. + */ + public function getRequestException(Response $response): ?RequestException + { + return $this->customException ? new ResponseRequestExceptionStub($response) : null; + } +} + +class ResponseConnectorExceptionStub extends RequestException +{ +} + +class ResponseRequestExceptionStub extends RequestException +{ +} diff --git a/tests/Saloon/Http/SenderTest.php b/tests/Saloon/Unit/SenderTest.php similarity index 60% rename from tests/Saloon/Http/SenderTest.php rename to tests/Saloon/Unit/SenderTest.php index fe5527b04a..0ae623c608 100644 --- a/tests/Saloon/Http/SenderTest.php +++ b/tests/Saloon/Unit/SenderTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Hypervel\Tests\Saloon\Http; +namespace Hypervel\Tests\Saloon\Unit; use GuzzleHttp\Cookie\CookieJarInterface; -use Hypervel\Contracts\Cache\Factory as CacheFactory; +use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Telescope\TelescopeTag; use Hypervel\Http\Client\Factory; use Hypervel\Http\Client\Request as HttpRequest; -use Hypervel\RateLimiter\RateLimiter; use Hypervel\Saloon\Enums\Method; use Hypervel\Saloon\Http\Auth\BasicAuthenticator; use Hypervel\Saloon\Http\Connector; @@ -18,13 +18,59 @@ use Hypervel\Saloon\Http\Request; use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Http\Sender; +use Hypervel\Saloon\SaloonServiceProvider; use Hypervel\Saloon\Traits\Body\HasJsonBody; -use Hypervel\Tests\TestCase; +use Hypervel\Support\Facades\Http; +use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Saloon\Fixtures\Connectors\TestConnector; +use Hypervel\Tests\Saloon\Fixtures\Requests\UserRequest; use Mockery as m; use Psr\Http\Message\RequestInterface; +// Hypervel HTTP is Saloon's only transport. Connectors choose a named HTTP connection instead of a sender. class SenderTest extends TestCase { + public function testTheDefaultConnectionOnAllConnectorsIsTheSaloonConnection(): void + { + $connector = new TestConnector; + $sender = $this->app->make(Sender::class); + + $pendingRequest = $connector->createPendingRequest(new UserRequest); + + $this->assertSame('saloon', $sender->resolveTransport($pendingRequest)['connection']); + + // Test the same instance is re-used + + $this->assertSame($sender, $this->app->make(Sender::class)); + } + + // Upstream also overrides the sender through a connector property; connectors select their connection only + // through resolveHttpConnection(). + public function testYouCanOverwriteTheConnectionOnAConnectorUsingTheResolveHttpConnectionMethod(): void + { + Http::registerConnection('secondary', ['timeout' => 12]); + $timeout = null; + Http::fake(function (HttpRequest $request, array $options) use (&$timeout): PromiseInterface { + $timeout = $options['timeout']; + + return Http::response('Default', 200, ['X-Fake' => 'true']); + }); + + $response = (new SecondaryConnectionConnectorStub)->send(new UserRequest); + + $this->assertSame(12, $timeout); + $this->assertSame('true', $response->header('X-Fake')); + $this->assertSame('Default', $response->body()); + } + + // REMOVED: the case that sets a class which does not implement the sender contract. There is no sender contract. + + // laravel-plugin's Feature/TelescopeMiddlewareTest covers the plugin's own Telescope recording middleware. Saloon + // requests reach Telescope's HTTP client watcher instead, so these cases check the tags and opt-out passed to it, + // and Telescope's ClientRequestWatcherTest covers formatting and masking. + // REMOVED: laravel-plugin's Feature/PulseMiddlewareTest and Feature/NightwatchMiddlewareTest. Hypervel has no Pulse + // or Nightwatch package. + public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection(): void { $http = new Factory; @@ -34,7 +80,7 @@ public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection(): ]); $capturedRequest = null; $capturedOptions = null; - $http->fake(function (HttpRequest $request, array $options) use (&$capturedRequest, &$capturedOptions) { + $http->fake(function (HttpRequest $request, array $options) use (&$capturedRequest, &$capturedOptions): PromiseInterface { $capturedRequest = $request; $capturedOptions = $options; @@ -48,7 +94,7 @@ public function testItSendsTheFinalOperationThroughTheSelectedHttpConnection(): ->withTelescopeTags(['operation']) ->authenticate(new BasicAuthenticator('taylor', 'secret')) ->withData(['name' => 'Taylor']); - $pendingRequest = $this->pendingRequest(new SenderConnectorStub, $request) + $pendingRequest = (new PendingRequest(new SenderConnectorStub, $request)) ->applyAuthentication() ->executeRequestPipeline() ->finalizeUri() @@ -80,13 +126,13 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag(): $http = new Factory; $http->registerConnection('saloon'); $capturedOptions = null; - $http->fake(function (HttpRequest $request, array $options) use (&$capturedOptions) { + $http->fake(function (HttpRequest $request, array $options) use (&$capturedOptions): PromiseInterface { $capturedOptions = $options; return Factory::response(); }); $request = (new SenderRequestStub)->withoutTelescope(); - $pendingRequest = $this->pendingRequest(new SenderConnectorStub, $request) + $pendingRequest = (new PendingRequest(new SenderConnectorStub, $request)) ->finalizeUri() ->prepareBody(); $sender = new Sender($http, $this->config()); @@ -97,24 +143,22 @@ public function testOperationCanDisableTelescopeWithoutLosingTheCanonicalTag(): $this->assertSame([TelescopeTag::Saloon], $capturedOptions['telescope_tags']); } - protected function setUp(): void + /** + * Get the package providers. + */ + protected function getPackageProviders(ApplicationContract $app): array { - parent::setUp(); - - SenderRequestStub::$psrHookCalls = 0; + return [SaloonServiceProvider::class]; } /** - * Create a pending request with isolated framework dependencies. + * Set up the test environment. */ - protected function pendingRequest(Connector $connector, Request $request): PendingRequest + protected function setUp(): void { - return new PendingRequest( - $connector, - $request, - m::mock(CacheFactory::class), - m::mock(RateLimiter::class), - ); + parent::setUp(); + + SenderRequestStub::$psrHookCalls = 0; } /** @@ -131,13 +175,30 @@ protected function config(): ConfigRepository } } +class SecondaryConnectionConnectorStub extends TestConnector +{ + /** + * Resolve the HTTP connection used by this connector. + */ + public function resolveHttpConnection(): ?string + { + return 'secondary'; + } +} + class SenderConnectorStub extends Connector { + /** + * Resolve the integration base URL. + */ public function resolveBaseUrl(): string { return 'https://api.example.com'; } + /** + * Resolve the default query parameters. + */ protected function defaultQuery(): array { return ['version' => 1]; @@ -154,11 +215,17 @@ class SenderRequestStub extends Request protected ?string $response = SenderResponseStub::class; + /** + * Resolve the request endpoint. + */ public function resolveEndpoint(): string { return '/users'; } + /** + * Modify the final PSR request. + */ public function handlePsrRequest(RequestInterface $request, PendingRequest $pendingRequest): RequestInterface { ++static::$psrHookCalls; diff --git a/tests/Saloon/Unit/URLHelperTest.php b/tests/Saloon/Unit/URLHelperTest.php new file mode 100644 index 0000000000..7b99a6bc48 --- /dev/null +++ b/tests/Saloon/Unit/URLHelperTest.php @@ -0,0 +1,307 @@ +assertSame($expected, (string) UrlResolver::resolve($baseUrl, $endpoint, false)); + } + + /** + * Get the base URLs and endpoints to join. + * + * Upstream's last case joins an empty base URL and a relative endpoint into "/google.com/search", which cannot + * be sent, so it is rejected instead (see testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint). + * + * @return array + */ + public static function urlsToJoin(): array + { + return [ + ['https://google.com', '/search', 'https://google.com/search'], + ['https://google.com', 'search', 'https://google.com/search'], + ['https://google.com/', '/search', 'https://google.com/search'], + ['https://google.com/', 'search', 'https://google.com/search'], + ['https://google.com//', '//search', 'https://google.com/search'], + ]; + } + + public function testJoinThrowsWhenEndpointIsAnAbsoluteUrlToPreventSsrfAndCredentialLeakage(): void + { + $trustedBaseUrl = 'https://api.trusted.com'; + $attackerUrl = 'https://attacker.example.com/steal'; + + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.'); + + UrlResolver::resolve($trustedBaseUrl, $attackerUrl, false); + } + + public function testJoinAllowsAbsoluteEndpointWhenAllowBaseUrlOverrideIsTrue(): void + { + $this->assertSame( + 'https://auth.provider.com/token', + (string) UrlResolver::resolve('https://api.example.com', 'https://auth.provider.com/token', true), + ); + } + + public function testJoinWithEmptyBaseAndAbsoluteEndpointIgnoresAllowBaseUrlOverrideFlag(): void + { + $this->assertSame( + 'https://solo.example.com/hook', + (string) UrlResolver::resolve('', 'https://solo.example.com/hook', false), + ); + } + + public function testCreatingAPendingRequestWithARequestThatReturnsAbsoluteUrlFromResolveEndpointThrows(): void + { + $connector = new TestConnector('https://api.trusted.com'); + $request = new AbsoluteEndpointRequest('https://attacker.example.com/callback'); + + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIsOrContains('The request endpoint cannot replace the connector base URL.'); + + // The URL is resolved when it is first read rather than when the pending request is created. + $connector->createPendingRequest($request)->uri(); + } + + public function testConnectorAllowBaseUrlOverrideAllowsPendingRequestWithAbsoluteEndpoint(): void + { + $connector = new AllowsBaseUrlOverrideConnector('https://api.trusted.com'); + $request = new AbsoluteEndpointRequest('https://other-api.example.com/v1'); + + $pending = $connector->createPendingRequest($request); + + $this->assertSame('https://other-api.example.com/v1', (string) $pending->uri()); + } + + public function testRequestAllowBaseUrlOverrideFalseDeniesAbsoluteEndpointEvenWhenConnectorAllows(): void + { + $connector = new AllowsBaseUrlOverrideConnector('https://api.trusted.com'); + $request = new AbsoluteEndpointRequest('https://other.example.com/x', false); + + $this->expectException(PendingRequestException::class); + + $connector->createPendingRequest($request)->uri(); + } + + // Upstream parses the existing URL query into an array and rebuilds it. Hypervel keeps the existing pairs as + // written and only replaces the names being merged, so this dataset checks what is kept. + #[DataProvider('existingQueries')] + public function testTheUrlHelperCanParseAVarietyOfQueryParameters(string $query, string $expected): void + { + $uri = UrlResolver::withQuery(new Uri('https://api.example.com/users?' . $query), ['page' => 2]); + + $this->assertSame($expected, $uri->getQuery()); + } + + /** + * Get existing URL queries and the result of merging a parameter into them. + * + * @return array + */ + public static function existingQueries(): array + { + return [ + ['foo=bar', 'foo=bar&page=2'], + ['foo=bar&name=sam', 'foo=bar&name=sam&page=2'], + ['foo==bar&name=sam', 'foo==bar&name=sam&page=2'], + ['=abc&name=sam', '=abc&name=sam&page=2'], + ['foo&name=sam', 'foo&name=sam&page=2'], + ['account.id=1', 'account.id=1&page=2'], + ['name=cowboy%20sam', 'name=cowboy%20sam&page=2'], + ['name=sam&', 'name=sam&page=2'], + ['&name=sam&&page=1', 'name=sam&page=2'], + ]; + } + + #[DataProvider('relativeEndpoints')] + public function testEndpointsWithoutASchemeStayOnTheConnectorHost(string $endpoint, string $expected): void + { + $this->assertSame($expected, (string) UrlResolver::resolve('https://api.example.com/v1', $endpoint, false)); + } + + /** + * Get endpoints that URI parsing would otherwise read as a scheme or a host. + * + * @return array + */ + public static function relativeEndpoints(): array + { + return [ + 'colon in the first segment' => ['documents:batchGet', 'https://api.example.com/v1/documents:batchGet'], + 'colon with a query' => ['users:search?q=sam', 'https://api.example.com/v1/users:search?q=sam'], + 'host-looking path' => ['//evil.example.com/steal', 'https://api.example.com/v1/evil.example.com/steal'], + 'scheme without slashes' => ['mailto:sam@example.com', 'https://api.example.com/v1/mailto:sam@example.com'], + ]; + } + + public function testRelativeEndpointIsJoinedToTheConnectorBaseUrl(): void + { + $uri = UrlResolver::resolve('https://api.example.com/v1/', '/users?active=1', false); + + $this->assertSame('https://api.example.com/v1/users?active=1', (string) $uri); + } + + public function testEmptyEndpointPreservesTheConnectorBasePath(): void + { + $this->assertSame( + 'https://api.example.com/v1', + (string) UrlResolver::resolve('https://api.example.com/v1', '', false), + ); + $this->assertSame( + 'https://api.example.com/v1/', + (string) UrlResolver::resolve('https://api.example.com/v1', '/', false), + ); + } + + public function testBaseAndEndpointQueriesAreRetainedBeforeRepositoryOverrides(): void + { + $this->assertSame( + 'https://api.example.com/v1/users?key=base&keep=base', + (string) UrlResolver::resolve('https://api.example.com/v1?key=base&keep=base', '/users', false), + ); + + $uri = UrlResolver::resolve( + 'https://api.example.com/v1?key=base&keep=base', + '/users?endpoint=value', + false, + ); + + $this->assertSame('key=base&keep=base&endpoint=value', $uri->getQuery()); + $this->assertSame( + 'keep=base&endpoint=value&key=repository', + UrlResolver::withQuery($uri, ['key' => 'repository'])->getQuery(), + ); + } + + public function testEmptyBaseUrlRequiresAnAbsoluteHttpEndpoint(): void + { + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIs('A request without a connector base URL must use an absolute HTTP or HTTPS endpoint.'); + + UrlResolver::resolve('', 'google.com/search', true); + } + + #[DataProvider('alternateSchemeBaseUrls')] + public function testAlternateSchemesAreRejected(string $baseUrl): void + { + $this->expectException(PendingRequestException::class); + $this->expectExceptionMessageIs('The request endpoint must be an absolute HTTP or HTTPS URI.'); + + UrlResolver::resolve($baseUrl, 'file:///etc/passwd', true); + } + + /** + * Get the base URLs an alternate scheme endpoint cannot replace. + * + * @return array + */ + public static function alternateSchemeBaseUrls(): array + { + return [ + 'without a base URL' => [''], + 'with a base URL' => ['https://api.example.com'], + ]; + } + + public function testRepositoryQueryReplacesExactAndNestedRawFamilies(): void + { + $uri = UrlResolver::resolve( + 'https://api.example.com', + '/users?filter=old&filter%5Bname%5D=Taylor&keep=one&keep=two&flag', + false, + ); + + $resolved = UrlResolver::withQuery($uri, [ + 'filter' => ['name' => 'Abigail'], + 'page' => 2, + ]); + + $this->assertSame( + 'keep=one&keep=two&flag&filter%5Bname%5D=Abigail&page=2', + $resolved->getQuery(), + ); + } + + public function testNullAndEmptyArrayValuesRemoveMatchingRawFamilies(): void + { + $uri = UrlResolver::resolve( + 'https://api.example.com', + '/users?remove=one&remove%5Bnested%5D=two&empty%5B0%5D=value&keep=yes', + false, + ); + + $resolved = UrlResolver::withQuery($uri, ['remove' => null, 'empty' => []]); + + $this->assertSame('keep=yes', $resolved->getQuery()); + } + + public function testDecodedNamesUseQueryStringSemanticsWithoutNormalizingRetainedPairs(): void + { + $uri = UrlResolver::resolve( + 'https://api.example.com', + '/users?first+name=old&retained=a+b&literal=%7Bvalue%7D', + false, + ); + + $resolved = UrlResolver::withQuery($uri, ['first name' => 'Taylor']); + + $this->assertSame('retained=a+b&literal=%7Bvalue%7D&first%20name=Taylor', $resolved->getQuery()); + } + + public function testStructuredValuesAreNormalized(): void + { + $uri = UrlResolver::resolve('https://api.example.com', '/users', false); + + $resolved = UrlResolver::withQuery($uri, [ + 'collection' => new Collection(['one', 'two']), + 'stringable' => new Stringable('value'), + 'not-a-number' => NAN, + ]); + + $this->assertSame( + 'collection%5B0%5D=one&collection%5B1%5D=two&stringable=value¬-a-number=NAN', + $resolved->getQuery(), + ); + } +} + +// Upstream sets the public $allowBaseUrlOverride property on a connector instance. Hypervel connectors are +// read-only and declare it by overriding allowsBaseUrlOverride(). +class AllowsBaseUrlOverrideConnector extends TestConnector +{ + /** + * Resolve whether requests may replace this connector's base URL. + */ + public function allowsBaseUrlOverride(): bool + { + return true; + } +} diff --git a/tests/Support/SupportMacroableTest.php b/tests/Support/SupportMacroableTest.php index d372ba0c4c..5526fa9abe 100644 --- a/tests/Support/SupportMacroableTest.php +++ b/tests/Support/SupportMacroableTest.php @@ -76,6 +76,16 @@ public function testWhenCallingMacroClosureIsBoundToObject(): void $result = TestMacroable::tryStatic(); $this->assertSame('static', $result); + + TestMacroable::macro('tryPrivateInstance', function (): string { + return $this->privateVariable; + }); + TestMacroable::macro('tryPrivateStatic', function (): string { + return $this::getPrivateStatic(); + }); + + $this->assertSame('private instance', $instance->tryPrivateInstance()); + $this->assertSame('private static', $instance->tryPrivateStatic()); } public function testClassBasedMacros(): void @@ -263,10 +273,20 @@ class TestMacroable protected string $protectedVariable = 'instance'; + private string $privateVariable = 'private instance'; + protected static function getProtectedStatic(): string { return 'static'; } + + /** + * Get a value only visible inside the class. + */ + private static function getPrivateStatic(): string + { + return 'private static'; + } } class TestMixin diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index 2bcd6cf3e4..2a1b5f3760 100644 --- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php +++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php @@ -211,6 +211,41 @@ public static function jsonContentTypeProvider(): array ]; } + #[DataProvider('jsonMediaTypeRequestProvider')] + public function testValidJsonMediaTypeRequestIsDecoded(string $contentType, string $body, array $payload): void + { + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/raw', ['Content-Type' => $contentType], $body), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame($payload, $entry->content['payload']); + } + + /** + * Provide JSON media types and payloads. + */ + public static function jsonMediaTypeRequestProvider(): array + { + return [ + 'hal+json' => [ + 'application/hal+json', + '{"_links":{"self":{"href":"/api/users/1"}},"name":"Test"}', + ['_links' => ['self' => ['href' => '/api/users/1']], 'name' => 'Test'], + ], + 'hal+json with charset' => ['application/hal+json; charset=utf-8', '{"id":1}', ['id' => 1]], + 'vnd.api+json' => [ + 'application/vnd.api+json', + '{"data":{"type":"users","id":"1"}}', + ['data' => ['type' => 'users', 'id' => '1']], + ], + ]; + } + public function testHeaderlessJsonRequestIsMaskedAndDeepHeaderlessJsonIsPurged(): void { Telescope::hideRequestParameters(['password']); @@ -336,6 +371,58 @@ public function testClientRequestWatcherPlainTextResponse(): void $this->assertSame('plain telescope response', $entry->content['response']); } + #[DataProvider('scalarResponseProvider')] + public function testScalarResponseIsRecordedAsSentUnderAJsonMediaType(string $contentType, string $body, string $recorded): void + { + $client = $this->makeClient([new Response(200, ['Content-Type' => $contentType], $body)]); + + $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/scalar')); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame($recorded, $entry->content['response']); + } + + /** + * Provide scalar response bodies and their recorded values. + */ + public static function scalarResponseProvider(): array + { + return [ + 'zero' => ['application/json', '0', '0'], + 'false' => ['application/json', 'false', 'false'], + 'null' => ['application/json', 'null', 'null'], + 'string' => ['application/json', '"ok"', '"ok"'], + '+json with charset' => ['application/problem+json; charset=utf-8', '42', '42'], + 'malformed json' => ['application/json', '{"id":', 'HTML Response'], + 'html' => ['text/html', '0', 'HTML Response'], + ]; + } + + public function testHalJsonResponseIsDecodedAndMasked(): void + { + Telescope::hideResponseParameters(['access_token']); + $client = $this->makeClient([ + new Response(200, ['Content-Type' => 'application/hal+json'], json_encode([ + '_links' => ['self' => ['href' => '/api/users/1']], + 'name' => 'Test', + 'access_token' => 'secret-token-value', + 'expires_in' => 3600, + ])), + ]); + + $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/hal')); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame([ + '_links' => ['self' => ['href' => '/api/users/1']], + 'name' => 'Test', + 'access_token' => '********', + 'expires_in' => 3600, + ], $entry->content['response']); + } + public function testClientRequestWatcherRegistersServerErrorResponse(): void { $client = $this->makeClient([ @@ -819,6 +906,43 @@ public function testOversizedResponseIsPurgedByDefault(): void $this->assertSame('Purged By Telescope', $entry->content['response']); } + #[WithConfig('telescope.watchers', [ + ClientRequestWatcher::class => [ + 'enabled' => true, + 'response_size_limit' => 1, + ], + ])] + public function testOversizedScalarJsonResponseIsPurgedByDefault(): void + { + $body = '"' . str_repeat('x', 2000) . '"'; + $client = $this->makeClient([new Response(200, ['Content-Type' => 'application/json'], $body)]); + + $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/raw')); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('Purged By Telescope', $entry->content['response']); + } + + #[WithConfig('telescope.watchers', [ + ClientRequestWatcher::class => [ + 'enabled' => true, + 'response_size_limit' => 1, + 'truncate_oversized' => true, + ], + ])] + public function testOversizedPlainTextResponseIsTruncated(): void + { + $body = str_repeat('x', 2000); + $client = $this->makeClient([new Response(200, ['Content-Type' => 'text/plain'], $body)]); + + $this->executeTransfer($client, new Request('GET', 'https://hypervel.org/raw')); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame(substr($body, 0, 1024) . ' (truncated...)', $entry->content['response']); + } + #[WithConfig('telescope.watchers', [ ClientRequestWatcher::class => [ 'enabled' => true, diff --git a/tests/Telescope/Watchers/RequestWatchersTest.php b/tests/Telescope/Watchers/RequestWatchersTest.php index 5b7839d651..851e5db64f 100644 --- a/tests/Telescope/Watchers/RequestWatchersTest.php +++ b/tests/Telescope/Watchers/RequestWatchersTest.php @@ -5,7 +5,9 @@ namespace Hypervel\Tests\Telescope\Watchers; use Closure; +use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; +use Hypervel\Http\Response as HttpResponse; use Hypervel\Http\UploadedFile; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Routing\Controllers\HasMiddleware; @@ -20,6 +22,7 @@ use Hypervel\Telescope\Watchers\RequestWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; #[WithConfig('telescope.watchers', [ @@ -298,6 +301,51 @@ public function testRequestWatcherPlainTextResponse(): void $this->assertSame('plain telescope response', $entry->content['response']); } + #[DataProvider('scalarResponseProvider')] + public function testRequestWatcherRecordsScalarResponseAsSentUnderAJsonMediaType(string $contentType, string $body, string $recorded): void + { + Route::get('/scalar', fn (): HttpResponse => Response::make($body, 200, ['Content-Type' => $contentType])); + + $this->get('/scalar')->assertSuccessful(); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame($recorded, $entry->content['response']); + } + + /** + * Provide scalar response bodies and their recorded values. + */ + public static function scalarResponseProvider(): array + { + return [ + 'zero' => ['application/json', '0', '0'], + 'false' => ['application/json', 'false', 'false'], + 'null' => ['application/json', 'null', 'null'], + 'string' => ['application/json', '"ok"', '"ok"'], + '+json with charset' => ['application/problem+json; charset=utf-8', '42', '42'], + 'malformed json' => ['application/json', '{"id":', 'HTML Response'], + 'html' => ['text/html', '0', 'HTML Response'], + ]; + } + + #[WithConfig('telescope.watchers', [ + RequestWatcher::class => [ + 'enabled' => true, + 'size_limit' => 1, + ], + ])] + public function testRequestWatcherPurgesOversizedScalarJsonResponse(): void + { + Route::get('/scalar', fn (): JsonResponse => response()->json(str_repeat('x', 2000))); + + $this->get('/scalar')->assertSuccessful(); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame(Telescope::PURGED_VALUE, $entry->content['response']); + } + public function testRequestWatcherRecordsPlainTextPayload(): void { Route::post('/receive-plain-text', function () {