From 9b739a54685b51f68fcaaf6b525883770b859859 Mon Sep 17 00:00:00 2001 From: Viet Vu Date: Wed, 19 Aug 2026 15:07:57 -0600 Subject: [PATCH] feat: Add async middleware, HTTP error mapping, schema validation, useragent, and failover Include the remaining 3.0 items in the recut: promise-based first-party middleware, status-to-exception mapping after retry, optional JSON Schema body validation, optional jooservices/useragent helpers, and curl/Guzzle transport failover. --- AGENTS.md | 2 +- CHANGELOG.md | 5 + README.md | 12 +- UPGRADE-3.0.md | 10 + composer.json | 3 + composer.lock | 150 ++++++- .../00-architecture/04-modules-and-domains.md | 11 +- .../business-context-and-goals.md | 2 +- docs/02-user-guide/api-reference.md | 49 ++- docs/02-user-guide/classes-reference.md | 15 +- .../08-async-schema-ua-failover.php | 38 ++ docs/03-examples/README.md | 1 + docs/05-maintenance/BACKLOG-POST-2.0.md | 20 +- src/Adapters/Curl/CurlHttpClientAdapter.php | 6 +- src/Adapters/FailoverTransportAdapter.php | 80 ++++ src/Adapters/MiddlewareTransportAdapter.php | 45 +- src/Client/ClientBuilder.php | 182 +++++++- src/Contracts/AsyncMiddlewareInterface.php | 26 ++ src/Middleware/ApiVersionMiddleware.php | 12 +- src/Middleware/AuthenticationMiddleware.php | 12 +- src/Middleware/BulkheadMiddleware.php | 41 +- src/Middleware/CacheMiddleware.php | 50 ++- src/Middleware/CircuitBreakerMiddleware.php | 55 ++- src/Middleware/CorrelationIdMiddleware.php | 45 +- src/Middleware/DeadlineMiddleware.php | 21 +- src/Middleware/FallbackMiddleware.php | 38 +- src/Middleware/HttpErrorMappingMiddleware.php | 67 +++ src/Middleware/IdempotencyKeyMiddleware.php | 20 +- src/Middleware/InterceptorMiddleware.php | 37 +- src/Middleware/LoggingMiddleware.php | 136 ++++-- src/Middleware/MetricsMiddleware.php | 36 +- src/Middleware/MiddlewarePipeline.php | 112 +++-- .../OAuthTokenRefreshMiddleware.php | 45 +- src/Middleware/ProgressMiddleware.php | 27 +- src/Middleware/RateLimitMiddleware.php | 46 +- .../RequestCoalescingMiddleware.php | 40 +- src/Middleware/RequestSigningMiddleware.php | 12 +- .../ResponseValidationMiddleware.php | 27 +- src/Middleware/RetryMiddleware.php | 61 ++- src/Middleware/TraceContextMiddleware.php | 40 +- src/Middleware/UserAgentMiddleware.php | 44 +- src/Support/ConnectionReuseTracker.php | 3 +- src/Support/MiddlewarePromise.php | 50 +++ src/Validation/JsonSchemaBodyValidator.php | 120 +++++ src/Validation/ResponseValidationConfig.php | 11 + .../CurlMultiBatchClientIntegrationTest.php | 8 + .../Adapters/FailoverTransportAdapterTest.php | 157 +++++++ tests/Unit/Client/ClientBuilderDxTest.php | 180 ++++++++ .../ClientBuilderNewFeaturesCoverageTest.php | 410 ++++++++++++++++++ .../AsyncFirstPartyMiddlewareTest.php | 375 ++++++++++++++++ .../AsyncMiddlewarePipelineTest.php | 207 +++++++++ .../HttpErrorMappingMiddlewareTest.php | 73 ++++ .../Middleware/InterceptorOnErrorTest.php | 16 + .../MiddlewarePipelineCoverageTest.php | 7 +- .../Middleware/UserAgentMiddlewareTest.php | 31 ++ tests/Unit/Support/MiddlewarePromiseTest.php | 52 +++ .../JsonSchemaBodyValidatorTest.php | 85 ++++ 57 files changed, 3241 insertions(+), 225 deletions(-) create mode 100644 docs/03-examples/08-async-schema-ua-failover.php create mode 100644 src/Adapters/FailoverTransportAdapter.php create mode 100644 src/Contracts/AsyncMiddlewareInterface.php create mode 100644 src/Middleware/HttpErrorMappingMiddleware.php create mode 100644 src/Support/MiddlewarePromise.php create mode 100644 src/Validation/JsonSchemaBodyValidator.php create mode 100644 tests/Unit/Adapters/FailoverTransportAdapterTest.php create mode 100644 tests/Unit/Client/ClientBuilderNewFeaturesCoverageTest.php create mode 100644 tests/Unit/Middleware/AsyncFirstPartyMiddlewareTest.php create mode 100644 tests/Unit/Middleware/AsyncMiddlewarePipelineTest.php create mode 100644 tests/Unit/Middleware/HttpErrorMappingMiddlewareTest.php create mode 100644 tests/Unit/Support/MiddlewarePromiseTest.php create mode 100644 tests/Unit/Validation/JsonSchemaBodyValidatorTest.php diff --git a/AGENTS.md b/AGENTS.md index 0e4ab99..41ef8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,6 @@ This repository builds `jooservices/client`, a PHP 8.5+ HTTP client package unde - Use the canonical product name **JOOservices Client**; use `jooservices/client` only as the Composer identifier. - When public behavior changes, update README, `docs/`, AI skills, contributor guidance, `CHANGELOG.md`, and the relevant upgrade guide in the same change. -- Document only wired runtime behavior. Mark known limitations explicitly, especially the async middleware limitation and opt-in body/WAN-IP logging. +- Document only wired runtime behavior. Mark known limitations explicitly, especially opt-in body/WAN-IP logging, the blocking retry delay on the async path, and the blocking fallback for third-party sync-only middleware. - For 3.0+, document Guzzle `^8.0` (and 7.10+ compatibility where shipped), PSR-3-only logging via `withLogger()`, and the absence of Laravel application integration and package Mongo/MySQL/Monolog sinks. - Keep release workflows aligned with Composer requirements and required integration services. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70be28d..542d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection reuse metrics: `MetricsRecorderInterface::recordConnectionReused()` populated from Guzzle handler stats or libcurl connection ids, recorded by `MetricsMiddleware`. - PHP 8.5 property hooks in `ClientConfig` (validated `timeout` / `connectTimeout`) and computed `partitionKeyResolver` properties on `CircuitBreakerConfig`, `RateLimitConfig`, and `BulkheadConfig`. - Shared `Support\RetryAfterHeader` parser used by retry and rate-limit middleware. +- Async-safe middleware pipeline: first-party middleware implements `AsyncMiddlewareInterface` and chains Guzzle promises without `wait()`. Third-party `MiddlewareInterface` implementations still run through a blocking fallback. Retry backoff still uses `SleeperInterface` (blocking delay). +- `ClientBuilder::withHttpErrorMapping(?array $statuses = null, int $minStatus = 400)` / `HttpErrorMappingMiddleware`: map HTTP error statuses to `HttpResponseException` after inner middleware, so retry still sees raw status codes. Distinct from transport-level `withHttpErrors()`. +- JSON Schema / OpenAPI response validation: `JsonSchemaBodyValidator`, `ResponseValidationConfig::jsonSchema()`, and `ClientBuilder::withJsonSchemaValidation()`. Requires optional `justinrainbow/json-schema` (suggested; fail-closed when missing). +- Optional `jooservices/useragent` integration: `withUserAgent(string|callable(): string)`, `withGeneratedUserAgent()`, and `withRotatingUserAgent()`. Generated helpers fail closed with `InvalidConfigurationException` when the package is missing and no callable is passed. Realistic browser UAs are never the default. +- Transport failover: `FailoverTransportAdapter` + `ClientBuilder::withFailoverTransport('guzzle'|'curl'|TransportAdapterInterface)`. Fails over on `NetworkConnectionException`, `TimeoutException`, `TransportBusyException`, and `AsyncTransportNotSupportedException` only — never on HTTP 4xx/5xx. Wrapped inside the adapter so middleware is not duplicated. ### Changed - `RateLimitStoreInterface` gained `pauseUntil(string $partitionKey, int $unixTimestampSeconds)`; `InMemoryRateLimitStore` and `Psr16RateLimitStore` honour pauses (most recent hint wins). diff --git a/README.md b/README.md index 410ffe3..1ca87f6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Latest stable release: `v3.0.0` (see [CHANGELOG](./CHANGELOG.md)) - **Strictly Typed**: Configuration object (`ClientConfig`) ensures type safety before requests start (validated via PHP 8.5 property hooks). - **Layered Architecture**: Guzzle (default), native cURL, and cURL multi transports are isolated from core logic. -- **Resilience**: Built-in Retry (Backoff/Jitter + `onRetry` hook), Circuit Breaker (per-host/`partition_key` scoping + open/close hooks), Rate Limit (server `RateLimit-Reset`/`Retry-After` hints), Bulkhead, Fallback, and Deadline middleware. +- **Resilience**: Built-in Retry (Backoff/Jitter + `onRetry` hook), Circuit Breaker (per-host/`partition_key` scoping + open/close hooks), Rate Limit (server `RateLimit-Reset`/`Retry-After` hints), Bulkhead, Fallback, Deadline, HTTP error mapping, and curl↔Guzzle transport failover. +- **Async middleware**: First-party middleware chains Guzzle promises without `wait()` (`AsyncMiddlewareInterface`). - **Observability**: Logging, W3C trace context, metrics (including connection reuse), correlation IDs, and `onError` recovery interceptors. - **Auth**: Bearer, API key, Basic auth, and OAuth token refresh middleware. - **Performance**: < 10μs overhead per request; true concurrency via cURL multi `batch()`. @@ -74,7 +75,14 @@ items may be `RequestInterface`, `callable(): RequestInterface`, or | `allow_redirects` | `max`, `protocols`, `track_redirects`, `strict`, `referer`, `on_redirect` | | `proxy`, `progress`, `cookies`, `version`, `force_ip_resolve`, `on_stats` | `cookies` = jar or `name => value` map; `version` includes HTTP/3 when libcurl supports it | -Non-portable keys (`handler`, `curl`, `delay`, `on_headers`, `read_timeout`) throw. Enable strict mode with `withCurlAdapter(strictPortableOptions: true)` to also reject unknown keys. Async / `batch()` remain Guzzle-only. +Non-portable keys (`handler`, `curl`, `delay`, `on_headers`, `read_timeout`) throw. Enable strict mode with `withCurlAdapter(strictPortableOptions: true)` to also reject unknown keys. Async / `batch()` remain Guzzle-only unless you fail over with `withFailoverTransport('guzzle')`. + +```php +$client = ClientBuilder::create() + ->withTransport('curl') + ->withFailoverTransport('guzzle') + ->build(); +``` ## Quick Start diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index fc3da83..2af8f71 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -36,6 +36,16 @@ Optional WAN/public IP enrichment remains opt-in via `withWanIpProvider()`. - Runtime `monolog/monolog` is **no longer** required by this package. Add it in your application if you use Monolog. - `mongodb/mongodb`, `ext-mongodb`, `ext-pdo`, and `ext-pdo_mysql` are no longer suggested for Client logging. Require them only if your app still uses those sinks. +## 3.0 additions (same major) + +These ship in 3.0.0 alongside the logging break: + +- **Async middleware.** First-party middleware implements `AsyncMiddlewareInterface`. `getAsync()` / `requestAsync()` return pending promises; the stack no longer `wait()`s between layers. Third-party `MiddlewareInterface` classes still block via a Guzzle task fallback. Retry delays still sleep through `SleeperInterface`. +- **Status → exception mapping.** `withHttpErrorMapping()` throws `HttpResponseException` *after* retry has inspected the raw status. `withHttpErrors(true)` still throws at the transport boundary (retry then sees an exception, not a status). Prefer mapping when you want retries on 503/429. +- **JSON Schema validation.** `withJsonSchemaValidation($schema)` requires `composer require justinrainbow/json-schema`. Missing the package fails closed at builder/validator construction. +- **User-Agent helpers.** `withUserAgent()` accepts `string|callable(): string`. `withGeneratedUserAgent()` / `withRotatingUserAgent()` require `jooservices/useragent` unless you pass a callable. Default identity remains `jooservices-client/` — do not use rotating browser-like UAs as the service default. +- **Failover transport.** `withTransport('curl')->withFailoverTransport('guzzle')` (or the reverse, or custom adapters). Only transport-level failures failover; HTTP error statuses do not. + ## Migration checklist 1. Replace every `withMongo*` / `withMySql*` / `withDefaultLogging()` call with `withLogger($yourLogger)`. diff --git a/composer.json b/composer.json index 29b0163..429718b 100644 --- a/composer.json +++ b/composer.json @@ -36,12 +36,15 @@ }, "suggest": { "jooservices/dto": "Optional DTO helpers for ResponseWrapper::toDto()", + "jooservices/useragent": "Optional User-Agent generation for ClientBuilder::withGeneratedUserAgent() / withRotatingUserAgent()", + "justinrainbow/json-schema": "Optional JSON Schema / OpenAPI response validation via ClientBuilder::withJsonSchemaValidation()", "ext-curl": "Required when using ClientBuilder::withCurlAdapter() or withCurlMultiAdapter()/buildCurlMulti()" }, "require-dev": { "captainhook/captainhook": "^5.25", "captainhook/plugin-composer": "^5.3", "friendsofphp/php-cs-fixer": "^3.66", + "justinrainbow/json-schema": "^6.4", "laravel/pint": "^1.18", "mockery/mockery": "^1.6", "phpbench/phpbench": "^1.4", diff --git a/composer.lock b/composer.lock index 7934607..7933ee5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b531de505f29694e491b7df95272193d", + "content-hash": "400a6d93db1e30a8e22446e68e153128", "packages": [ { "name": "guzzlehttp/guzzle", @@ -1783,6 +1783,81 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "justinrainbow/json-schema", + "version": "6.10.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "dev-main", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" + }, + "time": "2026-06-16T20:50:26+00:00" + }, { "name": "laravel/pint", "version": "v1.30.5", @@ -1853,6 +1928,79 @@ }, "time": "2026-08-10T15:35:50+00:00" }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, { "name": "mockery/mockery", "version": "1.6.12", diff --git a/docs/00-architecture/04-modules-and-domains.md b/docs/00-architecture/04-modules-and-domains.md index 0993890..dd9572d 100644 --- a/docs/00-architecture/04-modules-and-domains.md +++ b/docs/00-architecture/04-modules-and-domains.md @@ -96,7 +96,9 @@ $response = $promise->wait(); **Status**: ✅ Confirmed -**Tests**: `tests/Feature/AsyncTest.php`, `tests/Unit/Adapters/GuzzleHttpClientAdapterAsyncTest.php` +**Notes**: First-party middleware implements `AsyncMiddlewareInterface` so `getAsync()` returns a pending promise. Third-party `MiddlewareInterface` implementations still block via a Guzzle task fallback. + +**Tests**: `tests/Feature/AsyncTest.php`, `tests/Unit/Middleware/AsyncMiddlewarePipelineTest.php`, `tests/Unit/Adapters/GuzzleHttpClientAdapterAsyncTest.php` **Confidence**: Confirmed @@ -392,7 +394,12 @@ $client = ClientBuilder::create() **Usage**: ```php $client = ClientBuilder::create() - ->withUserAgent('MyApp/1.0') + ->withUserAgent('MyApp/1.0') // service identity (default intent) + ->build(); + +// Per-request provider (QA / scraping). Do not make this the service default. +$client = ClientBuilder::create() + ->withRotatingUserAgent(static fn (): string => 'Custom/' . bin2hex(random_bytes(2))) ->build(); ``` diff --git a/docs/00-architecture/business-context-and-goals.md b/docs/00-architecture/business-context-and-goals.md index f53175a..dfa8476 100644 --- a/docs/00-architecture/business-context-and-goals.md +++ b/docs/00-architecture/business-context-and-goals.md @@ -24,7 +24,7 @@ The Composer package is `jooservices/client`; the public namespace is `JOOservic ## Deliberate limits -- Async methods return Guzzle promises; the middleware stack still has the documented synchronous `wait()` limitation. +- Async methods return Guzzle promises. First-party middleware chains those promises without `wait()`; third-party sync-only middleware still uses a blocking fallback. Retry backoff still sleeps via `SleeperInterface`. - The native cURL transport supports portable synchronous configuration and package middleware (including when combined with `withAdapter()`), but does not implement async requests, `batch()`, or non-portable Guzzle options (`handler`, `curl`, `delay`, `on_headers`, `read_timeout`, and related keys — these fail closed). - The package does not provide Laravel service providers, facades, or automatic application configuration. - Retry/circuit callbacks remain post-2.0 work, not implied release behavior. diff --git a/docs/02-user-guide/api-reference.md b/docs/02-user-guide/api-reference.md index 9848763..42448ea 100644 --- a/docs/02-user-guide/api-reference.md +++ b/docs/02-user-guide/api-reference.md @@ -215,6 +215,17 @@ $builder->withOption('allow_redirects', ['max' => 5]); --- +#### `withFailoverTransport(string|TransportAdapterInterface $fallback, bool $strictPortableOptions = false): static` + +**Description**: Fail over to a second transport on `NetworkConnectionException`, `TimeoutException`, `TransportBusyException`, or `AsyncTransportNotSupportedException`. HTTP 4xx/5xx never failover. Named fallbacks: `guzzle`, `curl`. Middleware is not duplicated on the fallback attempt. + +**Example**: +```php +$builder->withTransport('curl')->withFailoverTransport('guzzle'); +``` + +--- + #### `withCurlMultiAdapter(bool $strictPortableOptions = false): static` **Description**: Enable the cURL multi transport (same as `withTransport('curl_multi')`). Pair with `buildCurlMulti()`. @@ -430,6 +441,18 @@ implementors must add this method. --- +#### `withJsonSchemaValidation(array|object|string $schema): static` + +**Description**: Validate JSON response bodies against a JSON Schema document (including OpenAPI 3 response schemas that are valid JSON Schema). Requires `justinrainbow/json-schema`. Construction fails closed when the package is not installed. + +--- + +#### `withHttpErrorMapping(?array $statuses = null, int $minStatus = 400): static` + +**Description**: Map HTTP error statuses to `HttpResponseException` after inner middleware, including retry. Default: every status `>= 400`. Unlike `withHttpErrors()`, retry still sees raw status codes. + +--- + #### `withRequestSigning(RequestSignerInterface $signer): static` **Description**: Sign outbound requests (e.g. HMAC webhook signatures). @@ -438,7 +461,7 @@ implementors must add this method. #### `withRequestCoalescing(): static` -**Description**: Deprecated. Registers `RequestCoalescingMiddleware`, which provides no meaningful in-flight GET coalescing benefit under this package's synchronous middleware pipeline (and does not apply to the async promise path). Prefer application-level deduplication. Kept for compatibility until a future major removal. +**Description**: Deprecated. Registers in-process GET coalescing. The async path shares in-flight promises; the sync path still busy-waits. Prefer application-level deduplication. Kept for compatibility until a future major removal. --- @@ -450,7 +473,7 @@ implementors must add this method. #### `withMiddleware(MiddlewareInterface $middleware, string $name): static` -**Description**: Register custom middleware by name (outermost when added first). +**Description**: Register custom middleware by name. Last-pushed middleware is outermost (LIFO, matching Guzzle `HandlerStack::push()`). --- @@ -482,22 +505,30 @@ $builder->withCorrelationId('X-Trace-ID'); --- -#### `withUserAgent(string $userAgent): static` +#### `withUserAgent(string|callable(): string $userAgent): static` -**Description**: Set custom User-Agent header - -**Parameters**: -- `$userAgent` (string): User agent string - -**Returns**: Builder instance +**Description**: Set a User-Agent header. A string is a static service identity (`MyApp/1.0`). A callable is invoked on every request (rotation). Realistic browser-like UAs are never the package default. **Example**: ```php $builder->withUserAgent('MyApp/1.0'); +$builder->withUserAgent(static fn (): string => 'QA-Bot/' . bin2hex(random_bytes(2))); ``` --- +#### `withGeneratedUserAgent(?callable $generator = null): static` + +**Description**: Generate a User-Agent once (sticky). Without `$generator`, requires `jooservices/useragent` (`UserAgent::generate()`) and fails closed when that package is missing. + +--- + +#### `withRotatingUserAgent(?callable $generator = null): static` + +**Description**: Generate a User-Agent on every request. Same fail-closed rule as `withGeneratedUserAgent()` when no callable is passed. Do not use this as the default service identity. + +--- + #### `onRequest(callable $callback): static` **Description**: Add a request interceptor. Interceptors run in registration order, diff --git a/docs/02-user-guide/classes-reference.md b/docs/02-user-guide/classes-reference.md index 07a3b1d..3a7755f 100644 --- a/docs/02-user-guide/classes-reference.md +++ b/docs/02-user-guide/classes-reference.md @@ -32,6 +32,11 @@ Complete reference for all classes in the JOOClient package. - `withCurlAdapter(bool $strictPortableOptions = false): self` - Native synchronous `ext-curl` transport. - `withCurlMultiAdapter(bool $strictPortableOptions = false): self` - Enable the cURL multi transport; pair with `buildCurlMulti()`. - `withTransport('guzzle'|'curl'|'curl_multi', bool $strictPortableOptions = false): self` - Named built-in transport. +- `withFailoverTransport('guzzle'|'curl'|TransportAdapterInterface, bool $strictPortableOptions = false): self` - Fail over on transport-level errors only. +- `withUserAgent(string|callable(): string $userAgent): self` - Static or per-request User-Agent. +- `withGeneratedUserAgent(?callable $generator = null): self` / `withRotatingUserAgent(?callable $generator = null): self` - Optional `jooservices/useragent` helpers (fail closed if missing unless a callable is passed). +- `withHttpErrorMapping(?array $statuses = null, int $minStatus = 400): self` - Map HTTP error statuses to `HttpResponseException` after retry. +- `withJsonSchemaValidation(array|object|string $schema): self` - JSON Schema / OpenAPI response body validation (requires `justinrainbow/json-schema`). - `onRequest(callable(RequestInterface, array): RequestInterface $callback): self` - Transform the request once before it's sent. - `onResponse(callable(ResponseInterface, array): ResponseInterface $callback): self` - Transform the final response. - `onError(callable(Throwable|ResponseInterface, array): (Throwable|ResponseInterface) $callback): self` - Observe or recover from the request's terminal failure, after retry/rate-limit have run. @@ -110,7 +115,7 @@ Complete reference for all classes in the JOOClient package. ## Middleware Classes -All middleware implements `JOOservices\Client\Contracts\MiddlewareInterface`. +All middleware implements `JOOservices\Client\Contracts\MiddlewareInterface`. First-party middleware also implements `AsyncMiddlewareInterface` so async requests chain Guzzle promises without `wait()`. | Middleware | Purpose | |---|---| @@ -128,11 +133,12 @@ All middleware implements `JOOservices\Client\Contracts\MiddlewareInterface`. | **BulkheadMiddleware** | Concurrency limiting | | **DeadlineMiddleware** | Per-request timeout override | | **FallbackMiddleware** | Stale cache fallback on failure | -| **ResponseValidationMiddleware** | Response contract validation | +| **ResponseValidationMiddleware** | Response contract validation (`JsonSchemaBodyValidator` optional helper) | +| **HttpErrorMappingMiddleware** | Map HTTP error statuses to `HttpResponseException` after retry | | **RequestSigningMiddleware** | Pluggable request signing | -| **RequestCoalescingMiddleware** | Deprecated — no real coalescing benefit under the sync pipeline | +| **RequestCoalescingMiddleware** | Deprecated — in-process GET coalescing only | | **ApiVersionMiddleware** | API version header | -| **UserAgentMiddleware** | User-Agent header | +| **UserAgentMiddleware** | User-Agent header (`string` or `callable(): string`) | | **InterceptorMiddleware** | `onRequest`/`onResponse`/`onError` callbacks; `onError` observes the terminal outcome, after retry/rate-limit have run | | **ProgressMiddleware** | Upload/download progress via the portable `progress` option | @@ -145,6 +151,7 @@ All middleware implements `JOOservices\Client\Contracts\MiddlewareInterface`. | **GuzzleHttpClientAdapter** | Default transport; full Guzzle options + async | | **CurlHttpClientAdapter** | Native `ext-curl` sync transport; portable options; rejects known non-portable keys | | **MiddlewareTransportAdapter** | Applies builder middleware to a custom/cURL transport | +| **FailoverTransportAdapter** | Fail over to a second transport on transport-level errors only | ## Exceptions (transport) diff --git a/docs/03-examples/08-async-schema-ua-failover.php b/docs/03-examples/08-async-schema-ua-failover.php new file mode 100644 index 0000000..991e7b4 --- /dev/null +++ b/docs/03-examples/08-async-schema-ua-failover.php @@ -0,0 +1,38 @@ +withBaseUri('https://jsonplaceholder.typicode.com') + ->withUserAgent('ExampleService/3.0') + ->withRetry(new RetryConfig(maxAttempts: 2, baseDelayMs: 1, useJitter: false)) + ->withHttpErrorMapping() + ->withJsonSchemaValidation([ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]) + ->build(); + +try { + $promise = $client->getAsync('/posts/1'); + /** @var ResponseWrapperInterface $response */ + $response = $promise->wait(); + $data = $response->json(); + $id = is_int($data['id'] ?? null) ? $data['id'] : 0; + echo "id={$id}\n"; +} catch (ResponseValidationException $exception) { + echo 'Schema: ' . $exception->getMessage() . "\n"; +} catch (HttpResponseException $exception) { + echo 'HTTP ' . $exception->getStatusCode() . "\n"; +} diff --git a/docs/03-examples/README.md b/docs/03-examples/README.md index 41c7f80..82c0083 100644 --- a/docs/03-examples/README.md +++ b/docs/03-examples/README.md @@ -9,3 +9,4 @@ These examples are runnable snippets for common package flows. - `05-middleware-logging.php` - `06-production-middleware.php` - `07-curl-transport.php` +- `08-async-schema-ua-failover.php` diff --git a/docs/05-maintenance/BACKLOG-POST-2.0.md b/docs/05-maintenance/BACKLOG-POST-2.0.md index c1eb8a9..0ec2a60 100644 --- a/docs/05-maintenance/BACKLOG-POST-2.0.md +++ b/docs/05-maintenance/BACKLOG-POST-2.0.md @@ -10,7 +10,7 @@ Related: [2.0 Upgrade Guide](../../UPGRADE-2.0.md) | ID | Item | Notes | |----|------|-------| -| D1 | Async-safe middleware pipeline | Stack still resolves promises via `wait()` under the Guzzle adapter; needs dedicated behavior and integration coverage. | +| D1 | ~~Async-safe middleware pipeline~~ | **Done in 3.0** — `AsyncMiddlewareInterface` + promise chaining in `MiddlewarePipeline` / `MiddlewareTransportAdapter`. Third-party sync middleware still uses a blocking fallback. | | D2 | ~~Retry / circuit breaker callbacks~~ | **Done in 3.0** — `RetryConfig::onRetry`, `CircuitBreakerConfig::onCircuitOpen/onCircuitClose`. | | D3 | ~~Mongo index provisioning~~ | **Obsolete in 3.0** — package Mongo logging removed; sinks are app-owned. | @@ -23,7 +23,7 @@ Related: [2.0 Upgrade Guide](../../UPGRADE-2.0.md) | ID | Item | Notes | |----|------|-------| | M1 | ~~`onError` / exception interceptor~~ | **Done in 3.0** — `InterceptorMiddleware::onError()` + `ClientBuilder::onError()`; callbacks may recover with a `ResponseInterface`. | -| M2 | Status → exception mapping middleware | Deferred — deliberately out of 3.0 scope (assert-oriented `ResponseValidationMiddleware` remains the supported path). | +| M2 | ~~Status → exception mapping middleware~~ | **Done in 3.0** — `HttpErrorMappingMiddleware` + `ClientBuilder::withHttpErrorMapping()` (outer than retry). | | M3 | ~~Retry / circuit callbacks~~ | **Done in 3.0** — see D2. | ### Medium value @@ -32,7 +32,7 @@ Related: [2.0 Upgrade Guide](../../UPGRADE-2.0.md) |----|------|-------| | M4 | ~~Progress middleware~~ | **Done in 3.0** — `ProgressMiddleware` + `ClientBuilder::withProgress()`. | | M5 | ~~Default headers merge policy~~ | **Done in 3.0** — `withHeaders($headers, overwrite: bool)` with documented semantics. | -| M6 | JSON Schema / OpenAPI response validation helper | Still open — first-party helper on top of `ResponseValidationConfig::$bodyValidator`. | +| M6 | ~~JSON Schema / OpenAPI response validation helper~~ | **Done in 3.0** — `JsonSchemaBodyValidator` / `ResponseValidationConfig::jsonSchema()` / `withJsonSchemaValidation()`. | ### Docs hygiene @@ -65,10 +65,10 @@ Keep integration **optional and thin** — do not hard-require the package. | ID | Item | Notes | |----|------|-------| -| U1 | Composer `suggest` | Add `jooservices/useragent` alongside existing `jooservices/dto` suggest. | -| U2 | `string\|callable(): string` on UA middleware | Extend `UserAgentMiddleware` / `withUserAgent()` so callers can pass a static string or a provider (sticky vs per-request rotation). | -| U3 | Optional builder helpers | e.g. `withGeneratedUserAgent(callable)` / `withRotatingUserAgent(callable)`; fail closed with `InvalidConfigurationException` if the package is missing when a first-class helper requires it. | -| U4 | Docs recipe | Document two intents: service identity (`MyApp/2.0`) vs browser-like / QA / scraping via useragent; do not make realistic UA the default. | +| U1 | ~~Composer `suggest`~~ | **Done in 3.0** — `jooservices/useragent` is suggested; not a runtime require. | +| U2 | ~~`string\|callable(): string` on UA middleware~~ | **Done in 3.0** — `UserAgentMiddleware` / `withUserAgent(string|callable(): string)`. | +| U3 | ~~Optional builder helpers~~ | **Done in 3.0** — `withGeneratedUserAgent()` / `withRotatingUserAgent()`; fail closed without the package unless a callable is passed. | +| U4 | ~~Docs recipe~~ | **Done in 3.0** — service identity vs rotating/browser-like UA; realistic UA is not the default. | Boundary: middleware should depend on `string|callable` (or a tiny provider interface), not on `UserAgentService` / profiles / strategies directly. @@ -82,10 +82,8 @@ Boundary: middleware should depend on `string|callable` (or a tiny provider inte | T2 | ~~StreamInterface body/sink, progress, cookies thin, track_redirects~~ | **Done** (sync path). | | T3 | ~~`CurlMultiTransport` / true concurrent batch without Guzzle promises~~ | **Done in 3.0** — `CurlMultiBatchClient` via `buildCurlMulti()`; middleware applies to single-request calls only. | | T4 | ~~Shared connection pool metrics / per-host keep-alive stats~~ | **Done in 3.0** — `MetricsRecorderInterface::recordConnectionReused()` fed by Guzzle handler stats / libcurl `conn_id`. | -| T5 | Failover transport (curl ↔ guzzle) | Not scheduled unless a consumer asks. | +| T5 | ~~Failover transport (curl ↔ guzzle)~~ | **Done in 3.0** — `FailoverTransportAdapter` + `withFailoverTransport()`. Transport-level failures only. | ## Suggested scheduling -1. **After 3.0:** U1–U4 (`jooservices/useragent`, optional and thin). -2. **Later / dedicated epic:** D1 (async pipeline), M2, M6. -3. **Not scheduled unless a consumer asks:** T5 (failover transport). +Remaining items from this backlog are the explicit out-of-core list (compression, cookie-jar middleware, health-check, pagination auto-follow, history, hedged requests). diff --git a/src/Adapters/Curl/CurlHttpClientAdapter.php b/src/Adapters/Curl/CurlHttpClientAdapter.php index ae032cb..77e1ec2 100644 --- a/src/Adapters/Curl/CurlHttpClientAdapter.php +++ b/src/Adapters/Curl/CurlHttpClientAdapter.php @@ -329,6 +329,9 @@ private function execute( $info = []; // @codeCoverageIgnoreEnd } + if (!isset($info['conn_id']) && defined('CURLINFO_CONN_ID')) { + $info['conn_id'] = curl_getinfo($handle, CURLINFO_CONN_ID); + } if ($transferFlags['callbackException'] !== null) { $this->recordTransferStats($options, $request, null, $info, curl_errno($handle)); @@ -564,9 +567,10 @@ private function recordTransferStats( private function recordConnectionReuse(TransferStatsBag $stats, array $info): void { $connId = $info['conn_id'] ?? null; - if (!is_int($connId)) { + if (!is_numeric($connId)) { return; } + $connId = (int) $connId; $stats->connectionReused = $this->lastConnId !== null && $this->lastConnId === $connId; $this->lastConnId = $connId; diff --git a/src/Adapters/FailoverTransportAdapter.php b/src/Adapters/FailoverTransportAdapter.php new file mode 100644 index 0000000..bfbfb6c --- /dev/null +++ b/src/Adapters/FailoverTransportAdapter.php @@ -0,0 +1,80 @@ +[] $failoverExceptions + */ + public function __construct( + private readonly TransportAdapterInterface $primary, + private readonly TransportAdapterInterface $fallback, + private readonly array $failoverExceptions = [ + NetworkConnectionException::class, + TimeoutException::class, + TransportBusyException::class, + AsyncTransportNotSupportedException::class, + ] + ) { + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + try { + return $this->primary->send($request, $options); + } catch (Throwable $exception) { // @phpstan-ignore catch.neverThrown (transport adapters throw at runtime) + if (!$this->shouldFailover($exception)) { + throw $exception; + } + + return $this->fallback->send($request, $options); + } + } + + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return $this->primary->sendAsync($request, $options)->otherwise( + function (mixed $reason) use ($request, $options): PromiseInterface { + $exception = MiddlewarePromise::throwable($reason); + if (!$this->shouldFailover($exception)) { + throw $exception; + } + + return $this->fallback->sendAsync($request, $options); + } + ); + } + + private function shouldFailover(Throwable $exception): bool + { + foreach ($this->failoverExceptions as $class) { + if ($exception instanceof $class) { + return true; + } + } + + return false; + } +} diff --git a/src/Adapters/MiddlewareTransportAdapter.php b/src/Adapters/MiddlewareTransportAdapter.php index 78f4345..972eb9a 100644 --- a/src/Adapters/MiddlewareTransportAdapter.php +++ b/src/Adapters/MiddlewareTransportAdapter.php @@ -6,27 +6,27 @@ use Closure; use GuzzleHttp\Promise\PromiseInterface; -use GuzzleHttp\Promise\Utils; use JOOservices\Client\Contracts\TransportAdapterInterface; use JOOservices\Client\Exceptions\ClientException; use JOOservices\Client\Middleware\MiddlewarePipeline; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** - * Applies the package's synchronous middleware to an injected transport. + * Applies the package middleware pipeline to an injected transport. * - * Async middleware is intentionally not emulated here. The inner transport - * call always goes through the wrapped transport's own send()/sendAsync(), - * so a sync-only adapter (e.g. CurlHttpClientAdapter) keeps rejecting async - * requests the same way whether or not builder middleware is configured. + * {@see MiddlewarePipeline::buildSynchronousHandler()} is used for send(). + * {@see MiddlewarePipeline::buildAsynchronousHandler()} chains promises for + * sendAsync() so first-party {@see \JOOservices\Client\Contracts\AsyncMiddlewareInterface} + * implementors do not {@see PromiseInterface::wait()} the inner transport. */ final class MiddlewareTransportAdapter implements TransportAdapterInterface { /** @var Closure(RequestInterface, array): ResponseInterface|null */ private ?Closure $syncHandler = null; - /** @var Closure(RequestInterface, array): ResponseInterface|null */ + /** @var Closure(RequestInterface, array): PromiseInterface|null */ private ?Closure $asyncHandler = null; public function __construct( @@ -49,27 +49,18 @@ public function send(RequestInterface $request, array $options = []): ResponseIn public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface { - $handler = $this->asyncHandler ??= $this->pipeline->buildSynchronousHandler( - fn (RequestInterface $nextRequest, array $nextOptions): ResponseInterface => $this->awaitTransport( - $nextRequest, - $nextOptions - ) - ); - - return Utils::task(fn (): ResponseInterface => $handler($request, $options)); - } - - /** - * @param array $options - */ - private function awaitTransport(RequestInterface $request, array $options): ResponseInterface - { - $response = $this->transport->sendAsync($request, $options)->wait(); + $handler = $this->asyncHandler ??= $this->pipeline->buildAsynchronousHandler( + fn (RequestInterface $nextRequest, array $nextOptions): PromiseInterface => $this->transport + ->sendAsync($nextRequest, $nextOptions) + ->then(static function (mixed $value): ResponseInterface { + if ($value instanceof ResponseInterface) { + return $value; + } - if (!$response instanceof ResponseInterface) { - throw new ClientException('Transport adapter resolved to a non-response value.'); - } + throw new ClientException('Transport adapter resolved to a non-response value.'); + }) + ); - return $response; + return MiddlewarePromise::from($handler($request, $options)); } } diff --git a/src/Client/ClientBuilder.php b/src/Client/ClientBuilder.php index 431fdad..47f0049 100644 --- a/src/Client/ClientBuilder.php +++ b/src/Client/ClientBuilder.php @@ -8,6 +8,7 @@ use GuzzleHttp\HandlerStack; use JOOservices\Client\Adapters\Curl\CurlHttpClientAdapter; use JOOservices\Client\Adapters\Curl\CurlMultiBatchClient; +use JOOservices\Client\Adapters\FailoverTransportAdapter; use JOOservices\Client\Adapters\Guzzle\GuzzleHttpClientAdapter; use JOOservices\Client\Adapters\MiddlewareTransportAdapter; use JOOservices\Client\Auth\AuthenticationConfig; @@ -31,6 +32,7 @@ use JOOservices\Client\Middleware\CorrelationIdMiddleware; use JOOservices\Client\Middleware\DeadlineMiddleware; use JOOservices\Client\Middleware\FallbackMiddleware; +use JOOservices\Client\Middleware\HttpErrorMappingMiddleware; use JOOservices\Client\Middleware\IdempotencyKeyMiddleware; use JOOservices\Client\Middleware\InterceptorMiddleware; use JOOservices\Client\Middleware\LoggingMiddleware; @@ -85,6 +87,7 @@ * @SuppressWarnings("PHPMD.CouplingBetweenObjects") * @SuppressWarnings("PHPMD.TooManyPublicMethods") * @SuppressWarnings("PHPMD.TooManyMethods") + * @SuppressWarnings("PHPMD.TooManyFields") */ final class ClientBuilder { @@ -121,6 +124,11 @@ final class ClientBuilder /** @var array{config: RateLimitConfig, store: RateLimitStoreInterface|null}|null */ private ?array $rateLimitConfig = null; + private ?TransportAdapterInterface $failoverAdapter = null; + + /** @var array{statuses: int[]|null, minStatus: int}|null */ + private ?array $httpErrorMapping = null; + public static function create(): self { return new self(); @@ -412,6 +420,29 @@ public function withTransport(string $transport, bool $strictPortableOptions = f }; } + /** + * Fail over to a second transport when the primary fails with a transport-level + * error ({@see \JOOservices\Client\Exceptions\NetworkConnectionException}, + * {@see \JOOservices\Client\Exceptions\TimeoutException}, + * {@see \JOOservices\Client\Exceptions\TransportBusyException}, + * {@see \JOOservices\Client\Exceptions\AsyncTransportNotSupportedException}). + * + * HTTP 4xx/5xx responses are not failed over. The wrapper sits inside the + * adapter so builder middleware is not duplicated on the fallback attempt. + * + * @param string|TransportAdapterInterface $fallback Named `guzzle` or `curl`, or a custom adapter + */ + public function withFailoverTransport( + string|TransportAdapterInterface $fallback, + bool $strictPortableOptions = false + ): self { + $this->failoverAdapter = is_string($fallback) + ? $this->createNamedTransport($fallback, $strictPortableOptions) + : $fallback; + + return $this; + } + /** * Reset to the default Guzzle transport (clears a previously set custom adapter). */ @@ -465,11 +496,40 @@ public function withCorrelationId(string $headerName = CorrelationIdMiddleware:: return $this->withMiddleware(new CorrelationIdMiddleware(), 'correlation_id'); } - public function withUserAgent(string $userAgent): self + /** + * @param string|callable(): string $userAgent Static service identity, or a per-request provider + */ + public function withUserAgent(string|callable $userAgent): self { return $this->withMiddleware(new UserAgentMiddleware($userAgent), 'user_agent'); } + /** + * Set a generated User-Agent once (sticky for the builder lifetime). + * + * When $generator is omitted, requires `jooservices/useragent` and uses + * `JOOservices\UserAgent\UserAgent::generate()`. + * + * @param callable(): string|null $generator + */ + public function withGeneratedUserAgent(?callable $generator = null): self + { + return $this->withUserAgent(($generator ?? $this->userAgentGenerator())()); + } + + /** + * Generate a User-Agent on every request (rotating). + * + * When $generator is omitted, requires `jooservices/useragent` and uses + * `JOOservices\UserAgent\UserAgent::generate()`. + * + * @param callable(): string|null $generator + */ + public function withRotatingUserAgent(?callable $generator = null): self + { + return $this->withUserAgent($generator ?? $this->userAgentGenerator()); + } + /** * Attach upload/download progress callbacks via the portable `progress` * request option. A pre-existing `progress` request option is preserved @@ -638,14 +698,43 @@ public function withResponseValidation(ResponseValidationConfig $config): self return $this->withMiddleware(new ResponseValidationMiddleware($config), 'response_validation'); } + /** + * Validate JSON response bodies against a JSON Schema document (including + * OpenAPI 3 response schemas that are valid JSON Schema). + * + * Requires `justinrainbow/json-schema`. Construction fails closed when the + * package is not installed. + * + * @param array|object|string $schema + */ + public function withJsonSchemaValidation(array|object|string $schema): self + { + return $this->withResponseValidation(ResponseValidationConfig::jsonSchema($schema)); + } + + /** + * Map HTTP error statuses to {@see \JOOservices\Client\Exceptions\HttpResponseException} + * after inner middleware (including retry) have inspected the raw status. + * + * Unlike {@see withHttpErrors()}, this does not convert 4xx/5xx at the + * transport boundary, so retry still sees retryable status codes. + * + * @param int[]|null $statuses Null maps every status greater than or equal to $minStatus + */ + public function withHttpErrorMapping(?array $statuses = null, int $minStatus = 400): self + { + $this->httpErrorMapping = ['statuses' => $statuses, 'minStatus' => $minStatus]; + + return $this; + } + public function withRequestSigning(RequestSignerInterface $signer): self { return $this->withMiddleware(new RequestSigningMiddleware($signer), 'request_signing'); } /** - * @deprecated Sync pipeline limitation: in-flight GET coalescing only works within a single - * process and does not apply to the async promise path. Prefer application-level deduplication. + * @deprecated In-process GET coalescing only. Prefer application-level deduplication. */ public function withRequestCoalescing(): self { @@ -836,8 +925,7 @@ public function buildSync(): HttpClientInterface * * Requires `withCurlMultiAdapter()` / `withTransport('curl_multi')`. The * middleware stack applies to single-request calls only; batch() executes - * at transport level (same documented limitation as the async middleware - * path). + * at transport level. * * @throws InvalidConfigurationException When the curl_multi transport was not enabled. */ @@ -851,12 +939,8 @@ public function buildCurlMulti(): CurlMultiBatchClient } $config = $this->buildConfig(); - $adapter = $this->adapter ?? $this->createDefaultAdapter(); - if ($this->adapter !== null && $this->pipeline !== null && !$this->pipeline->isEmpty()) { - $adapter = new MiddlewareTransportAdapter($adapter, $this->pipeline); - } - return new CurlMultiBatchClient(new HttpClient($adapter, $config), $config); + return new CurlMultiBatchClient(new HttpClient($this->resolveTransportAdapter(), $config), $config); } private function buildClient(): HttpClient @@ -868,12 +952,30 @@ private function buildClient(): HttpClient $config = $this->buildConfig(); + return new HttpClient($this->resolveTransportAdapter(), $config); + } + + private function resolveTransportAdapter(): TransportAdapterInterface + { + $fallback = $this->failoverAdapter; + $useFailover = $fallback !== null && !HttpFakeRegistry::isFaked(); + + if ($useFailover) { + $primary = $this->adapter ?? $this->createBareGuzzleAdapter(); + $adapter = new FailoverTransportAdapter($primary, $fallback); + if ($this->pipeline !== null && !$this->pipeline->isEmpty()) { + $adapter = new MiddlewareTransportAdapter($adapter, $this->pipeline); + } + + return $adapter; + } + $adapter = $this->adapter ?? $this->createDefaultAdapter(); if ($this->adapter !== null && $this->pipeline !== null && !$this->pipeline->isEmpty()) { $adapter = new MiddlewareTransportAdapter($adapter, $this->pipeline); } - return new HttpClient($adapter, $config); + return $adapter; } private function buildConfig(): ClientConfig @@ -1010,6 +1112,18 @@ private function addDeferredResilienceMiddleware(): void $addedResilienceMiddleware = true; } + if ($this->httpErrorMapping !== null) { + $this->withMiddleware( + new HttpErrorMappingMiddleware( + $this->httpErrorMapping['statuses'], + $this->httpErrorMapping['minStatus'] + ), + 'http_error_mapping' + ); + $this->httpErrorMapping = null; + $addedResilienceMiddleware = true; + } + // retry/rate_limit are deferred to this point regardless of when // onError()/onRequest()/onResponse() was first called, which would // otherwise leave the interceptor closer to the transport than retry — @@ -1035,4 +1149,50 @@ private function getInterceptorMiddleware(): InterceptorMiddleware return $this->interceptor; } + + /** + * @return callable(): string + */ + private function userAgentGenerator(): callable + { + $generatorClass = 'JOOservices\\UserAgent\\UserAgent'; + if (!class_exists($generatorClass)) { + throw new InvalidConfigurationException( + 'Generated User-Agent helpers require jooservices/useragent. ' + . 'Run `composer require jooservices/useragent` or pass an explicit generator callable.' + ); + } + + /** @var callable(): string $generate */ + $generate = [$generatorClass, 'generate']; + + return $generate; + } + + /** + * @param 'guzzle'|'curl'|string $transport + */ + private function createNamedTransport( + string $transport, + bool $strictPortableOptions = false + ): TransportAdapterInterface { + return match (strtolower($transport)) { + 'guzzle' => $this->createBareGuzzleAdapter(), + 'curl' => new CurlHttpClientAdapter(strictPortableOptions: $strictPortableOptions), + default => throw new InvalidConfigurationException( + 'Unknown failover transport "' . $transport . '". Supported values: guzzle, curl.' + ), + }; + } + + private function createBareGuzzleAdapter(): TransportAdapterInterface + { + $pipeline = $this->pipeline; + $this->pipeline = null; + try { + return $this->createDefaultAdapter(); + } finally { + $this->pipeline = $pipeline; + } + } } diff --git a/src/Contracts/AsyncMiddlewareInterface.php b/src/Contracts/AsyncMiddlewareInterface.php new file mode 100644 index 0000000..2663a22 --- /dev/null +++ b/src/Contracts/AsyncMiddlewareInterface.php @@ -0,0 +1,26 @@ + $options + * @param Closure(RequestInterface, array): PromiseInterface $next + */ + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface; +} diff --git a/src/Middleware/ApiVersionMiddleware.php b/src/Middleware/ApiVersionMiddleware.php index 3dfad1c..2d91357 100644 --- a/src/Middleware/ApiVersionMiddleware.php +++ b/src/Middleware/ApiVersionMiddleware.php @@ -5,11 +5,12 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class ApiVersionMiddleware implements MiddlewareInterface +final class ApiVersionMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly string $headerName, @@ -19,8 +20,11 @@ public function __construct( public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { - $request = $request->withHeader($this->headerName, $this->version); + return $next($request->withHeader($this->headerName, $this->version), $options); + } - return $next($request, $options); + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($request->withHeader($this->headerName, $this->version), $options); } } diff --git a/src/Middleware/AuthenticationMiddleware.php b/src/Middleware/AuthenticationMiddleware.php index 8923de3..afd044d 100644 --- a/src/Middleware/AuthenticationMiddleware.php +++ b/src/Middleware/AuthenticationMiddleware.php @@ -5,13 +5,14 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\PromiseInterface; use JOOservices\Client\Auth\AuthenticationConfig; use JOOservices\Client\Auth\AuthenticationType; -use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class AuthenticationMiddleware implements MiddlewareInterface +final class AuthenticationMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly AuthenticationConfig $config @@ -20,9 +21,12 @@ public function __construct( public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { - $request = $this->applyAuthentication($request); + return $next($this->applyAuthentication($request), $options); + } - return $next($request, $options); + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($this->applyAuthentication($request), $options); } private function applyAuthentication(RequestInterface $request): RequestInterface diff --git a/src/Middleware/BulkheadMiddleware.php b/src/Middleware/BulkheadMiddleware.php index bef2287..351fb4d 100644 --- a/src/Middleware/BulkheadMiddleware.php +++ b/src/Middleware/BulkheadMiddleware.php @@ -5,15 +5,17 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Exceptions\BulkheadRejectedException; use JOOservices\Client\Resilience\BulkheadConfig; use JOOservices\Client\Resilience\Contracts\BulkheadStoreInterface; use JOOservices\Client\Resilience\Storage\InMemoryBulkheadStore; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class BulkheadMiddleware implements MiddlewareInterface +final class BulkheadMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly BulkheadConfig $config, @@ -35,4 +37,39 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex $this->store->release($partitionKey); } } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + $partitionKey = $this->config->partitionKeyResolver->resolve($request, $options); + + if (!$this->store->tryAcquire($partitionKey, $this->config->maxConcurrent)) { + throw new BulkheadRejectedException('Bulkhead concurrency limit reached.'); + } + + $released = false; + $release = function () use (&$released, $partitionKey): void { + if ($released) { + return; + } + $released = true; + $this->store->release($partitionKey); + }; + + try { + return $next($request, $options)->then( + function (mixed $value) use ($release): ResponseInterface { + $release(); + + return MiddlewarePromise::response($value); + }, + function (mixed $reason) use ($release): never { + $release(); + throw MiddlewarePromise::throwable($reason); + } + ); + } catch (\Throwable $exception) { + $release(); + throw $exception; + } + } } diff --git a/src/Middleware/CacheMiddleware.php b/src/Middleware/CacheMiddleware.php index a20c552..df0cee8 100644 --- a/src/Middleware/CacheMiddleware.php +++ b/src/Middleware/CacheMiddleware.php @@ -5,15 +5,18 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response; -use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\ValueObjects\CacheConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\SimpleCache\CacheInterface; /** @SuppressWarnings("PHPMD.ExcessiveClassComplexity") */ -final class CacheMiddleware implements MiddlewareInterface +final class CacheMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly CacheInterface $cache, @@ -62,6 +65,49 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex return $response; } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + if ($request->getMethod() !== 'GET') { + return $next($request, $options); + } + + if ($this->shouldBypassCache($request, $options)) { + return $next($request, $options); + } + + $cacheKey = $this->generateCacheKey($request); + $cachedValue = $this->cache->get($cacheKey); + $cachedResponse = $this->resolveCachedResponse($cachedValue); + + if ($cachedResponse instanceof ResponseInterface && !$this->shouldRevalidate($cachedValue)) { + $options['cache_hit'] = true; + + return Create::promiseFor($cachedResponse); + } + + if ($cachedResponse instanceof ResponseInterface && $this->config->sendConditionalHeaders && is_array($cachedValue)) { + /** @var array $cachedArray */ + $cachedArray = $cachedValue; + $request = $this->applyConditionalHeaders($request, $cachedArray); + } + + return $next($request, $options)->then( + function (mixed $value) use ($cacheKey, $cachedResponse, $options): ResponseInterface { + $response = MiddlewarePromise::response($value); + + if ($response->getStatusCode() === 304 && $cachedResponse instanceof ResponseInterface) { + return $cachedResponse; + } + + if ($response->getStatusCode() === 200) { + $this->storeSuccessfulResponse($cacheKey, $response, $options); + } + + return $response; + } + ); + } + public function generateCacheKey(RequestInterface $request): string { $parts = [ diff --git a/src/Middleware/CircuitBreakerMiddleware.php b/src/Middleware/CircuitBreakerMiddleware.php index d09af02..3d7408e 100644 --- a/src/Middleware/CircuitBreakerMiddleware.php +++ b/src/Middleware/CircuitBreakerMiddleware.php @@ -5,18 +5,20 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\PartitionKeyResolverInterface; use JOOservices\Client\Exceptions\CircuitOpenException; use JOOservices\Client\Resilience\CircuitBreakerConfig; use JOOservices\Client\Resilience\Contracts\StateStoreFactoryInterface; use JOOservices\Client\Resilience\Contracts\StateStoreInterface; use JOOservices\Client\Support\HostPartitionKeyResolver; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; -final class CircuitBreakerMiddleware implements MiddlewareInterface +final class CircuitBreakerMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly CircuitBreakerConfig $config, @@ -71,6 +73,55 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + $partitionKey = $this->resolvePartitionKey($request, $options); + $store = $this->resolveStore($partitionKey); + + if ($store->isCircuitOpen($this->config->failureThreshold, $this->config->recoveryTimeoutMs)) { + throw new CircuitOpenException('Circuit Breaker is OPEN'); + } + + $halfOpen = $store->isHalfOpen($this->config->recoveryTimeoutMs); + if ($halfOpen && !$store->tryClaimProbe()) { + throw new CircuitOpenException('Circuit Breaker is OPEN'); + } + + return $next($request, $options)->then( + function (mixed $value) use ($store, $partitionKey): ResponseInterface { + $response = MiddlewarePromise::response($value); + + if ($this->isFailureStatus($response->getStatusCode())) { + $store->recordFailure($this->config->failureThreshold); + $this->notifyOpenIfTripped($store, $partitionKey); + + return $response; + } + + if ($store->isHalfOpen($this->config->recoveryTimeoutMs)) { + $store->reportSuccessInHalfOpen(); + + if ($store->checkHalfOpenRecovery($this->config->successThreshold)) { + $store->reset(); + $this->notifyClose($partitionKey); + } + + return $response; + } + + $store->recordSuccess(); + + return $response; + }, + function (mixed $reason) use ($store, $partitionKey): never { + $exception = MiddlewarePromise::throwable($reason); + $store->recordFailure($this->config->failureThreshold); + $this->notifyOpenIfTripped($store, $partitionKey); + throw $exception; + } + ); + } + /** * Fire the onCircuitOpen callback exactly when the failure pushed the circuit * from CLOSED (or HALF-OPEN) into OPEN. diff --git a/src/Middleware/CorrelationIdMiddleware.php b/src/Middleware/CorrelationIdMiddleware.php index 70e2a20..0b2a138 100644 --- a/src/Middleware/CorrelationIdMiddleware.php +++ b/src/Middleware/CorrelationIdMiddleware.php @@ -5,15 +5,44 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -class CorrelationIdMiddleware implements MiddlewareInterface +class CorrelationIdMiddleware implements AsyncMiddlewareInterface { public const HEADER_NAME = 'X-Correlation-ID'; public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + [$request, $headerName, $uuid] = $this->prepare($request, $options); + + /** @var ResponseInterface $response */ + $response = $next($request, $options); + + return $this->propagate($response, $headerName, $uuid); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + [$request, $headerName, $uuid] = $this->prepare($request, $options); + + return $next($request, $options)->then( + fn (mixed $value): ResponseInterface => $this->propagate( + MiddlewarePromise::response($value), + $headerName, + $uuid + ) + ); + } + + /** + * @param array $options + * @return array{0: RequestInterface, 1: string, 2: string} + */ + private function prepare(RequestInterface $request, array $options): array { $correlationHeader = $options['correlation_header'] ?? self::HEADER_NAME; if (!is_string($correlationHeader)) { @@ -23,14 +52,15 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex $uuid = $request->getHeaderLine($headerName); if ($uuid === '') { - // Generate UUID v4 when the request did not include a correlation ID. $uuid = $this->generateUuid(); $request = $request->withHeader($headerName, $uuid); } - $response = $next($request, $options); + return [$request, $headerName, $uuid]; + } - // Propagate back to response if not present + private function propagate(ResponseInterface $response, string $headerName, string $uuid): ResponseInterface + { if (!$response->hasHeader($headerName)) { $response = $response->withHeader($headerName, $uuid); } @@ -40,10 +70,9 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex private function generateUuid(): string { - // Simple v4 UUID generation $data = random_bytes(16); - $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100 - $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10 + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } diff --git a/src/Middleware/DeadlineMiddleware.php b/src/Middleware/DeadlineMiddleware.php index 1b30fc0..9839a36 100644 --- a/src/Middleware/DeadlineMiddleware.php +++ b/src/Middleware/DeadlineMiddleware.php @@ -5,11 +5,12 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class DeadlineMiddleware implements MiddlewareInterface +final class DeadlineMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly ?int $defaultDeadlineMs = null @@ -17,6 +18,20 @@ public function __construct( } public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + return $next($request, $this->applyDeadline($options)); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($request, $this->applyDeadline($options)); + } + + /** + * @param array $options + * @return array + */ + private function applyDeadline(array $options): array { $deadlineMs = $options['deadline_ms'] ?? $this->defaultDeadlineMs; @@ -29,6 +44,6 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex $options['connect_timeout'] = min($existingConnect, $timeoutSeconds); } - return $next($request, $options); + return $options; } } diff --git a/src/Middleware/FallbackMiddleware.php b/src/Middleware/FallbackMiddleware.php index 4c2951e..d2288a4 100644 --- a/src/Middleware/FallbackMiddleware.php +++ b/src/Middleware/FallbackMiddleware.php @@ -5,15 +5,17 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response; -use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Resilience\FallbackConfig; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\SimpleCache\CacheInterface; use Throwable; -final class FallbackMiddleware implements MiddlewareInterface +final class FallbackMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly FallbackConfig $config, @@ -50,6 +52,38 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + if (($options['fallback_enabled'] ?? true) === false) { + return $next($request, $options); + } + + return $next($request, $options)->then( + function (mixed $value) use ($request): ResponseInterface { + $response = MiddlewarePromise::response($value); + if (in_array($response->getStatusCode(), $this->config->fallbackStatuses, true)) { + $fallback = $this->resolveFallback($request); + if ($fallback instanceof ResponseInterface) { + return $fallback; + } + } + + return $response; + }, + function (mixed $reason) use ($request): ResponseInterface { + $exception = MiddlewarePromise::throwable($reason); + if ($this->shouldFallbackOnException($exception)) { + $fallback = $this->resolveFallback($request); + if ($fallback instanceof ResponseInterface) { + return $fallback; + } + } + + throw $exception; + } + ); + } + private function resolveFallback(RequestInterface $request): ?ResponseInterface { $cacheKey = $this->config->cacheKeyPrefix . md5((string) $request->getUri()); diff --git a/src/Middleware/HttpErrorMappingMiddleware.php b/src/Middleware/HttpErrorMappingMiddleware.php new file mode 100644 index 0000000..02d754c --- /dev/null +++ b/src/Middleware/HttpErrorMappingMiddleware.php @@ -0,0 +1,67 @@ +throwIfMapped($response); + + return $response; + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($request, $options)->then(function (mixed $value): ResponseInterface { + $response = MiddlewarePromise::response($value); + $this->throwIfMapped($response); + + return $response; + }); + } + + private function throwIfMapped(ResponseInterface $response): void + { + $status = $response->getStatusCode(); + $mapped = $this->statuses === null + ? $status >= $this->minStatus + : in_array($status, $this->statuses, true); + + if (!$mapped) { + return; + } + + throw new HttpResponseException( + sprintf('HTTP Client error: HTTP %d', $status), + $response + ); + } +} diff --git a/src/Middleware/IdempotencyKeyMiddleware.php b/src/Middleware/IdempotencyKeyMiddleware.php index f149904..a612846 100644 --- a/src/Middleware/IdempotencyKeyMiddleware.php +++ b/src/Middleware/IdempotencyKeyMiddleware.php @@ -5,12 +5,13 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\ValueObjects\IdempotencyConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class IdempotencyKeyMiddleware implements MiddlewareInterface +final class IdempotencyKeyMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly IdempotencyConfig $config = new IdempotencyConfig() @@ -18,6 +19,19 @@ public function __construct( } public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + return $next($this->apply($request, $options), $options); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($this->apply($request, $options), $options); + } + + /** + * @param array $options + */ + private function apply(RequestInterface $request, array $options): RequestInterface { $headerName = $this->config->headerName; $existing = $request->getHeaderLine($headerName); @@ -35,7 +49,7 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } - return $next($request, $options); + return $request; } private function shouldAutoGenerate(RequestInterface $request): bool diff --git a/src/Middleware/InterceptorMiddleware.php b/src/Middleware/InterceptorMiddleware.php index df5b590..29e4f43 100644 --- a/src/Middleware/InterceptorMiddleware.php +++ b/src/Middleware/InterceptorMiddleware.php @@ -5,13 +5,15 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; use UnexpectedValueException; -class InterceptorMiddleware implements MiddlewareInterface +class InterceptorMiddleware implements AsyncMiddlewareInterface { /** * @var array): RequestInterface> @@ -83,7 +85,36 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } - // Run Response Interceptors + return $this->applyResponseInterceptors($response, $options); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + foreach ($this->requestInterceptors as $interceptor) { + $request = $interceptor($request, $options); + } + + return $next($request, $options)->then( + fn (mixed $value): ResponseInterface => $this->applyResponseInterceptors( + MiddlewarePromise::response($value), + $options + ), + function (mixed $reason) use ($options): ResponseInterface { + $outcome = $this->applyErrorInterceptors(MiddlewarePromise::throwable($reason), $options); + if ($outcome instanceof ResponseInterface) { + return $this->applyResponseInterceptors($outcome, $options); + } + + throw $outcome; + } + ); + } + + /** + * @param array $options + */ + private function applyResponseInterceptors(ResponseInterface $response, array $options): ResponseInterface + { foreach ($this->responseInterceptors as $interceptor) { $response = $interceptor($response, $options); } diff --git a/src/Middleware/LoggingMiddleware.php b/src/Middleware/LoggingMiddleware.php index 912fb95..72717db 100644 --- a/src/Middleware/LoggingMiddleware.php +++ b/src/Middleware/LoggingMiddleware.php @@ -5,17 +5,19 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\PromiseInterface; use JOOservices\Client\Client\HttpClient; -use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\WanIpProviderInterface; use JOOservices\Client\Logging\LogSanitizer; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Support\TransferStatsBag; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use Throwable; -final class LoggingMiddleware implements MiddlewareInterface +final class LoggingMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly LoggerInterface $logger, @@ -25,21 +27,55 @@ public function __construct( ) { } - /** @SuppressWarnings("PHPMD.ExcessiveMethodLength") */ public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { - $start = microtime(true); + [$start, $method, $uri, $context] = $this->begin($request, $options); + + try { + /** @var ResponseInterface $response */ + $response = $next($request, $options); + $this->logResponse($response, $start, $method, $uri, $context, $options); + + return $response; + } catch (Throwable $e) { + $this->logFailure($e, $start, $method, $uri, $context, $options); + throw $e; + } + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + [$start, $method, $uri, $context] = $this->begin($request, $options); + + return $next($request, $options)->then( + function (mixed $value) use ($start, $method, $uri, $context, $options): ResponseInterface { + $response = MiddlewarePromise::response($value); + $this->logResponse($response, $start, $method, $uri, $context, $options); + + return $response; + }, + function (mixed $reason) use ($start, $method, $uri, $context, $options): never { + $exception = MiddlewarePromise::throwable($reason); + $this->logFailure($exception, $start, $method, $uri, $context, $options); + throw $exception; + } + ); + } + + /** + * @param array $options + * @return array{0: float, 1: string, 2: string, 3: array} + */ + private function begin(RequestInterface $request, array $options): array + { $method = $request->getMethod(); $uri = $this->sanitizer->sanitizeUri((string) $request->getUri()); - $correlationId = $request->getHeaderLine(CorrelationIdMiddleware::HEADER_NAME); $transferStats = $this->getTransferStatsBag($options); - $targetHostname = $this->resolveTargetHostname($uri, $options, $transferStats); - $context = [ 'method' => $method, 'uri' => $uri, - 'correlation_id' => $correlationId, - 'target_hostname' => $targetHostname, + 'correlation_id' => $request->getHeaderLine(CorrelationIdMiddleware::HEADER_NAME), + 'target_hostname' => $this->resolveTargetHostname($uri, $options, $transferStats), 'target_ip' => $transferStats?->targetIp, 'local_ip' => $transferStats?->localIp, ]; @@ -54,42 +90,60 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex $this->logRequestBody($request); } - try { - /** @var ResponseInterface $response */ - $response = $next($request, $options); - - $duration = round((microtime(true) - $start) * 1000, 2); - $statusCode = $response->getStatusCode(); - - $context = $this->updateTransferContext($context, $options); - $context['status'] = $statusCode; - $context['duration_ms'] = $duration; - - $level = ($statusCode >= 400) ? 'error' : 'info'; - - $this->safeLog( - $level, - "Received response {$statusCode} for {$method} {$uri} ({$duration}ms)", - $context - ); - - if ($this->logBodies && !isset($options['sink'])) { - $this->logResponseBody($response); - } - - return $response; - } catch (Throwable $e) { - $duration = round((microtime(true) - $start) * 1000, 2); - $context = $this->updateTransferContext($context, $options); - $context['duration_ms'] = $duration; - $exceptionMessage = $this->sanitizer->sanitizeExceptionMessage($e->getMessage()); - $context['exception'] = $exceptionMessage; + return [microtime(true), $method, $uri, $context]; + } - $this->safeLog('error', "Exception for {$method} {$uri}: " . $exceptionMessage, $context); - throw $e; + /** + * @param array $context + * @param array $options + */ + private function logResponse( + ResponseInterface $response, + float $start, + string $method, + string $uri, + array $context, + array $options + ): void { + $duration = round((microtime(true) - $start) * 1000, 2); + $statusCode = $response->getStatusCode(); + $context = $this->updateTransferContext($context, $options); + $context['status'] = $statusCode; + $context['duration_ms'] = $duration; + $level = ($statusCode >= 400) ? 'error' : 'info'; + + $this->safeLog( + $level, + "Received response {$statusCode} for {$method} {$uri} ({$duration}ms)", + $context + ); + + if ($this->logBodies && !isset($options['sink'])) { + $this->logResponseBody($response); } } + /** + * @param array $context + * @param array $options + */ + private function logFailure( + Throwable $exception, + float $start, + string $method, + string $uri, + array $context, + array $options + ): void { + $duration = round((microtime(true) - $start) * 1000, 2); + $context = $this->updateTransferContext($context, $options); + $context['duration_ms'] = $duration; + $exceptionMessage = $this->sanitizer->sanitizeExceptionMessage($exception->getMessage()); + $context['exception'] = $exceptionMessage; + + $this->safeLog('error', "Exception for {$method} {$uri}: " . $exceptionMessage, $context); + } + /** * @param array $context */ diff --git a/src/Middleware/MetricsMiddleware.php b/src/Middleware/MetricsMiddleware.php index 18ce268..92c8077 100644 --- a/src/Middleware/MetricsMiddleware.php +++ b/src/Middleware/MetricsMiddleware.php @@ -5,16 +5,18 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\PromiseInterface; use JOOservices\Client\Client\HttpClient; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\MetricsRecorderInterface; -use JOOservices\Client\Contracts\MiddlewareInterface; use JOOservices\Client\Support\HostPartitionKeyResolver; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Support\TransferStatsBag; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; -final class MetricsMiddleware implements MiddlewareInterface +final class MetricsMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly MetricsRecorderInterface $recorder, @@ -49,6 +51,36 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + $start = microtime(true); + $method = $request->getMethod(); + $host = $this->hostResolver->resolve($request, $options); + $tags = []; + + if (($options['cache_hit'] ?? false) === true) { + $tags['cache_hit'] = true; + } + + return $next($request, $options)->then( + function (mixed $value) use ($start, $method, $host, $tags, $options): ResponseInterface { + $response = MiddlewarePromise::response($value); + $durationMs = round((microtime(true) - $start) * 1000, 2); + $this->safeRecord($method, $host, $response->getStatusCode(), $durationMs, $tags); + $this->safeRecordConnectionReuse($options, $host, $tags); + + return $response; + }, + function (mixed $reason) use ($start, $method, $host, $tags): never { + $durationMs = round((microtime(true) - $start) * 1000, 2); + $exception = MiddlewarePromise::throwable($reason); + $tags['exception'] = $exception::class; + $this->safeRecord($method, $host, 0, $durationMs, $tags); + throw $exception; + } + ); + } + /** * @param array $options * @param array $tags diff --git a/src/Middleware/MiddlewarePipeline.php b/src/Middleware/MiddlewarePipeline.php index 4cc56c2..63ed88d 100644 --- a/src/Middleware/MiddlewarePipeline.php +++ b/src/Middleware/MiddlewarePipeline.php @@ -6,13 +6,16 @@ use Closure; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Promise\FulfilledPromise; +use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; -use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Promise\Utils; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; +use Throwable; class MiddlewarePipeline { @@ -29,6 +32,8 @@ class MiddlewarePipeline /** * Add a middleware to the pipeline. * + * Last-pushed middleware is outermost (LIFO), matching Guzzle HandlerStack::push(). + * * @param MiddlewareInterface $middleware * @param string $name * @return self @@ -172,8 +177,8 @@ public function buildHandlerStack(?HandlerStack $stack = null): HandlerStack { $stack = $stack ?? HandlerStack::create(); - // Iterate in reverse order so first-added middleware becomes outermost - // (Guzzle's push() adds to top of stack) + // Iterate in reverse order so first-added middleware is pushed first and + // last-added is pushed last. Guzzle's push() makes last-pushed outermost. foreach (array_reverse($this->order) as $name) { if (!isset($this->middlewares[$name])) { continue; @@ -188,6 +193,8 @@ public function buildHandlerStack(?HandlerStack $stack = null): HandlerStack /** * Build the synchronous pipeline around a transport callback. * + * Last-pushed middleware is outermost, matching {@see buildHandlerStack()}. + * * @param Closure(RequestInterface, array): ResponseInterface $transport * @return Closure(RequestInterface, array): ResponseInterface */ @@ -195,7 +202,7 @@ public function buildSynchronousHandler(Closure $transport): Closure { $handler = $transport; - foreach (array_reverse($this->order) as $name) { + foreach ($this->order as $name) { if (!isset($this->middlewares[$name])) { continue; } @@ -209,6 +216,39 @@ public function buildSynchronousHandler(Closure $transport): Closure return $handler; } + /** + * Build the asynchronous pipeline around a transport callback that returns a promise. + * + * Last-pushed middleware is outermost. {@see AsyncMiddlewareInterface} implementors + * chain promises; other {@see MiddlewareInterface} implementors run inside a + * Guzzle task that still {@see PromiseInterface::wait()}s the inner handler. + * + * @param Closure(RequestInterface, array): PromiseInterface $transport + * @return Closure(RequestInterface, array): PromiseInterface + */ + public function buildAsynchronousHandler(Closure $transport): Closure + { + $handler = $transport; + + foreach ($this->order as $name) { + if (!isset($this->middlewares[$name])) { + continue; + } + + $middleware = $this->middlewares[$name]; + $next = $handler; + $handler = fn (RequestInterface $request, array $options): PromiseInterface + => $this->invokeMiddlewareAsync( + $middleware, + $request, + self::normalizeSynchronousOptions($options), + $next + ); + } + + return $handler; + } + /** * @param array $options * @return array @@ -233,52 +273,48 @@ private function wrapMiddleware(MiddlewareInterface $middleware): callable * @return PromiseInterface */ $wrappedHandler = function (RequestInterface $request, array $options, callable $handler) use ($middleware): PromiseInterface { - $nextClosure = $this->buildNextClosure($handler); + $normalized = $this->normalizeOptions($options); try { - $response = $middleware($request, $this->normalizeOptions($options), $nextClosure); - - return new FulfilledPromise($response); - } catch (\Throwable $e) { - return new RejectedPromise($e); + return $this->invokeMiddlewareAsync( + $middleware, + $request, + $normalized, + static fn (RequestInterface $nextRequest, array $nextOptions): PromiseInterface + => MiddlewarePromise::from($handler($nextRequest, $nextOptions)) + ); + } catch (Throwable $e) { // @phpstan-ignore catch.neverThrown (middleware may throw before returning a promise) + return Create::rejectionFor($e); } }; - // Guzzle's own PromiseInterface declares TReason as invariant (confirmed in - // vendor/guzzlehttp/promises), so a closure that can return either - // FulfilledPromise or RejectedPromise - // can never be statically typed as exactly PromiseInterface - // -- there is no covariant widening for TReason to fall back on. At runtime this is - // safe: HandlerStack only requires a PromiseInterface, and both branches are one. - // @phpstan-ignore return.type return static fn (callable $handler): callable => static fn (RequestInterface $request, array $options): PromiseInterface => $wrappedHandler($request, $options, $handler); } /** - * @param callable(RequestInterface, array): PromiseInterface $handler - * @return Closure(RequestInterface, array): ResponseInterface + * @param Closure(RequestInterface, array): PromiseInterface $next + * @param array $options */ - private function buildNextClosure(callable $handler): Closure - { - return function (RequestInterface $req, array $opts) use ($handler): ResponseInterface { - $result = $handler($req, $opts); - - if ($result instanceof PromiseInterface) { - $resolved = $result->wait(); - if ($resolved instanceof ResponseInterface) { - return $resolved; - } - - throw new RuntimeException('Middleware handler resolved to a non-response value.'); - } + private function invokeMiddlewareAsync( + MiddlewareInterface $middleware, + RequestInterface $request, + array $options, + Closure $next + ): PromiseInterface { + if ($middleware instanceof AsyncMiddlewareInterface) { + return $middleware->processAsync($request, $options, $next); + } - if ($result instanceof ResponseInterface) { - return $result; - } + return Utils::task(function () use ($middleware, $request, $options, $next): ResponseInterface { + $syncNext = static function (RequestInterface $nextRequest, array $nextOptions) use ($next): ResponseInterface { + return MiddlewarePromise::response( + $next($nextRequest, self::normalizeSynchronousOptions($nextOptions))->wait() + ); + }; - throw new RuntimeException('Middleware handler returned an invalid response type.'); - }; + return $middleware($request, $options, $syncNext); + }); } /** diff --git a/src/Middleware/OAuthTokenRefreshMiddleware.php b/src/Middleware/OAuthTokenRefreshMiddleware.php index 861994d..33e079c 100644 --- a/src/Middleware/OAuthTokenRefreshMiddleware.php +++ b/src/Middleware/OAuthTokenRefreshMiddleware.php @@ -5,13 +5,15 @@ namespace JOOservices\Client\Middleware; use Closure; +use GuzzleHttp\Promise\PromiseInterface; use JOOservices\Client\Auth\OAuthTokenRefreshConfig; -use JOOservices\Client\Contracts\MiddlewareInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\TokenProviderInterface; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class OAuthTokenRefreshMiddleware implements MiddlewareInterface +final class OAuthTokenRefreshMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly TokenProviderInterface $tokenProvider, @@ -48,6 +50,45 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $this->attemptRefreshAsync($request, $options, $next, 0); + } + + /** + * @param Closure(RequestInterface, array): PromiseInterface $next + * @param array $options + */ + private function attemptRefreshAsync( + RequestInterface $request, + array $options, + Closure $next, + int $refreshAttempts + ): PromiseInterface { + return $next($request, $options)->then(function (mixed $value) use ($request, $options, $next, $refreshAttempts): mixed { + $response = MiddlewarePromise::response($value); + if (!$this->shouldRefresh($response, $refreshAttempts)) { + return $response; + } + + if (!$this->tokenProvider->refreshToken()) { + return $response; + } + + $body = $request->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + + $request = $request->withHeader( + 'Authorization', + 'Bearer ' . $this->tokenProvider->getAccessToken() + ); + + return $this->attemptRefreshAsync($request, $options, $next, $refreshAttempts + 1); + }); + } + private function shouldRefresh(ResponseInterface $response, int $refreshAttempts): bool { if ($refreshAttempts >= $this->config->maxRefreshAttempts) { diff --git a/src/Middleware/ProgressMiddleware.php b/src/Middleware/ProgressMiddleware.php index 2a82d21..586f9f0 100644 --- a/src/Middleware/ProgressMiddleware.php +++ b/src/Middleware/ProgressMiddleware.php @@ -5,7 +5,8 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -14,7 +15,7 @@ * callbacks) without forcing callers to know transport-specific option * shapes. Works on Guzzle and the native cURL transport. */ -final class ProgressMiddleware implements MiddlewareInterface +final class ProgressMiddleware implements AsyncMiddlewareInterface { /** * @var \Closure(int, int): void|null @@ -40,15 +41,29 @@ public function __construct( } public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + /** @var ResponseInterface $response */ + $response = $next($request, $this->applyProgressOption($options)); + + return $response; + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($request, $this->applyProgressOption($options)); + } + + /** + * @param array $options + * @return array + */ + private function applyProgressOption(array $options): array { if (!isset($options['progress']) || $this->replaceExisting) { $options['progress'] = $this->buildProgressCallback(); } - /** @var ResponseInterface $response */ - $response = $next($request, $options); - - return $response; + return $options; } /** diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php index 70e7a82..691f5e9 100644 --- a/src/Middleware/RateLimitMiddleware.php +++ b/src/Middleware/RateLimitMiddleware.php @@ -5,18 +5,20 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\SleeperInterface; use JOOservices\Client\Exceptions\RateLimitExceededException; use JOOservices\Client\Resilience\Contracts\RateLimitStoreInterface; use JOOservices\Client\Resilience\RateLimitConfig; use JOOservices\Client\Resilience\Storage\InMemoryRateLimitStore; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Support\RetryAfterHeader; use JOOservices\Client\Support\UsleepSleeper; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class RateLimitMiddleware implements MiddlewareInterface +final class RateLimitMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly RateLimitConfig $config, @@ -65,6 +67,46 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + if (($options['rate_limit_bypass'] ?? false) === true) { + return $next($request, $options); + } + + $partitionKey = $this->config->partitionKeyResolver->resolve($request, $options); + $waitedMs = 0; + + while (true) { + $waitMs = $this->store->consume( + $partitionKey, + $this->config->maxTokens, + $this->config->refillRatePerSecond + ); + + if ($waitMs === 0) { + return $next($request, $options)->then(function (mixed $value) use ($partitionKey): ResponseInterface { + $response = MiddlewarePromise::response($value); + if ($this->config->honorServerHeaders) { + $this->applyServerHints($partitionKey, $response); + } + + return $response; + }); + } + + if ($this->config->mode === RateLimitConfig::MODE_FAIL_FAST) { + throw new RateLimitExceededException('Client-side rate limit exceeded.'); + } + + $waitedMs += $waitMs; + if ($waitedMs > $this->config->maxWaitMs) { + throw new RateLimitExceededException('Client-side rate limit wait time exceeded.'); + } + + $this->sleeper->sleep($waitMs); + } + } + /** * Honour server back-off hints so the client-side limiter aligns with the * server: RateLimit-Reset (unix epoch seconds) on any response, plus diff --git a/src/Middleware/RequestCoalescingMiddleware.php b/src/Middleware/RequestCoalescingMiddleware.php index 2299c2e..74fa8d3 100644 --- a/src/Middleware/RequestCoalescingMiddleware.php +++ b/src/Middleware/RequestCoalescingMiddleware.php @@ -5,21 +5,26 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Throwable; /** - * @deprecated Sync pipeline limitation: in-flight GET coalescing only works within a single - * process and does not apply to the async promise path. Prefer application-level deduplication. + * @deprecated In-process GET coalescing only. The async path shares in-flight promises; + * the sync path busy-waits. Prefer application-level deduplication. */ -final class RequestCoalescingMiddleware implements MiddlewareInterface +final class RequestCoalescingMiddleware implements AsyncMiddlewareInterface { /** @var array */ private array $inFlight = []; + /** @var array> */ + private array $asyncInFlight = []; + public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { if ($request->getMethod() !== 'GET') { @@ -49,6 +54,33 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + if ($request->getMethod() !== 'GET') { + return $next($request, $options); + } + + $key = $this->generateKey($request); + if (isset($this->asyncInFlight[$key])) { + return $this->asyncInFlight[$key]; + } + + $promise = $next($request, $options)->then( + function (mixed $value) use ($key): mixed { + unset($this->asyncInFlight[$key]); + + return $value; + }, + function (mixed $reason) use ($key): never { + unset($this->asyncInFlight[$key]); + throw MiddlewarePromise::throwable($reason); + } + ); + $this->asyncInFlight[$key] = $promise; + + return $promise; + } + private function generateKey(RequestInterface $request): string { return md5($request->getMethod() . "\0" . (string) $request->getUri()); diff --git a/src/Middleware/RequestSigningMiddleware.php b/src/Middleware/RequestSigningMiddleware.php index 3d237e3..bd826f3 100644 --- a/src/Middleware/RequestSigningMiddleware.php +++ b/src/Middleware/RequestSigningMiddleware.php @@ -5,12 +5,13 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\RequestSignerInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class RequestSigningMiddleware implements MiddlewareInterface +final class RequestSigningMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly RequestSignerInterface $signer @@ -19,8 +20,11 @@ public function __construct( public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { - $request = $this->signer->sign($request, $options); + return $next($this->signer->sign($request, $options), $options); + } - return $next($request, $options); + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($this->signer->sign($request, $options), $options); } } diff --git a/src/Middleware/ResponseValidationMiddleware.php b/src/Middleware/ResponseValidationMiddleware.php index d495143..0ef6337 100644 --- a/src/Middleware/ResponseValidationMiddleware.php +++ b/src/Middleware/ResponseValidationMiddleware.php @@ -5,13 +5,15 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Exceptions\ResponseValidationException; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Validation\ResponseValidationConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class ResponseValidationMiddleware implements MiddlewareInterface +final class ResponseValidationMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly ResponseValidationConfig $config @@ -22,7 +24,26 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex { /** @var ResponseInterface $response */ $response = $next($request, $options); + $this->validate($response, $options); + return $response; + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($request, $options)->then(function (mixed $value) use ($options): ResponseInterface { + $response = MiddlewarePromise::response($value); + $this->validate($response, $options); + + return $response; + }); + } + + /** + * @param array $options + */ + private function validate(ResponseInterface $response, array $options): void + { if ($this->config->expectedStatuses !== null && !in_array($response->getStatusCode(), $this->config->expectedStatuses, true) ) { @@ -45,7 +66,5 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex if (is_callable($bodyValidator)) { $bodyValidator($response, $options); } - - return $response; } } diff --git a/src/Middleware/RetryMiddleware.php b/src/Middleware/RetryMiddleware.php index 0753cd1..73d23e0 100644 --- a/src/Middleware/RetryMiddleware.php +++ b/src/Middleware/RetryMiddleware.php @@ -5,16 +5,19 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; use JOOservices\Client\Contracts\SleeperInterface; use JOOservices\Client\Resilience\RetryConfig; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Support\RetryAfterHeader; use JOOservices\Client\Support\UsleepSleeper; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; -final class RetryMiddleware implements MiddlewareInterface +final class RetryMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly RetryConfig $config, @@ -58,6 +61,57 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex } } + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + if (!$this->isMethodRetryable($request)) { + return $next($request, $options); + } + + return $this->attemptAsync($request, $options, $next, 1); + } + + /** + * @param Closure(RequestInterface, array): PromiseInterface $next + * @param array $options + */ + private function attemptAsync( + RequestInterface $request, + array $options, + Closure $next, + int $attempts + ): PromiseInterface { + try { + $this->rewindRequestBody($request); + } catch (Throwable $exception) { + return Create::rejectionFor($exception); + } + + $maxAttempts = $this->config->maxAttempts; + + return $next($request, $options)->then( + function (mixed $value) use ($request, $options, $next, $attempts, $maxAttempts): mixed { + $response = MiddlewarePromise::response($value); + if ($this->shouldRetryStatus($response) && $attempts < $maxAttempts) { + $this->doWait($attempts, $request, $response); + + return $this->attemptAsync($request, $options, $next, $attempts + 1); + } + + return $response; + }, + function (mixed $reason) use ($request, $options, $next, $attempts, $maxAttempts): mixed { + $exception = MiddlewarePromise::throwable($reason); + if ($attempts >= $maxAttempts || !$this->shouldRetryException($exception)) { + throw $exception; + } + + $this->doWait($attempts, $request, $exception); + + return $this->attemptAsync($request, $options, $next, $attempts + 1); + } + ); + } + private function isMethodRetryable(RequestInterface $request): bool { $method = strtoupper($request->getMethod()); @@ -140,6 +194,9 @@ private function parseRetryAfterMs(ResponseInterface $response): ?int return max(0, ($timestamp - time()) * 1000); } + /** + * @throws \RuntimeException + */ private function rewindRequestBody(RequestInterface $request): void { $body = $request->getBody(); diff --git a/src/Middleware/TraceContextMiddleware.php b/src/Middleware/TraceContextMiddleware.php index 8d8c62a..11fd736 100644 --- a/src/Middleware/TraceContextMiddleware.php +++ b/src/Middleware/TraceContextMiddleware.php @@ -5,13 +5,15 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Support\MiddlewarePromise; use JOOservices\Client\Support\W3cTraceContextGenerator; use JOOservices\Client\ValueObjects\TraceContextConfig; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -final class TraceContextMiddleware implements MiddlewareInterface +final class TraceContextMiddleware implements AsyncMiddlewareInterface { public function __construct( private readonly TraceContextConfig $config = new TraceContextConfig(), @@ -20,6 +22,32 @@ public function __construct( } public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + [$request, $traceparentHeader, $traceparent] = $this->prepare($request); + + /** @var ResponseInterface $response */ + $response = $next($request, $options); + + return $this->propagate($response, $traceparentHeader, $traceparent); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + [$request, $traceparentHeader, $traceparent] = $this->prepare($request); + + return $next($request, $options)->then( + fn (mixed $value): ResponseInterface => $this->propagate( + MiddlewarePromise::response($value), + $traceparentHeader, + $traceparent + ) + ); + } + + /** + * @return array{0: RequestInterface, 1: string, 2: string} + */ + private function prepare(RequestInterface $request): array { $traceparentHeader = $this->config->traceparentHeader; $traceparent = $request->getHeaderLine($traceparentHeader); @@ -33,8 +61,14 @@ public function __invoke(RequestInterface $request, array $options, Closure $nex $request = $request->withHeader($this->config->tracestateHeader, $this->config->tracestate); } - $response = $next($request, $options); + return [$request, $traceparentHeader, $traceparent]; + } + private function propagate( + ResponseInterface $response, + string $traceparentHeader, + string $traceparent + ): ResponseInterface { if (!$this->config->propagateToResponse) { return $response; } diff --git a/src/Middleware/UserAgentMiddleware.php b/src/Middleware/UserAgentMiddleware.php index 19cd50e..080bdd5 100644 --- a/src/Middleware/UserAgentMiddleware.php +++ b/src/Middleware/UserAgentMiddleware.php @@ -5,23 +5,53 @@ namespace JOOservices\Client\Middleware; use Closure; -use JOOservices\Client\Contracts\MiddlewareInterface; +use GuzzleHttp\Promise\PromiseInterface; +use JOOservices\Client\Contracts\AsyncMiddlewareInterface; +use JOOservices\Client\Exceptions\InvalidConfigurationException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -class UserAgentMiddleware implements MiddlewareInterface +class UserAgentMiddleware implements AsyncMiddlewareInterface { - private string $userAgent; + /** + * @var string|\Closure(): string + */ + private string|\Closure $userAgent; - public function __construct(string $userAgent = 'JOOClient/2.0') + /** + * @param string|callable(): string $userAgent Static identity, or a provider invoked per request + */ + public function __construct(string|callable $userAgent = 'JOOClient/2.0') { - $this->userAgent = $userAgent; + $this->userAgent = is_string($userAgent) ? $userAgent : \Closure::fromCallable($userAgent); } public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface { - $request = $request->withHeader('User-Agent', $this->userAgent); + return $next($this->apply($request), $options); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + return $next($this->apply($request), $options); + } + + private function apply(RequestInterface $request): RequestInterface + { + return $request->withHeader('User-Agent', $this->resolveUserAgent()); + } + + private function resolveUserAgent(): string + { + if ($this->userAgent instanceof \Closure) { + $value = ($this->userAgent)(); + if (!is_string($value) || trim($value) === '') { + throw new InvalidConfigurationException('User-Agent provider must return a non-empty string.'); + } + + return $value; + } - return $next($request, $options); + return $this->userAgent; } } diff --git a/src/Support/ConnectionReuseTracker.php b/src/Support/ConnectionReuseTracker.php index 3aa73f4..cc14b9d 100644 --- a/src/Support/ConnectionReuseTracker.php +++ b/src/Support/ConnectionReuseTracker.php @@ -16,9 +16,10 @@ final class ConnectionReuseTracker public function wasReused(mixed $connId): ?bool { - if (!is_int($connId)) { + if (!is_numeric($connId)) { return null; } + $connId = (int) $connId; $reused = $this->lastConnId !== null && $this->lastConnId === $connId; $this->lastConnId = $connId; diff --git a/src/Support/MiddlewarePromise.php b/src/Support/MiddlewarePromise.php new file mode 100644 index 0000000..b42cde0 --- /dev/null +++ b/src/Support/MiddlewarePromise.php @@ -0,0 +1,50 @@ +|object|string $schema JSON Schema as array, object, or JSON string + */ + public function __construct(array|object|string $schema) + { + if (!class_exists(Validator::class)) { + throw new InvalidConfigurationException( + 'JSON Schema validation requires justinrainbow/json-schema. ' + . 'Run `composer require justinrainbow/json-schema`.' + ); + } + + $this->schema = $this->normalizeSchema($schema); + } + + /** + * @param array $options + */ + public function __invoke(ResponseInterface $response, array $options): void + { + $stream = $response->getBody(); + $body = (string) $stream; + if ($stream->isSeekable()) { + $stream->rewind(); + } + + try { + $data = json_decode($body, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new ResponseValidationException( + 'Response body is not valid JSON: ' . $exception->getMessage(), + $response, + 0, + $exception + ); + } + + $validator = new Validator(); + $validator->validate($data, $this->schema); + + if ($validator->isValid()) { + return; + } + + $messages = []; + foreach ($validator->getErrors() as $error) { + if (!is_array($error)) { + continue; + } + + $pointer = is_string($error['property'] ?? null) ? $error['property'] : ''; + $message = is_string($error['message'] ?? null) ? $error['message'] : 'invalid'; + $messages[] = ($pointer !== '' ? $pointer . ': ' : '') . $message; + } + + throw new ResponseValidationException( + 'Response body failed JSON Schema validation: ' . implode('; ', $messages), + $response + ); + } + + /** + * @param array|object|string $schema + */ + private function normalizeSchema(array|object|string $schema): object + { + if (is_string($schema)) { + try { + $decoded = json_decode($schema, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidConfigurationException( + 'JSON Schema document is not valid JSON: ' . $exception->getMessage(), + 0, + $exception + ); + } + + if (!is_object($decoded)) { + throw new InvalidConfigurationException('JSON Schema document must decode to an object.'); + } + + return $decoded; + } + + if (is_object($schema)) { + return $schema; + } + + $decoded = json_decode((string) json_encode($schema), false); + if (!is_object($decoded)) { + throw new InvalidConfigurationException('JSON Schema document must encode to an object.'); + } + + return $decoded; + } +} diff --git a/src/Validation/ResponseValidationConfig.php b/src/Validation/ResponseValidationConfig.php index f8f221f..43c2828 100644 --- a/src/Validation/ResponseValidationConfig.php +++ b/src/Validation/ResponseValidationConfig.php @@ -17,4 +17,15 @@ public function __construct( public readonly mixed $bodyValidator = null ) { } + + /** + * Validate the JSON response body against a JSON Schema document (including + * OpenAPI 3 response schemas that are valid JSON Schema). + * + * @param array|object|string $schema + */ + public static function jsonSchema(array|object|string $schema): self + { + return new self(bodyValidator: new JsonSchemaBodyValidator($schema)); + } } diff --git a/tests/Integration/Adapters/CurlMultiBatchClientIntegrationTest.php b/tests/Integration/Adapters/CurlMultiBatchClientIntegrationTest.php index f629fb9..ff73544 100644 --- a/tests/Integration/Adapters/CurlMultiBatchClientIntegrationTest.php +++ b/tests/Integration/Adapters/CurlMultiBatchClientIntegrationTest.php @@ -333,6 +333,10 @@ public function test_single_request_methods_delegate_to_the_wrapped_client(): vo public function test_connection_reuse_metrics_flow_through_metrics_middleware(): void { + if (!defined('CURLINFO_CONN_ID')) { + self::markTestSkipped('CURLINFO_CONN_ID is required to observe libcurl connection reuse.'); + } + $recorder = new InMemoryMetricsRecorder(); $client = ClientBuilder::create() ->withBaseUri(self::$keepAliveBaseUri) @@ -356,6 +360,10 @@ public function test_connection_reuse_metrics_flow_through_metrics_middleware(): public function test_connection_reuse_metrics_flow_through_the_default_guzzle_transport(): void { + if (!defined('CURLINFO_CONN_ID')) { + self::markTestSkipped('CURLINFO_CONN_ID is required to observe libcurl connection reuse.'); + } + $recorder = new InMemoryMetricsRecorder(); $client = ClientBuilder::create() ->withBaseUri(self::$keepAliveBaseUri) diff --git a/tests/Unit/Adapters/FailoverTransportAdapterTest.php b/tests/Unit/Adapters/FailoverTransportAdapterTest.php new file mode 100644 index 0000000..5fa34a2 --- /dev/null +++ b/tests/Unit/Adapters/FailoverTransportAdapterTest.php @@ -0,0 +1,157 @@ +transport( + send: static fn (): ResponseInterface => new Response(200, [], 'primary'), + ); + $fallback = $this->transport( + send: static fn (): ResponseInterface => throw new \RuntimeException('fallback should not run'), + ); + + $adapter = new FailoverTransportAdapter($primary, $fallback); + $response = $adapter->send(new Request('GET', 'https://example.com')); + + self::assertSame('primary', (string) $response->getBody()); + } + + public function test_fails_over_on_network_errors_but_not_http_errors(): void + { + $primary = $this->transport( + send: static fn (): ResponseInterface => throw new NetworkConnectionException('down'), + ); + $fallback = $this->transport( + send: static fn (): ResponseInterface => new Response(200, [], 'fallback'), + ); + + $adapter = new FailoverTransportAdapter($primary, $fallback); + $response = $adapter->send(new Request('GET', 'https://example.com')); + self::assertSame('fallback', (string) $response->getBody()); + + $httpPrimary = $this->transport( + send: static fn (): ResponseInterface => throw new HttpResponseException( + 'HTTP 503', + new Response(503) + ), + ); + + $this->expectException(HttpResponseException::class); + (new FailoverTransportAdapter($httpPrimary, $fallback))->send(new Request('GET', 'https://example.com')); + } + + public function test_send_async_fails_over_when_primary_does_not_support_async(): void + { + $primary = $this->transport( + sendAsync: static fn () => Create::rejectionFor( + new AsyncTransportNotSupportedException('curl is sync-only') + ), + ); + $fallback = $this->transport( + sendAsync: static fn () => Create::promiseFor(new Response(200, [], 'async-fallback')), + ); + + $adapter = new FailoverTransportAdapter($primary, $fallback); + $response = $adapter->sendAsync(new Request('GET', 'https://example.com'))->wait(); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame('async-fallback', (string) $response->getBody()); + } + + public function test_send_async_does_not_failover_http_errors(): void + { + $primary = $this->transport( + sendAsync: static fn () => Create::rejectionFor( + new HttpResponseException('HTTP 404', new Response(404)) + ), + ); + $fallback = $this->transport( + sendAsync: static fn () => Create::promiseFor(new Response(200, [], 'should-not-run')), + ); + + $this->expectException(HttpResponseException::class); + (new FailoverTransportAdapter($primary, $fallback)) + ->sendAsync(new Request('GET', 'https://example.com')) + ->wait(); + } + + public function test_send_async_does_not_failover_timeouts_from_fallback_chain_when_primary_is_http_error(): void + { + $primary = $this->transport( + sendAsync: static fn () => Create::rejectionFor(new TimeoutException('slow')), + ); + $fallback = $this->transport( + sendAsync: static fn () => Create::promiseFor(new Response(200, [], 'recovered')), + ); + + $response = (new FailoverTransportAdapter($primary, $fallback)) + ->sendAsync(new Request('GET', 'https://example.com')) + ->wait(); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame('recovered', (string) $response->getBody()); + } + + /** + * @param callable(): ResponseInterface|null $send + * @param callable(): \GuzzleHttp\Promise\PromiseInterface|null $sendAsync + */ + private function transport(?callable $send = null, ?callable $sendAsync = null): TransportAdapterInterface + { + return new class ($send, $sendAsync) implements TransportAdapterInterface { + public function __construct( + private readonly mixed $send, + private readonly mixed $sendAsync + ) { + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + if (!is_callable($this->send)) { + throw new \RuntimeException('send not configured'); + } + + $response = ($this->send)(); + if (!$response instanceof ResponseInterface) { + throw new \RuntimeException('send must return a response'); + } + + return $response; + } + + public function sendAsync(RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + if (!is_callable($this->sendAsync)) { + throw new \RuntimeException('sendAsync not configured'); + } + + $promise = ($this->sendAsync)(); + if (!$promise instanceof \GuzzleHttp\Promise\PromiseInterface) { + throw new \RuntimeException('sendAsync must return a promise'); + } + + return $promise; + } + }; + } +} diff --git a/tests/Unit/Client/ClientBuilderDxTest.php b/tests/Unit/Client/ClientBuilderDxTest.php index 5a58d15..fb9e621 100644 --- a/tests/Unit/Client/ClientBuilderDxTest.php +++ b/tests/Unit/Client/ClientBuilderDxTest.php @@ -186,4 +186,184 @@ public function test_with_transport_rejects_unknown_transports(): void $transport = 'ftp'; ClientBuilder::create()->withTransport($transport); } + + public function test_with_user_agent_accepts_a_callable_provider(): void + { + ClientBuilder::fake([TestResponse::ok(), TestResponse::ok()]); + + $calls = 0; + $client = ClientBuilder::create() + ->withUserAgent(static function () use (&$calls): string { + $calls++; + + return 'Rotate/' . $calls; + }) + ->build(); + + $client->get('https://example.com/one'); + $client->get('https://example.com/two'); + + self::assertSame(2, $calls); + ClientBuilder::assertSentHeader('User-Agent', 'Rotate/2'); + } + + public function test_with_generated_user_agent_is_sticky_when_a_callable_is_passed(): void + { + ClientBuilder::fake([TestResponse::ok(), TestResponse::ok()]); + + $calls = 0; + $client = ClientBuilder::create() + ->withGeneratedUserAgent(static function () use (&$calls): string { + $calls++; + + return 'Sticky/' . $calls; + }) + ->build(); + + $client->get('https://example.com/one'); + $client->get('https://example.com/two'); + + self::assertSame(1, $calls); + ClientBuilder::assertSentHeader('User-Agent', 'Sticky/1'); + } + + public function test_with_rotating_user_agent_invokes_the_generator_per_request(): void + { + ClientBuilder::fake([TestResponse::ok(), TestResponse::ok()]); + + $calls = 0; + $client = ClientBuilder::create() + ->withRotatingUserAgent(static function () use (&$calls): string { + $calls++; + + return 'Spin/' . $calls; + }) + ->build(); + + $client->get('https://example.com/one'); + $client->get('https://example.com/two'); + + self::assertSame(2, $calls); + } + + public function test_generated_user_agent_without_package_or_callable_fails_closed(): void + { + if (class_exists('JOOservices\\UserAgent\\UserAgent')) { + self::markTestSkipped('jooservices/useragent is installed in this environment.'); + } + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('jooservices/useragent'); + ClientBuilder::create()->withGeneratedUserAgent(); + } + + public function test_with_failover_transport_rejects_unknown_names(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('failover transport'); + ClientBuilder::create()->withFailoverTransport('ftp'); + } + + public function test_with_http_error_mapping_throws_after_retry_sees_the_status(): void + { + ClientBuilder::fake([ + TestResponse::status(503), + TestResponse::status(503), + TestResponse::ok(), + ]); + + $attempts = 0; + $client = ClientBuilder::create() + ->withRetry(new \JOOservices\Client\Resilience\RetryConfig( + maxAttempts: 3, + baseDelayMs: 1, + useJitter: false, + retryableStatuses: [503] + )) + ->withHttpErrorMapping() + ->onError(static function ($outcome) use (&$attempts) { + if ($outcome instanceof \Throwable) { + $attempts++; + } + + return $outcome; + }) + ->build(); + + $response = $client->get('https://example.com/flaky'); + self::assertSame(200, $response->status()); + self::assertSame(0, $attempts); + } + + public function test_with_http_error_mapping_throws_once_retries_are_exhausted(): void + { + ClientBuilder::fake([ + TestResponse::status(500), + TestResponse::status(500), + ]); + + $client = ClientBuilder::create() + ->withRetry(new \JOOservices\Client\Resilience\RetryConfig( + maxAttempts: 2, + baseDelayMs: 1, + useJitter: false, + retryableStatuses: [500] + )) + ->withHttpErrorMapping() + ->build(); + + $this->expectException(\JOOservices\Client\Exceptions\HttpResponseException::class); + $client->get('https://example.com/down'); + } + + public function test_with_failover_transport_uses_the_fallback_on_network_errors(): void + { + $primary = new class () implements \JOOservices\Client\Contracts\TransportAdapterInterface { + public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): \Psr\Http\Message\ResponseInterface + { + throw new \JOOservices\Client\Exceptions\NetworkConnectionException('primary down'); + } + + public function sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + return \GuzzleHttp\Promise\Create::rejectionFor( + new \JOOservices\Client\Exceptions\NetworkConnectionException('primary down') + ); + } + }; + $fallback = new class () implements \JOOservices\Client\Contracts\TransportAdapterInterface { + public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): \Psr\Http\Message\ResponseInterface + { + return new Response(200, [], 'from-fallback'); + } + + public function sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + return \GuzzleHttp\Promise\Create::promiseFor(new Response(200, [], 'from-fallback')); + } + }; + + $client = ClientBuilder::create() + ->withAdapter($primary) + ->withFailoverTransport($fallback) + ->build(); + + self::assertSame('from-fallback', $client->get('https://example.com')->body()); + } + + public function test_with_json_schema_validation_rejects_invalid_bodies(): void + { + ClientBuilder::fake([TestResponse::json(200, ['id' => 'nope'])]); + + $client = ClientBuilder::create() + ->withJsonSchemaValidation([ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer']], + ]) + ->build(); + + $this->expectException(\JOOservices\Client\Exceptions\ResponseValidationException::class); + $client->get('https://example.com/item'); + } } diff --git a/tests/Unit/Client/ClientBuilderNewFeaturesCoverageTest.php b/tests/Unit/Client/ClientBuilderNewFeaturesCoverageTest.php new file mode 100644 index 0000000..d94886c --- /dev/null +++ b/tests/Unit/Client/ClientBuilderNewFeaturesCoverageTest.php @@ -0,0 +1,410 @@ +withAdapter($primary) + ->withFailoverTransport($fallback) + ->withUserAgent('Coverage/1.0') + ->build(); + + self::assertSame('ok', $client->get('https://example.com')->body()); + } + + public function test_named_failover_transports_construct(): void + { + if (!extension_loaded('curl')) { + self::markTestSkipped('ext-curl is required for named curl failover.'); + } + + ClientBuilder::create() + ->withTransport('curl') + ->withFailoverTransport('guzzle') + ->withUserAgent('Coverage/1.0') + ->buildSync(); + + ClientBuilder::create()->withFailoverTransport('curl')->buildSync(); + $this->addToAssertionCount(1); + } + + public function test_json_schema_rejects_an_array_schema_that_is_not_an_object(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('encode to an object'); + new JsonSchemaBodyValidator([1, 2, 3]); + } + + public function test_rate_limit_async_bypass_fail_fast_and_max_wait(): void + { + $bypass = (new RateLimitMiddleware(new RateLimitConfig(maxTokens: 1, refillRatePerSecond: 1), sleeper: new NullSleeper())) + ->processAsync( + new Request('GET', 'https://example.com'), + ['rate_limit_bypass' => true], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $bypass); + + $failFast = new RateLimitMiddleware( + new RateLimitConfig(maxTokens: 1, refillRatePerSecond: 1, mode: RateLimitConfig::MODE_FAIL_FAST), + sleeper: new NullSleeper() + ); + $failFast->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + + $this->expectException(RateLimitExceededException::class); + $failFast->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + ); + } + + public function test_fallback_async_disabled_and_exception_recovery(): void + { + $cache = new MemoryCache(); + $cache->set('http_fallback_' . md5('https://example.com/x'), [ + 'status' => 200, + 'headers' => [], + 'body' => 'stale', + ]); + $middleware = new FallbackMiddleware( + new FallbackConfig(fallbackStatuses: [503], fallbackExceptions: [TimeoutException::class]), + $cache + ); + + $disabled = $middleware->processAsync( + new Request('GET', 'https://example.com/x'), + ['fallback_enabled' => false], + static fn () => Create::promiseFor(new Response(503)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $disabled); + self::assertSame(503, $disabled->getStatusCode()); + + $recovered = $middleware->processAsync( + new Request('GET', 'https://example.com/x'), + [], + static fn () => Create::rejectionFor(new TimeoutException('slow')) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $recovered); + self::assertSame('stale', (string) $recovered->getBody()); + + $this->expectException(NetworkConnectionException::class); + $middleware->processAsync( + new Request('GET', 'https://example.com/x'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('down')) + )->wait(); + } + + public function test_cache_async_bypass_and_oauth_no_refresh(): void + { + $cache = new CacheMiddleware(new MemoryCache(), 60); + $calls = 0; + $cache->processAsync( + new Request('GET', 'https://example.com'), + ['cache_bypass' => true], + static function () use (&$calls) { + $calls++; + + return Create::promiseFor(new Response(200, [], 'fresh')); + } + )->wait(); + self::assertSame(1, $calls); + + $provider = new class () implements \JOOservices\Client\Contracts\TokenProviderInterface { + public function getAccessToken(): string + { + return 'old'; + } + + public function refreshToken(): bool + { + return false; + } + }; + $oauth = new OAuthTokenRefreshMiddleware($provider); + $response = $oauth->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(401)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(401, $response->getStatusCode()); + } + + public function test_retry_async_rejects_a_non_seekable_body(): void + { + $retry = new RetryMiddleware(new RetryConfig(maxAttempts: 2, baseDelayMs: 1, useJitter: false), new NullSleeper()); + $body = \GuzzleHttp\Psr7\FnStream::decorate(\GuzzleHttp\Psr7\Utils::streamFor('x'), [ + 'tell' => static fn (): int => 1, + 'isSeekable' => static fn (): bool => false, + ]); + $request = new Request('GET', 'https://example.com', [], $body); + + $promise = $retry->processAsync( + $request, + [], + static fn () => Create::promiseFor(new Response(503)) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $promise->getState()); + } + + public function test_rate_limit_async_exceeds_max_wait(): void + { + $middleware = new RateLimitMiddleware( + new RateLimitConfig(maxTokens: 1, refillRatePerSecond: 1, maxWaitMs: 0), + sleeper: new NullSleeper() + ); + $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + + $sleeper = new RateLimitMiddleware( + new RateLimitConfig(maxTokens: 1, refillRatePerSecond: 1000, maxWaitMs: 5000), + sleeper: new NullSleeper() + ); + $sleeper->processAsync( + new Request('GET', 'https://example.com/a'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + $sleeper->processAsync( + new Request('GET', 'https://example.com/a'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + + $this->expectException(RateLimitExceededException::class); + $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + ); + } + + public function test_circuit_async_records_rejected_promises(): void + { + $middleware = new \JOOservices\Client\Middleware\CircuitBreakerMiddleware( + new \JOOservices\Client\Resilience\CircuitBreakerConfig(failureThreshold: 5), + new \JOOservices\Client\Resilience\Storage\InMemoryStateStore() + ); + $promise = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('down')) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $promise->getState()); + } + + public function test_connection_reuse_tracker_accepts_numeric_ids(): void + { + $tracker = new ConnectionReuseTracker(); + self::assertFalse($tracker->wasReused('10')); + self::assertTrue($tracker->wasReused(10.0)); + self::assertNull($tracker->wasReused('nope')); + } + + public function test_metrics_async_records_cache_hit_tag(): void + { + $recorder = new \JOOservices\Client\Support\InMemoryMetricsRecorder(); + $middleware = new \JOOservices\Client\Middleware\MetricsMiddleware($recorder); + $middleware->processAsync( + new Request('GET', 'https://example.com'), + ['cache_hit' => true], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + + $records = $recorder->getRecords(); + self::assertNotSame([], $records); + self::assertTrue($records[0]['tags']['cache_hit'] ?? false); + } + + public function test_generated_user_agent_uses_the_useragent_class_when_present(): void + { + $stubbed = !class_exists('JOOservices\\UserAgent\\UserAgent'); + if ($stubbed) { + eval(<<<'PHP' +namespace JOOservices\UserAgent; +class UserAgent { + public static function generate(): string + { + return 'StubUA/1.0'; + } +} +PHP); + } + + ClientBuilder::fake([TestResponse::ok()]); + $client = ClientBuilder::create()->withGeneratedUserAgent()->build(); + self::assertSame(200, $client->get('https://example.com')->status()); + if ($stubbed) { + ClientBuilder::assertSentHeader('User-Agent', 'StubUA/1.0'); + } else { + ClientBuilder::assertSentHeader('User-Agent'); + } + } + + public function test_retry_async_passes_through_non_retryable_methods(): void + { + $retry = new RetryMiddleware(new RetryConfig(maxAttempts: 2), new NullSleeper()); + $response = $retry->processAsync( + new Request('POST', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $response); + } + + public function test_bulkhead_async_releases_on_success_and_on_sync_throw(): void + { + $middleware = new \JOOservices\Client\Middleware\BulkheadMiddleware( + new \JOOservices\Client\Resilience\BulkheadConfig(maxConcurrent: 2) + ); + $ok = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $ok); + + $rejected = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('down')) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $rejected->getState()); + + $this->expectException(\RuntimeException::class); + $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static function (): never { + throw new \RuntimeException('sync boom'); + } + ); + } + + public function test_cache_async_returns_cached_body_on_304(): void + { + $psrCache = new MemoryCache(); + $middleware = new CacheMiddleware($psrCache, 60, new \JOOservices\Client\ValueObjects\CacheConfig( + respectHttpCacheHeaders: true, + sendConditionalHeaders: true + )); + $request = new Request('GET', 'https://example.com/304'); + $middleware->processAsync( + $request, + [], + static fn () => Create::promiseFor(new Response(200, ['ETag' => '"abc"', 'Cache-Control' => 'max-age=0'], 'cached-body')) + )->wait(); + + $second = $middleware->processAsync( + $request, + [], + static fn () => Create::promiseFor(new Response(304, ['ETag' => '"abc"'])) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $second); + self::assertSame('cached-body', (string) $second->getBody()); + } + + public function test_fallback_async_uses_cached_body_for_fallback_status(): void + { + $cache = new MemoryCache(); + $cache->set('http_fallback_' . md5('https://example.com/stale'), [ + 'status' => 200, + 'headers' => [], + 'body' => 'stale-status', + ]); + $middleware = new FallbackMiddleware(new FallbackConfig(fallbackStatuses: [503]), $cache); + $response = $middleware->processAsync( + new Request('GET', 'https://example.com/stale'), + [], + static fn () => Create::promiseFor(new Response(503)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame('stale-status', (string) $response->getBody()); + } + + public function test_http_error_mapping_is_registered_with_retry_on_a_fake_client(): void + { + ClientBuilder::fake([TestResponse::ok()]); + $client = ClientBuilder::create() + ->withHttpErrorMapping([418]) + ->withRetry(new RetryConfig(maxAttempts: 1)) + ->build(); + + self::assertSame(200, $client->get('https://example.com')->status()); + } +} diff --git a/tests/Unit/Middleware/AsyncFirstPartyMiddlewareTest.php b/tests/Unit/Middleware/AsyncFirstPartyMiddlewareTest.php new file mode 100644 index 0000000..1716936 --- /dev/null +++ b/tests/Unit/Middleware/AsyncFirstPartyMiddlewareTest.php @@ -0,0 +1,375 @@ +getHeaders(); + + return Create::promiseFor(new Response(200)); + }; + + (new ApiVersionMiddleware('X-API-Version', '3'))->processAsync(new Request('GET', 'https://example.com'), [], $next)->wait(); + self::assertSame(['3'], $seen['X-API-Version'] ?? null); + + (new AuthenticationMiddleware(new AuthenticationConfig(AuthenticationType::Bearer, 'tok'))) + ->processAsync(new Request('GET', 'https://example.com'), [], $next)->wait(); + self::assertSame(['Bearer tok'], $seen['Authorization'] ?? null); + + (new IdempotencyKeyMiddleware())->processAsync( + new Request('POST', 'https://example.com'), + ['idempotency_key' => 'abc'], + $next + )->wait(); + self::assertSame(['abc'], $seen['Idempotency-Key'] ?? null); + + (new RequestSigningMiddleware(new HmacSha256Signer('secret'))) + ->processAsync(new Request('GET', 'https://example.com'), [], $next)->wait(); + + $deadlineOptions = []; + (new DeadlineMiddleware(1500))->processAsync( + new Request('GET', 'https://example.com'), + [], + static function (RequestInterface $request, array $options) use (&$deadlineOptions) { + $deadlineOptions = $options; + + return Create::promiseFor(new Response(200)); + } + )->wait(); + self::assertEqualsWithDelta(1.5, $deadlineOptions['timeout'] ?? 0, 0.001); + + $progressOptions = []; + (new ProgressMiddleware(static function (int $total, int $done): void { + })) + ->processAsync( + new Request('GET', 'https://example.com'), + [], + static function (RequestInterface $request, array $options) use (&$progressOptions) { + $progressOptions = $options; + + return Create::promiseFor(new Response(200)); + } + )->wait(); + self::assertArrayHasKey('progress', $progressOptions); + } + + public function test_correlation_and_trace_propagate_on_async_responses(): void + { + $response = (new CorrelationIdMiddleware())->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertNotSame('', $response->getHeaderLine('X-Correlation-ID')); + + $traced = (new TraceContextMiddleware())->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $traced); + self::assertNotSame('', $traced->getHeaderLine('traceparent')); + } + + public function test_logging_metrics_and_validation_observe_async_responses_and_errors(): void + { + $logger = new NullLogger(); + $ok = (new LoggingMiddleware($logger))->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $ok); + + $rejectedLog = (new LoggingMiddleware($logger))->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('down')) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $rejectedLog->getState()); + + $metrics = new InMemoryMetricsRecorder(); + (new MetricsMiddleware($metrics))->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(201)) + )->wait(); + + $rejectedMetrics = (new MetricsMiddleware($metrics))->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('down')) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $rejectedMetrics->getState()); + + $validated = (new ResponseValidationMiddleware(new ResponseValidationConfig(expectedStatuses: [200]))) + ->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $validated); + } + + public function test_retry_async_retries_retryable_statuses_and_exceptions(): void + { + $attempts = 0; + $retry = new RetryMiddleware(new RetryConfig( + maxAttempts: 3, + baseDelayMs: 1, + useJitter: false, + retryableStatuses: [503], + retryableExceptions: [NetworkConnectionException::class] + ), new NullSleeper()); + + $response = $retry->processAsync( + new Request('GET', 'https://example.com'), + [], + static function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + return Create::promiseFor(new Response(503)); + } + + return Create::promiseFor(new Response(200)); + } + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame(3, $attempts); + + $attempts = 0; + $recovered = $retry->processAsync( + new Request('GET', 'https://example.com'), + [], + static function () use (&$attempts) { + $attempts++; + if ($attempts < 2) { + return Create::rejectionFor(new NetworkConnectionException('blip')); + } + + return Create::promiseFor(new Response(200)); + } + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $recovered); + self::assertSame(2, $attempts); + } + + public function test_cache_async_returns_a_cached_hit_without_calling_next(): void + { + $cache = new MemoryCache(); + $middleware = new CacheMiddleware($cache, 60); + $request = new Request('GET', 'https://example.com/cached'); + $calls = 0; + $next = static function () use (&$calls) { + $calls++; + + return Create::promiseFor(new Response(200, [], 'body')); + }; + + $first = $middleware->processAsync($request, [], $next)->wait(); + $second = $middleware->processAsync($request, [], $next)->wait(); + + self::assertInstanceOf(ResponseInterface::class, $first); + self::assertInstanceOf(ResponseInterface::class, $second); + self::assertSame(1, $calls); + self::assertSame('body', (string) $second->getBody()); + + $calls = 0; + $bypassed = $middleware->processAsync( + new Request('POST', 'https://example.com/cached'), + [], + static function () use (&$calls) { + $calls++; + + return Create::promiseFor(new Response(201, [], 'post')); + } + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $bypassed); + self::assertSame(1, $calls); + } + + public function test_circuit_and_bulkhead_reject_on_the_async_path(): void + { + $open = new CircuitBreakerMiddleware( + new CircuitBreakerConfig(failureThreshold: 1, recoveryTimeoutMs: 60_000), + new InMemoryStateStore() + ); + $failed = $open->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::rejectionFor(new NetworkConnectionException('fail')) + ); + \GuzzleHttp\Promise\Utils::queue()->run(); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::REJECTED, $failed->getState()); + + $this->expectException(CircuitOpenException::class); + $open->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + ); + } + + public function test_circuit_async_records_failure_statuses_and_successes(): void + { + $store = new InMemoryStateStore(); + $middleware = new CircuitBreakerMiddleware( + new CircuitBreakerConfig(failureThreshold: 5, failureStatuses: [500]), + $store + ); + + $ok = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $ok); + + $failed = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(500)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $failed); + self::assertSame(500, $failed->getStatusCode()); + } + + public function test_bulkhead_async_releases_the_slot_after_the_promise_resolves(): void + { + $middleware = new BulkheadMiddleware(new BulkheadConfig(maxConcurrent: 1)); + $pending = new \GuzzleHttp\Promise\Promise(); + $held = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => $pending + ); + self::assertSame(\GuzzleHttp\Promise\PromiseInterface::PENDING, $held->getState()); + + $this->expectException(BulkheadRejectedException::class); + $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200)) + ); + } + + public function test_rate_limit_fallback_oauth_and_coalescing_async_paths(): void + { + $rateLimited = (new RateLimitMiddleware(new RateLimitConfig(maxTokens: 2, refillRatePerSecond: 100), sleeper: new NullSleeper())) + ->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(200, ['RateLimit-Reset' => (string) (time() + 5)])) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $rateLimited); + + $fallback = new FallbackMiddleware(new FallbackConfig(fallbackStatuses: [503]), new MemoryCache()); + $miss = $fallback->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(503)) + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $miss); + self::assertSame(503, $miss->getStatusCode()); + + $provider = new class () implements TokenProviderInterface { + public function getAccessToken(): string + { + return 'new-token'; + } + + public function refreshToken(): bool + { + return true; + } + }; + $attempts = 0; + $oauth = new OAuthTokenRefreshMiddleware($provider, new OAuthTokenRefreshConfig(maxRefreshAttempts: 1)); + $refreshed = $oauth->processAsync( + new Request('GET', 'https://example.com'), + [], + static function () use (&$attempts) { + $attempts++; + if ($attempts === 1) { + return Create::promiseFor(new Response(401)); + } + + return Create::promiseFor(new Response(200)); + } + )->wait(); + self::assertInstanceOf(ResponseInterface::class, $refreshed); + self::assertSame(2, $attempts); + + $coalesce = new RequestCoalescingMiddleware(); + $calls = 0; + $pending = new \GuzzleHttp\Promise\Promise(); + $next = static function () use (&$calls, $pending) { + $calls++; + + return $pending; + }; + $first = $coalesce->processAsync(new Request('GET', 'https://example.com/c'), [], $next); + $second = $coalesce->processAsync(new Request('GET', 'https://example.com/c'), [], $next); + self::assertSame(1, $calls); + $pending->resolve(new Response(200)); + $first->wait(); + $second->wait(); + } +} diff --git a/tests/Unit/Middleware/AsyncMiddlewarePipelineTest.php b/tests/Unit/Middleware/AsyncMiddlewarePipelineTest.php new file mode 100644 index 0000000..a69f567 --- /dev/null +++ b/tests/Unit/Middleware/AsyncMiddlewarePipelineTest.php @@ -0,0 +1,207 @@ +push(new class ($probe) implements AsyncMiddlewareInterface { + public function __construct(private AsyncPipelineProbe $probe) + { + } + + public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + return $next($request, $options); + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + $this->probe->entered = true; + + return $next($request, $options); + } + }, 'probe'); + + $stack = $pipeline->buildHandlerStack(); + $this->setMalformedHandler($stack, static fn (): PromiseInterface => $inner); + + $promise = $stack->resolve()(new Request('GET', '/'), []); + + self::assertTrue($probe->entered); + self::assertSame(PromiseInterface::PENDING, $promise->getState()); + + $inner->resolve(new Response(200)); + $response = $promise->wait(); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(200, $response->getStatusCode()); + } + + public function test_sync_handler_order_matches_guzzle_last_pushed_outermost(): void + { + $pipeline = new MiddlewarePipeline(); + $log = new AsyncPipelineLog(); + $pipeline->push($this->loggingMiddleware($log, 'inner'), 'inner'); + $pipeline->push($this->loggingMiddleware($log, 'outer'), 'outer'); + + $handler = $pipeline->buildSynchronousHandler( + static function () use ($log): ResponseInterface { + $log->entries[] = 'transport'; + + return new Response(200); + } + ); + $handler(new Request('GET', '/'), []); + + self::assertSame(['outer_req', 'inner_req', 'transport', 'inner_res', 'outer_res'], $log->entries); + } + + public function test_async_handler_order_matches_last_pushed_outermost(): void + { + $pipeline = new MiddlewarePipeline(); + $log = new AsyncPipelineLog(); + $pipeline->push($this->loggingMiddleware($log, 'inner'), 'inner'); + $pipeline->push($this->loggingMiddleware($log, 'outer'), 'outer'); + + $handler = $pipeline->buildAsynchronousHandler( + static function () use ($log): PromiseInterface { + $log->entries[] = 'transport'; + + return Create::promiseFor(new Response(200)); + } + ); + $handler(new Request('GET', '/'), [])->wait(); + + self::assertSame(['outer_req', 'inner_req', 'transport', 'inner_res', 'outer_res'], $log->entries); + } + + public function test_sync_only_middleware_still_runs_on_the_async_path(): void + { + $pipeline = new MiddlewarePipeline(); + $pipeline->push(new class () implements MiddlewareInterface { + public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + return $next($request->withHeader('X-Sync', '1'), $options); + } + }, 'sync'); + + $seen = null; + $handler = $pipeline->buildAsynchronousHandler( + static function (RequestInterface $request) use (&$seen): PromiseInterface { + $seen = $request->getHeaderLine('X-Sync'); + + return Create::promiseFor(new Response(200)); + } + ); + $handler(new Request('GET', '/'), [])->wait(); + + self::assertSame('1', $seen); + } + + public function test_builder_async_requests_stay_pending_until_the_handler_resolves(): void + { + $inner = new Promise(); + $stack = HandlerStack::create(); + $this->setMalformedHandler($stack, static fn (): PromiseInterface => $inner); + + $client = ClientBuilder::create() + ->withOption('handler', $stack) + ->withUserAgent('AsyncProbe/1.0') + ->build(); + + $promise = $client->getAsync('https://example.com/async'); + + self::assertSame(PromiseInterface::PENDING, $promise->getState()); + + $inner->resolve(new Response(200, [], '{"ok":true}')); + $response = $promise->wait(); + self::assertInstanceOf(\JOOservices\Client\Contracts\ResponseWrapperInterface::class, $response); + + self::assertSame(200, $response->status()); + self::assertSame(['ok' => true], $response->json()); + } + + public function test_user_agent_middleware_is_async_capable(): void + { + $middleware = new UserAgentMiddleware('Probe/1'); + $seen = null; + $promise = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static function (RequestInterface $request) use (&$seen): PromiseInterface { + $seen = $request->getHeaderLine('User-Agent'); + + return Create::promiseFor(new Response(200)); + } + ); + $promise->wait(); + + self::assertSame('Probe/1', $seen); + } + + private function loggingMiddleware(AsyncPipelineLog $log, string $name): AsyncMiddlewareInterface + { + return new class ($log, $name) implements AsyncMiddlewareInterface { + public function __construct(private AsyncPipelineLog $log, private string $name) + { + } + + public function __invoke(RequestInterface $request, array $options, Closure $next): ResponseInterface + { + $this->log->entries[] = $this->name . '_req'; + $response = $next($request, $options); + $this->log->entries[] = $this->name . '_res'; + + return $response; + } + + public function processAsync(RequestInterface $request, array $options, Closure $next): PromiseInterface + { + $this->log->entries[] = $this->name . '_req'; + + return $next($request, $options)->then(function (mixed $value): mixed { + $this->log->entries[] = $this->name . '_res'; + + return $value; + }); + } + }; + } +} + +final class AsyncPipelineLog +{ + /** @var list */ + public array $entries = []; +} + +final class AsyncPipelineProbe +{ + public bool $entered = false; +} diff --git a/tests/Unit/Middleware/HttpErrorMappingMiddlewareTest.php b/tests/Unit/Middleware/HttpErrorMappingMiddlewareTest.php new file mode 100644 index 0000000..0d8192d --- /dev/null +++ b/tests/Unit/Middleware/HttpErrorMappingMiddlewareTest.php @@ -0,0 +1,73 @@ + new Response(204) + ); + + self::assertSame(204, $response->getStatusCode()); + } + + public function test_throws_http_response_exception_for_default_error_statuses(): void + { + $middleware = new HttpErrorMappingMiddleware(); + + $this->expectException(HttpResponseException::class); + $this->expectExceptionMessage('HTTP 503'); + $middleware( + new Request('GET', 'https://example.com'), + [], + static fn (): Response => new Response(503) + ); + } + + public function test_maps_only_configured_statuses(): void + { + $middleware = new HttpErrorMappingMiddleware([404]); + $ok = $middleware( + new Request('GET', 'https://example.com'), + [], + static fn (): Response => new Response(500) + ); + self::assertSame(500, $ok->getStatusCode()); + + $this->expectException(HttpResponseException::class); + $middleware( + new Request('GET', 'https://example.com'), + [], + static fn (): Response => new Response(404) + ); + } + + public function test_process_async_maps_error_statuses(): void + { + $middleware = new HttpErrorMappingMiddleware(); + $promise = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => Create::promiseFor(new Response(401)) + ); + + $this->expectException(HttpResponseException::class); + $promise->wait(); + } +} diff --git a/tests/Unit/Middleware/InterceptorOnErrorTest.php b/tests/Unit/Middleware/InterceptorOnErrorTest.php index 45857dd..3952054 100644 --- a/tests/Unit/Middleware/InterceptorOnErrorTest.php +++ b/tests/Unit/Middleware/InterceptorOnErrorTest.php @@ -146,4 +146,20 @@ public function test_error_interceptor_is_not_invoked_on_success(): void self::assertSame(204, $response->getStatusCode()); self::assertFalse($called); } + + public function test_process_async_recovers_rejected_promises_through_on_error(): void + { + $middleware = new InterceptorMiddleware(); + $middleware->onError(static fn (Throwable|ResponseInterface $outcome): ResponseInterface => new Response(203, [], 'async-recovered')); + + $response = $middleware->processAsync( + new Request('GET', 'https://example.com'), + [], + static fn () => \GuzzleHttp\Promise\Create::rejectionFor(new RuntimeException('boom')) + )->wait(); + + self::assertInstanceOf(ResponseInterface::class, $response); + self::assertSame(203, $response->getStatusCode()); + self::assertSame('async-recovered', (string) $response->getBody()); + } } diff --git a/tests/Unit/Middleware/MiddlewarePipelineCoverageTest.php b/tests/Unit/Middleware/MiddlewarePipelineCoverageTest.php index 79dcaea..bfe69de 100644 --- a/tests/Unit/Middleware/MiddlewarePipelineCoverageTest.php +++ b/tests/Unit/Middleware/MiddlewarePipelineCoverageTest.php @@ -87,14 +87,9 @@ public function __invoke(\Psr\Http\Message\RequestInterface $request, array $opt public function test_rejects_a_non_promise_non_response_handler_result(): void { - $pipeline = new MiddlewarePipeline(); - $method = new \ReflectionMethod($pipeline, 'buildNextClosure'); - /** @var \Closure(\Psr\Http\Message\RequestInterface, array): \Psr\Http\Message\ResponseInterface $next */ - $next = $method->invoke($pipeline, fn () => 'invalid'); - $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('invalid response type'); - $next(new \GuzzleHttp\Psr7\Request('GET', '/'), []); + \JOOservices\Client\Support\MiddlewarePromise::from('invalid')->wait(); } public function test_same_name_insert_before_replaces_middleware_without_corrupting_order(): void diff --git a/tests/Unit/Middleware/UserAgentMiddlewareTest.php b/tests/Unit/Middleware/UserAgentMiddlewareTest.php index b6a2c0c..e836706 100644 --- a/tests/Unit/Middleware/UserAgentMiddlewareTest.php +++ b/tests/Unit/Middleware/UserAgentMiddlewareTest.php @@ -52,4 +52,35 @@ public function test_replaces_empty_user_agent(): void $middleware($request, [], $next); } + + public function test_callable_provider_is_invoked_per_request(): void + { + $calls = 0; + $middleware = new UserAgentMiddleware(static function () use (&$calls): string { + $calls++; + + return 'Gen/' . $calls; + }); + + $middleware(new Request('GET', 'https://example.com'), [], static function ($req) { + self::assertSame('Gen/1', $req->getHeaderLine('User-Agent')); + + return new Response(200); + }); + $middleware(new Request('GET', 'https://example.com'), [], static function ($req) { + self::assertSame('Gen/2', $req->getHeaderLine('User-Agent')); + + return new Response(200); + }); + + self::assertSame(2, $calls); + } + + public function test_callable_provider_must_return_a_non_empty_string(): void + { + $middleware = new UserAgentMiddleware(static fn (): string => ' '); + + $this->expectException(\JOOservices\Client\Exceptions\InvalidConfigurationException::class); + $middleware(new Request('GET', 'https://example.com'), [], static fn () => new Response(200)); + } } diff --git a/tests/Unit/Support/MiddlewarePromiseTest.php b/tests/Unit/Support/MiddlewarePromiseTest.php new file mode 100644 index 0000000..9640522 --- /dev/null +++ b/tests/Unit/Support/MiddlewarePromiseTest.php @@ -0,0 +1,52 @@ +wait(); + self::assertInstanceOf(\Psr\Http\Message\ResponseInterface::class, $response); + self::assertSame(201, $response->getStatusCode()); + } + + public function test_from_rejects_throwables_and_invalid_values(): void + { + $this->expectException(RuntimeException::class); + MiddlewarePromise::from(new RuntimeException('boom'))->wait(); + } + + public function test_response_rejects_non_responses(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('non-response value'); + MiddlewarePromise::response('nope'); + } + + public function test_throwable_wraps_non_throwable_reasons(): void + { + $wrapped = MiddlewarePromise::throwable('nope'); + + self::assertInstanceOf(RuntimeException::class, $wrapped); + self::assertStringContainsString('non-throwable', $wrapped->getMessage()); + self::assertSame('boom', MiddlewarePromise::throwable(new RuntimeException('boom'))->getMessage()); + } +} diff --git a/tests/Unit/Validation/JsonSchemaBodyValidatorTest.php b/tests/Unit/Validation/JsonSchemaBodyValidatorTest.php new file mode 100644 index 0000000..04dcc24 --- /dev/null +++ b/tests/Unit/Validation/JsonSchemaBodyValidatorTest.php @@ -0,0 +1,85 @@ + 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]); + + $validator(new Response(200, [], '{"id": 1}'), []); + $this->addToAssertionCount(1); + } + + public function test_rejects_a_body_that_fails_the_schema(): void + { + $validator = new JsonSchemaBodyValidator([ + 'type' => 'object', + 'required' => ['id'], + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]); + + $this->expectException(ResponseValidationException::class); + $this->expectExceptionMessage('JSON Schema'); + $validator(new Response(200, [], '{"id": "nope"}'), []); + } + + public function test_rejects_invalid_json_bodies(): void + { + $validator = new JsonSchemaBodyValidator(['type' => 'object']); + + $this->expectException(ResponseValidationException::class); + $this->expectExceptionMessage('not valid JSON'); + $validator(new Response(200, [], '{'), []); + } + + public function test_rejects_invalid_schema_json_strings(): void + { + $this->expectException(InvalidConfigurationException::class); + new JsonSchemaBodyValidator('{'); + } + + public function test_config_factory_wires_the_validator(): void + { + $config = ResponseValidationConfig::jsonSchema(['type' => 'object']); + + self::assertInstanceOf(JsonSchemaBodyValidator::class, $config->bodyValidator); + } + + public function test_accepts_a_json_string_schema_and_object_schema(): void + { + $fromString = new JsonSchemaBodyValidator('{"type":"object"}'); + $fromString(new Response(200, [], '{}'), []); + + $fromObject = new JsonSchemaBodyValidator((object) ['type' => 'object']); + $fromObject(new Response(200, [], '{}'), []); + $this->addToAssertionCount(2); + } + + public function test_rejects_a_json_schema_string_that_is_not_an_object(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('object'); + new JsonSchemaBodyValidator('[1,2,3]'); + } +}