diff --git a/README.md b/README.md index 02ab1ec..673cd3c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Generic `Request` ensures `$client->send(request: $request)` returns - **Fiber-compatible** — synchronous API that transparently supports async via AMP/Revolt fibers - **Flexible hydration** — default `ResponseHydrator` on the client, per-request override via `HasResponseHydrator` - **Typed error handling** — map `4xx/5xx` responses into typed exceptions with reusable error handlers +- **Per-request timeouts** — set total and connect timeouts globally, per client, or per endpoint ## Installation @@ -327,6 +328,60 @@ try { } ``` +## Timeouts + +Timeouts travel with the request as a `TransportOptions` value. `null` means "not set here" and never overwrites a value set earlier, so precedence is always the same: **request > client > global default**. + +```php +use Zlodes\Http\Client\Factory\Option\WithTimeout; + +// Global default, applied to every client from this factory +$factory = new ClientFactory(defaults: [ + new WithTransport($transport), + new WithResponseHydrator($hydrator), + new WithTimeout(timeout: 10, connectTimeout: 3), +]); + +// Per client: overrides only the values it sets +$slowClient = $factory->make( + new WithTimeout(timeout: 60), +); +``` + +For a single endpoint, implement `HasTransportOptions`. Those values win over everything else: + +```php +use Zlodes\Http\Client\Contract\HasTransportOptions; +use Zlodes\Http\Client\TransportOptions; + +final readonly class SlowReportRequest implements RequestContract, HasTransportOptions +{ + public function getTransportOptions(): TransportOptions + { + return new TransportOptions(timeout: 120); + } + + // ... getName(), buildRequest(), getResponseClass() +} +``` + +`WithTimeout` only adds a `TimeoutMiddleware` to the pipeline, so the same behaviour is available without the factory: + +```php +use Zlodes\Http\Client\Middleware\TimeoutMiddleware; + +$client = new HttpClient( + transport: $transport, + responseHydrator: $responseHydrator, + middlewares: [new TimeoutMiddleware(timeout: 10, connectTimeout: 3)], +); +``` + +Which transport actually applies the options: + +- `GuzzleTransport` maps them to Guzzle's `timeout` and `connect_timeout`, and sends with `http_errors` disabled. A cURL timeout (error 28) is thrown as `TransportTimeoutException`, which extends `TransportException`, so a timeout is distinguishable from a refused connection. +- `Psr18Transport` ignores them. PSR-18 has no per-request options, so configure timeouts on the wrapped client instead. + ## Middleware Middleware follows an onion model. Each middleware receives a `RequestContext` and a `RequestHandler $next`: @@ -485,13 +540,14 @@ $billingClient = $factory->make( ### Available options -| Option | Behavior | -|------------------------------------------------------|---------------------------------------| -| `WithTransport(Transport)` | Sets the transport (last-writer-wins) | -| `WithResponseHydrator(ResponseHydrator)` | Sets the hydrator (last-writer-wins) | -| `WithMiddleware(Middleware ...)` | Appends middlewares (additive) | -| `WithErrorResponseHandler(ErrorResponseHandler ...)` | Appends error handlers (additive) | -| `WithBaseUri(UriInterface)` | Sets the base URI (last-writer-wins) | +| Option | Behavior | +|------------------------------------------------------|------------------------------------------| +| `WithTransport(Transport)` | Sets the transport (last-writer-wins) | +| `WithResponseHydrator(ResponseHydrator)` | Sets the hydrator (last-writer-wins) | +| `WithMiddleware(Middleware ...)` | Appends middlewares (additive) | +| `WithErrorResponseHandler(ErrorResponseHandler ...)` | Appends error handlers (additive) | +| `WithBaseUri(UriInterface)` | Sets the base URI (last-writer-wins) | +| `WithTimeout(?timeout, ?connectTimeout)` | Appends a `TimeoutMiddleware` (additive) | Defaults are applied first, then `make()` options on top. Last-writer-wins options can be overridden per client; additive options accumulate across defaults and `make()` calls. @@ -519,11 +575,12 @@ $factory = new ClientFactory( ``` HttpClient::send(Request) - ├── builds RequestContext (PSR-7 request + name + factory) + ├── builds RequestContext (PSR-7 request + name + factory + TransportOptions) ├── MiddlewarePipeline (onion chain) │ ├── Middleware 1 │ ├── Middleware 2 - │ └── Transport::send() (innermost) + │ ├── request-level TransportOptions (innermost, wins) + │ └── Transport::send(request, options) └── ResponseHydrator::hydrate() → T ``` diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md new file mode 100644 index 0000000..524c7b2 --- /dev/null +++ b/UPGRADE-1.0.md @@ -0,0 +1,56 @@ +# Upgrading to 1.0 + +## Breaking change: `Transport::send()` takes options + +```php +// Before +public function send(RequestInterface $request): ResponseInterface; + +// After +public function send(RequestInterface $request, TransportOptions $options): ResponseInterface; +``` + +Every `Transport` implementation must accept the second argument. Apply the options you support and document the ones you ignore. + +`TransportOptions` holds `timeout` and `connectTimeout`, in seconds, both nullable. `null` means "not set" — leave the transport's own default in place. It never means "no limit". + +```php +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Zlodes\Http\Client\Contract\Transport; +use Zlodes\Http\Client\TransportOptions; + +final readonly class MyTransport implements Transport +{ + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface + { + $timeout = $options->timeout ?? 30; + + // ... + } +} +``` + +## Test doubles + +Anonymous transports and mocks need the new parameter. Closures passed to `withArgs()` keep working, because an extra argument is ignored. A single `with()` matcher needs a second one: + +```php +// Before +$transport->shouldReceive('send')->once()->with(Mockery::on($matcher)); + +// After +$transport->shouldReceive('send')->once()->with(Mockery::on($matcher), Mockery::any()); +``` + +## `RequestContext` + +`RequestContext` now carries `transportOptions`. The constructor parameter is optional and defaults to nothing set, so existing `new RequestContext(...)` calls keep working. `withHttpRequest()` and `withFreshHttpRequest()` keep the options, so a retry or token refresh does not drop the timeout. + +## Timeouts + +Set them with `WithTimeout` on a `ClientFactory`, with `TimeoutMiddleware` directly, or per endpoint by implementing `HasTransportOptions`. Precedence is request > client > global default. + +`Psr18Transport` ignores `TransportOptions`. PSR-18 has no per-request options; configure timeouts on the wrapped client. + +`GuzzleTransport` applies them. It requires `guzzlehttp/guzzle`, which this library now depends on. diff --git a/composer.json b/composer.json index 22578e5..d4844b6 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "type": "library", "require": { "php": "^8.4", + "guzzlehttp/guzzle": "^7.9", "psr/http-client": "^1.0", "psr/http-message": "^2.0" }, diff --git a/src/Contract/HasTransportOptions.php b/src/Contract/HasTransportOptions.php new file mode 100644 index 0000000..2c1f103 --- /dev/null +++ b/src/Contract/HasTransportOptions.php @@ -0,0 +1,12 @@ +middlewares[] = new TimeoutMiddleware($this->timeout, $this->connectTimeout); + } +} diff --git a/src/HttpClient.php b/src/HttpClient.php index 6fc3c37..607bd9e 100644 --- a/src/HttpClient.php +++ b/src/HttpClient.php @@ -12,6 +12,7 @@ use Zlodes\Http\Client\Contract\ErrorResponseHandler; use Zlodes\Http\Client\Contract\HasErrorResponseHandlers; use Zlodes\Http\Client\Contract\HasResponseHydrator; +use Zlodes\Http\Client\Contract\HasTransportOptions; use Zlodes\Http\Client\Contract\Middleware; use Zlodes\Http\Client\Contract\Request; use Zlodes\Http\Client\Contract\Response; @@ -20,6 +21,7 @@ use Zlodes\Http\Client\Exception\HttpClientException; use Zlodes\Http\Client\Exception\HttpErrorException; use Zlodes\Http\Client\Exception\HydrationException; +use Zlodes\Http\Client\Middleware\RequestTransportOptionsMiddleware; final readonly class HttpClient implements Client { @@ -58,7 +60,7 @@ public function send(Request $request): Response requestFactory: fn(): RequestInterface => $this->applyBaseUri($request->buildRequest()), ); - $httpResponse = $this->pipeline->handle($context); + $httpResponse = $this->pipeline->handle($context, $this->requestOptionsStep($request)); if ($httpResponse->getStatusCode() >= 400) { $this->throwForErrorResponse($httpResponse, $request); @@ -145,6 +147,20 @@ private function throwForErrorResponse(ResponseInterface $response, Request $req throw new HttpErrorException($request, $response); } + /** + * Innermost pipeline step, so request options win over every middleware. + * + * @param Request $request + */ + private function requestOptionsStep(Request $request): ?Middleware + { + if (! $request instanceof HasTransportOptions) { + return null; + } + + return new RequestTransportOptionsMiddleware($request->getTransportOptions()); + } + private function applyBaseUri(RequestInterface $httpRequest): RequestInterface { if ($this->baseUri === null) { diff --git a/src/Middleware/RequestTransportOptionsMiddleware.php b/src/Middleware/RequestTransportOptionsMiddleware.php new file mode 100644 index 0000000..63e337e --- /dev/null +++ b/src/Middleware/RequestTransportOptionsMiddleware.php @@ -0,0 +1,27 @@ +handle($context->withTransportOptions( + $context->transportOptions->merge($this->options), + )); + } +} diff --git a/src/Middleware/TimeoutMiddleware.php b/src/Middleware/TimeoutMiddleware.php new file mode 100644 index 0000000..6fd7d3a --- /dev/null +++ b/src/Middleware/TimeoutMiddleware.php @@ -0,0 +1,31 @@ +handle($context->withTransportOptions( + $context->transportOptions->merge(new TransportOptions($this->timeout, $this->connectTimeout)), + )); + } +} diff --git a/src/MiddlewarePipeline.php b/src/MiddlewarePipeline.php index 9d848a2..30d7118 100644 --- a/src/MiddlewarePipeline.php +++ b/src/MiddlewarePipeline.php @@ -20,10 +20,14 @@ public function __construct( ) { } - public function handle(RequestContext $context): ResponseInterface + public function handle(RequestContext $context, ?Middleware $innermost = null): ResponseInterface { $handler = new TransportHandler($this->transport); + if ($innermost !== null) { + $handler = new MiddlewareHandler($innermost, $handler); + } + foreach (array_reverse($this->middlewares) as $middleware) { $handler = new MiddlewareHandler($middleware, $handler); } diff --git a/src/RequestContext.php b/src/RequestContext.php index b23fe76..4cbba58 100644 --- a/src/RequestContext.php +++ b/src/RequestContext.php @@ -16,16 +16,22 @@ public function __construct( public RequestInterface $httpRequest, public string $requestName, public Closure $requestFactory, + public TransportOptions $transportOptions = new TransportOptions(), ) { } public function withHttpRequest(RequestInterface $httpRequest): self { - return new self($httpRequest, $this->requestName, $this->requestFactory); + return new self($httpRequest, $this->requestName, $this->requestFactory, $this->transportOptions); } public function withFreshHttpRequest(): self { - return new self(($this->requestFactory)(), $this->requestName, $this->requestFactory); + return new self(($this->requestFactory)(), $this->requestName, $this->requestFactory, $this->transportOptions); + } + + public function withTransportOptions(TransportOptions $options): self + { + return new self($this->httpRequest, $this->requestName, $this->requestFactory, $options); } } diff --git a/src/Transport/GuzzleTransport.php b/src/Transport/GuzzleTransport.php new file mode 100644 index 0000000..3e6d9dc --- /dev/null +++ b/src/Transport/GuzzleTransport.php @@ -0,0 +1,61 @@ + false]; + + if ($options->timeout !== null) { + $guzzleOptions[RequestOptions::TIMEOUT] = $options->timeout; + } + + if ($options->connectTimeout !== null) { + $guzzleOptions[RequestOptions::CONNECT_TIMEOUT] = $options->connectTimeout; + } + + try { + return $this->client->send($request, $guzzleOptions); + } catch (ConnectException $e) { + if ($this->isTimeout($e)) { + throw new TransportTimeoutException($e->getMessage(), (int) $e->getCode(), $e); + } + + throw new TransportException($e->getMessage(), (int) $e->getCode(), $e); + } catch (GuzzleException $e) { + throw new TransportException($e->getMessage(), (int) $e->getCode(), $e); + } + } + + private function isTimeout(ConnectException $exception): bool + { + $context = $exception->getHandlerContext(); + + return ($context['errno'] ?? null) === self::CURL_OPERATION_TIMEDOUT; + } +} diff --git a/src/Transport/Psr18Transport.php b/src/Transport/Psr18Transport.php index 55f8bc3..f666f2c 100644 --- a/src/Transport/Psr18Transport.php +++ b/src/Transport/Psr18Transport.php @@ -10,7 +10,12 @@ use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\Contract\Transport; use Zlodes\Http\Client\Exception\TransportException; +use Zlodes\Http\Client\TransportOptions; +/** + * PSR-18 has no per-request options, so $options is ignored. + * Configure timeouts on the wrapped PSR-18 client instead. + */ final readonly class Psr18Transport implements Transport { public function __construct( @@ -18,7 +23,7 @@ public function __construct( ) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { try { return $this->client->sendRequest($request); diff --git a/src/TransportHandler.php b/src/TransportHandler.php index 6832af7..d79f463 100644 --- a/src/TransportHandler.php +++ b/src/TransportHandler.php @@ -18,6 +18,6 @@ public function __construct( public function handle(RequestContext $context): ResponseInterface { - return $this->transport->send($context->httpRequest); + return $this->transport->send($context->httpRequest, $context->transportOptions); } } diff --git a/src/TransportOptions.php b/src/TransportOptions.php new file mode 100644 index 0000000..44f4a49 --- /dev/null +++ b/src/TransportOptions.php @@ -0,0 +1,45 @@ +timeout ?? $this->timeout, + connectTimeout: $override->connectTimeout ?? $this->connectTimeout, + ); + } + + private static function assertPositive(int|float|null $value, string $name): void + { + if ($value !== null && $value <= 0) { + throw new InvalidArgumentException(sprintf('%s must be positive, got %s.', $name, $value)); + } + } +} diff --git a/tests/Contract/CollectionRequestTest.php b/tests/Contract/CollectionRequestTest.php index c47c48a..7e73414 100644 --- a/tests/Contract/CollectionRequestTest.php +++ b/tests/Contract/CollectionRequestTest.php @@ -18,13 +18,14 @@ use Zlodes\Http\Client\Contract\Transport; use Zlodes\Http\Client\HttpClient; use Zlodes\Http\Client\Tests\Fixture\FakeDto; +use Zlodes\Http\Client\TransportOptions; final class CollectionRequestTest extends TestCase { public function testHttpClientReturnsCollectionResponseFromCollectionRequest(): void { $transport = new class implements Transport { - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return new GuzzleResponse( 200, diff --git a/tests/Factory/ClientFactoryTest.php b/tests/Factory/ClientFactoryTest.php index 80b0802..08627ce 100644 --- a/tests/Factory/ClientFactoryTest.php +++ b/tests/Factory/ClientFactoryTest.php @@ -23,7 +23,9 @@ use Zlodes\Http\Client\Factory\Option\WithErrorResponseHandler; use Zlodes\Http\Client\Factory\Option\WithMiddleware; use Zlodes\Http\Client\Factory\Option\WithResponseHydrator; +use Zlodes\Http\Client\Factory\Option\WithTimeout; use Zlodes\Http\Client\Factory\Option\WithTransport; +use Zlodes\Http\Client\TransportOptions; use Zlodes\Http\Client\HttpClient; use Zlodes\Http\Client\RequestContext; @@ -112,7 +114,7 @@ public function __construct(private ?RequestInterface &$captured) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { $this->captured = $request; @@ -176,6 +178,36 @@ public function process(RequestContext $context, RequestHandler $next): Response self::assertSame(['A', 'B'], $order); } + public function testWithTimeoutMakeOverridesDefaults(): void + { + $captured = null; + + $transport = new class ($captured) implements Transport { + public function __construct(private ?TransportOptions &$captured) + { + } + + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface + { + $this->captured = $options; + + return new Response(200); + } + }; + + $factory = new ClientFactory(defaults: [ + new WithTransport($transport), + new WithResponseHydrator($this->createHydrator()), + new WithTimeout(timeout: 30, connectTimeout: 5), + ]); + + $factory->make(new WithTimeout(timeout: 5))->send($this->createRequest()); + + self::assertNotNull($captured); + self::assertSame(5, $captured->timeout); + self::assertSame(5, $captured->connectTimeout); + } + public function testCustomClientCreator(): void { $capturedConfig = null; @@ -222,7 +254,7 @@ public function __construct(private readonly ResponseInterface $response) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return $this->response; } diff --git a/tests/HttpClientTest.php b/tests/HttpClientTest.php index 6b89776..c784b14 100644 --- a/tests/HttpClientTest.php +++ b/tests/HttpClientTest.php @@ -19,6 +19,7 @@ use Zlodes\Http\Client\Exception\HttpClientException; use Zlodes\Http\Client\Exception\HttpErrorException; use Zlodes\Http\Client\HttpClient; +use Zlodes\Http\Client\TransportOptions; final class HttpClientTest extends TestCase { @@ -387,7 +388,7 @@ public function __construct(private ?RequestInterface &$captured) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { $this->captured = $request; @@ -418,7 +419,7 @@ public function __construct(private readonly ResponseInterface $response) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return $this->response; } diff --git a/tests/HttpClientTransportOptionsTest.php b/tests/HttpClientTransportOptionsTest.php new file mode 100644 index 0000000..f761933 --- /dev/null +++ b/tests/HttpClientTransportOptionsTest.php @@ -0,0 +1,128 @@ +captured = $options; + + return new Response(200); + } + }; + + $client = new HttpClient( + transport: $transport, + responseHydrator: $this->hydrator(), + middlewares: [new TimeoutMiddleware(timeout: 30, connectTimeout: 5)], + ); + + $client->send(new class implements Request, HasTransportOptions { + public function getName(): string + { + return 'slow.report'; + } + + public function buildRequest(): RequestInterface + { + return new GuzzleRequest('GET', 'https://example.com/report'); + } + + public function getResponseClass(): string + { + return ResponseContract::class; + } + + public function getTransportOptions(): TransportOptions + { + return new TransportOptions(timeout: 120); + } + }); + + self::assertNotNull($captured); + self::assertSame(120, $captured->timeout); + self::assertSame(5, $captured->connectTimeout); + } + + public function testRequestWithoutOptionsKeepsMiddlewareValues(): void + { + $captured = null; + + $transport = new class ($captured) implements Transport { + public function __construct(private ?TransportOptions &$captured) + { + } + + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface + { + $this->captured = $options; + + return new Response(200); + } + }; + + $client = new HttpClient( + transport: $transport, + responseHydrator: $this->hydrator(), + middlewares: [new TimeoutMiddleware(timeout: 30, connectTimeout: 5)], + ); + + $client->send(new class implements Request { + public function getName(): string + { + return 'health'; + } + + public function buildRequest(): RequestInterface + { + return new GuzzleRequest('GET', 'https://example.com/health'); + } + + public function getResponseClass(): string + { + return ResponseContract::class; + } + }); + + self::assertNotNull($captured); + self::assertSame(30, $captured->timeout); + self::assertSame(5, $captured->connectTimeout); + } + + private function hydrator(): ResponseHydrator + { + return new class implements ResponseHydrator { + public function hydrate(ResponseInterface $response, Request $request): ResponseContract + { + return new class implements ResponseContract { + }; + } + }; + } +} diff --git a/tests/Middleware/TimeoutMiddlewareTest.php b/tests/Middleware/TimeoutMiddlewareTest.php new file mode 100644 index 0000000..d30fd03 --- /dev/null +++ b/tests/Middleware/TimeoutMiddlewareTest.php @@ -0,0 +1,105 @@ +captured = $context->transportOptions; + + return new Response(200); + } + }; + + $middleware = new TimeoutMiddleware(timeout: 5, connectTimeout: 2); + $middleware->process($this->context(TransportOptions::none()), $next); + + self::assertNotNull($captured); + self::assertSame(5, $captured->timeout); + self::assertSame(2, $captured->connectTimeout); + } + + public function testNullDoesNotOverwriteSetValue(): void + { + $captured = null; + + $next = new class ($captured) implements RequestHandler { + public function __construct(private ?TransportOptions &$captured) + { + } + + public function handle(RequestContext $context): ResponseInterface + { + $this->captured = $context->transportOptions; + + return new Response(200); + } + }; + + $middleware = new TimeoutMiddleware(timeout: 5); + $middleware->process($this->context(new TransportOptions(timeout: 10, connectTimeout: 3)), $next); + + self::assertNotNull($captured); + self::assertSame(5, $captured->timeout); + self::assertSame(3, $captured->connectTimeout); + } + + public function testKeepsRequestAndName(): void + { + $captured = null; + + $next = new class ($captured) implements RequestHandler { + public function __construct(private ?RequestContext &$captured) + { + } + + public function handle(RequestContext $context): ResponseInterface + { + $this->captured = $context; + + return new Response(200); + } + }; + + $context = $this->context(TransportOptions::none()); + (new TimeoutMiddleware(timeout: 1))->process($context, $next); + + self::assertNotNull($captured); + self::assertSame($context->httpRequest, $captured->httpRequest); + self::assertSame('test', $captured->requestName); + } + + private function context(TransportOptions $options): RequestContext + { + $request = new Request('GET', 'https://example.com'); + + return new RequestContext( + httpRequest: $request, + requestName: 'test', + requestFactory: fn (): RequestInterface => $request, + transportOptions: $options, + ); + } +} diff --git a/tests/MiddlewarePipelineTest.php b/tests/MiddlewarePipelineTest.php index bed093b..a3a68f8 100644 --- a/tests/MiddlewarePipelineTest.php +++ b/tests/MiddlewarePipelineTest.php @@ -14,6 +14,7 @@ use Zlodes\Http\Client\Contract\Transport; use Zlodes\Http\Client\MiddlewarePipeline; use Zlodes\Http\Client\RequestContext; +use Zlodes\Http\Client\TransportOptions; final class MiddlewarePipelineTest extends TestCase { @@ -26,7 +27,7 @@ public function __construct(private readonly ResponseInterface $response) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return $this->response; } @@ -43,7 +44,7 @@ public function send(RequestInterface $request): ResponseInterface public function testSingleMiddlewareWrapsTransport(): void { $transport = new class implements Transport { - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return new Response(200, ['X-Transport' => 'true']); } @@ -70,7 +71,7 @@ public function testMiddlewareExecutionOrder(): void $order = []; $transport = new class implements Transport { - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { return new Response(200); } @@ -123,7 +124,7 @@ public function __construct(private ?string &$capturedMethod) { } - public function send(RequestInterface $request): ResponseInterface + public function send(RequestInterface $request, TransportOptions $options): ResponseInterface { $this->capturedMethod = $request->getMethod(); diff --git a/tests/RequestContextTest.php b/tests/RequestContextTest.php index d8fb3e9..c219316 100644 --- a/tests/RequestContextTest.php +++ b/tests/RequestContextTest.php @@ -7,6 +7,7 @@ use GuzzleHttp\Psr7\Request; use PHPUnit\Framework\TestCase; use Zlodes\Http\Client\RequestContext; +use Zlodes\Http\Client\TransportOptions; final class RequestContextTest extends TestCase { @@ -46,4 +47,38 @@ public function testWithFreshHttpRequestCallsFactory(): void self::assertSame($initial, $context->httpRequest); self::assertSame($fresh, $newContext->httpRequest); } + + public function testCopiesKeepTransportOptions(): void + { + $request = new Request('GET', 'https://example.com/original'); + $options = new TransportOptions(timeout: 10, connectTimeout: 3); + + $context = new RequestContext( + httpRequest: $request, + requestName: 'test', + requestFactory: fn () => new Request('GET', 'https://example.com/fresh'), + transportOptions: $options, + ); + + $withRequest = $context->withHttpRequest(new Request('POST', 'https://example.com/other')); + $withFresh = $context->withFreshHttpRequest(); + $withOptions = $context->withTransportOptions(new TransportOptions(timeout: 1)); + + self::assertSame($options, $withRequest->transportOptions); + self::assertSame($options, $withFresh->transportOptions); + self::assertSame(1, $withOptions->transportOptions->timeout); + self::assertSame('test', $withOptions->requestName); + self::assertSame($request, $withOptions->httpRequest); + } + + public function testTransportOptionsDefaultToNone(): void + { + $context = new RequestContext( + httpRequest: new Request('GET', 'https://example.com'), + requestName: 'test', + requestFactory: fn () => new Request('GET', 'https://example.com'), + ); + + self::assertEquals(TransportOptions::none(), $context->transportOptions); + } } diff --git a/tests/Transport/GuzzleTransportTest.php b/tests/Transport/GuzzleTransportTest.php new file mode 100644 index 0000000..26d39cf --- /dev/null +++ b/tests/Transport/GuzzleTransportTest.php @@ -0,0 +1,204 @@ +captured = $options; + + return $this->response; + } + + public function sendAsync(RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function request(string $method, $uri, array $options = []): ResponseInterface + { + throw new \LogicException('not used'); + } + + public function requestAsync(string $method, $uri, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function getConfig(?string $option = null): mixed + { + throw new \LogicException('not used'); + } + }; + + $response = (new GuzzleTransport($client))->send( + $request, + new TransportOptions(timeout: 5, connectTimeout: 1.5), + ); + + self::assertSame($expected, $response); + self::assertSame([ + 'http_errors' => false, + RequestOptions::TIMEOUT => 5, + RequestOptions::CONNECT_TIMEOUT => 1.5, + ], $captured); + } + + public function testOmitsUnsetOptions(): void + { + $captured = null; + + $client = new class ($captured) implements ClientInterface { + public function __construct(private ?array &$captured) + { + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + $this->captured = $options; + + return new Response(200); + } + + public function sendAsync(RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function request(string $method, $uri, array $options = []): ResponseInterface + { + throw new \LogicException('not used'); + } + + public function requestAsync(string $method, $uri, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function getConfig(?string $option = null): mixed + { + throw new \LogicException('not used'); + } + }; + + (new GuzzleTransport($client))->send(new Request('GET', 'https://example.com'), TransportOptions::none()); + + self::assertSame(['http_errors' => false], $captured); + } + + public function testConnectTimeoutBecomesTransportTimeoutException(): void + { + $request = new Request('GET', 'https://example.com'); + $connectException = new ConnectException( + 'cURL error 28: Connection timed out', + $request, + null, + ['errno' => 28], + ); + + $client = $this->clientThrowing($connectException); + + try { + (new GuzzleTransport($client))->send($request, new TransportOptions(timeout: 5)); + self::fail('Expected TransportTimeoutException'); + } catch (TransportTimeoutException $e) { + self::assertSame($connectException, $e->getPrevious()); + } + } + + public function testConnectionRefusedStaysTransportException(): void + { + $request = new Request('GET', 'https://example.com'); + $connectException = new ConnectException( + 'cURL error 7: Connection refused', + $request, + null, + ['errno' => 7], + ); + + $client = $this->clientThrowing($connectException); + + try { + (new GuzzleTransport($client))->send($request, TransportOptions::none()); + self::fail('Expected TransportException'); + } catch (TransportTimeoutException) { + self::fail('A refused connection must not be reported as a timeout'); + } catch (TransportException $e) { + self::assertSame($connectException, $e->getPrevious()); + } + } + + public function testOtherGuzzleExceptionStaysTransportException(): void + { + $request = new Request('GET', 'https://example.com'); + $requestException = new RequestException('bad request', $request); + + $this->expectException(TransportException::class); + + (new GuzzleTransport($this->clientThrowing($requestException)))->send($request, TransportOptions::none()); + } + + private function clientThrowing(\Throwable $exception): ClientInterface + { + return new class ($exception) implements ClientInterface { + public function __construct(private readonly \Throwable $exception) + { + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + throw $this->exception; + } + + public function sendAsync(RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function request(string $method, $uri, array $options = []): ResponseInterface + { + throw new \LogicException('not used'); + } + + public function requestAsync(string $method, $uri, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \LogicException('not used'); + } + + public function getConfig(?string $option = null): mixed + { + throw new \LogicException('not used'); + } + }; + } +} diff --git a/tests/Transport/Psr18TransportTest.php b/tests/Transport/Psr18TransportTest.php index f8fb0b5..12edb99 100644 --- a/tests/Transport/Psr18TransportTest.php +++ b/tests/Transport/Psr18TransportTest.php @@ -13,6 +13,7 @@ use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\Exception\TransportException; use Zlodes\Http\Client\Transport\Psr18Transport; +use Zlodes\Http\Client\TransportOptions; final class Psr18TransportTest extends TestCase { @@ -33,7 +34,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface }; $transport = new Psr18Transport($client); - $response = $transport->send($request); + $response = $transport->send($request, new TransportOptions(timeout: 5, connectTimeout: 1)); self::assertSame($expectedResponse, $response); } @@ -59,7 +60,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->expectException(TransportException::class); $this->expectExceptionMessage('Connection refused'); - $transport->send(new Request('GET', 'https://example.com')); + $transport->send(new Request('GET', 'https://example.com'), TransportOptions::none()); } public function testTransportExceptionPreservesPrevious(): void @@ -81,7 +82,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface $transport = new Psr18Transport($client); try { - $transport->send(new Request('GET', 'https://example.com')); + $transport->send(new Request('GET', 'https://example.com'), TransportOptions::none()); self::fail('Expected TransportException'); } catch (TransportException $e) { self::assertSame($clientException, $e->getPrevious()); diff --git a/tests/TransportOptionsTest.php b/tests/TransportOptionsTest.php new file mode 100644 index 0000000..6b0bdc4 --- /dev/null +++ b/tests/TransportOptionsTest.php @@ -0,0 +1,94 @@ +timeout); + self::assertNull($options->connectTimeout); + } + + public function testNoneMatchesEmptyConstructor(): void + { + self::assertEquals(new TransportOptions(), TransportOptions::none()); + } + + public function testAcceptsPositiveValues(): void + { + $options = new TransportOptions(timeout: 5, connectTimeout: 1.5); + + self::assertSame(5, $options->timeout); + self::assertSame(1.5, $options->connectTimeout); + } + + #[DataProvider('nonPositiveValues')] + public function testRejectsNonPositiveTimeout(int|float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new TransportOptions(timeout: $value); + } + + #[DataProvider('nonPositiveValues')] + public function testRejectsNonPositiveConnectTimeout(int|float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new TransportOptions(connectTimeout: $value); + } + + /** + * @return iterable + */ + public static function nonPositiveValues(): iterable + { + yield 'zero int' => [0]; + yield 'zero float' => [0.0]; + yield 'negative int' => [-1]; + yield 'negative float' => [-0.5]; + } + + public function testMergeOverrideWinsOnSetValues(): void + { + $base = new TransportOptions(timeout: 10, connectTimeout: 3); + $override = new TransportOptions(timeout: 5, connectTimeout: 1); + + $merged = $base->merge($override); + + self::assertSame(5, $merged->timeout); + self::assertSame(1, $merged->connectTimeout); + } + + public function testMergeKeepsBaseWhenOverrideIsNull(): void + { + $base = new TransportOptions(timeout: 10, connectTimeout: 3); + + $merged = $base->merge(new TransportOptions(timeout: 5)); + + self::assertSame(5, $merged->timeout); + self::assertSame(3, $merged->connectTimeout); + } + + public function testMergeDoesNotMutateEitherSide(): void + { + $base = new TransportOptions(timeout: 10, connectTimeout: 3); + $override = new TransportOptions(connectTimeout: 1); + + $base->merge($override); + + self::assertSame(10, $base->timeout); + self::assertSame(3, $base->connectTimeout); + self::assertNull($override->timeout); + } +}