From 3ed4e82329dcefaa1e5d8e1924365b4b3bfe4caa Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 19 May 2026 21:06:06 +0000 Subject: [PATCH 01/49] fix: prevent duplicate rate limit headers in response --- .../RateLimit/Middlewares/RateLimiter.php | 12 +++++--- tests/Feature/Cache/CustomRateLimiterTest.php | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Cache/RateLimit/Middlewares/RateLimiter.php b/src/Cache/RateLimit/Middlewares/RateLimiter.php index acbf5e16..5a34bc73 100644 --- a/src/Cache/RateLimit/Middlewares/RateLimiter.php +++ b/src/Cache/RateLimit/Middlewares/RateLimiter.php @@ -55,10 +55,14 @@ public function handleRequest(Request $request, RequestHandler $next): Response $remaining = max(0, $perMinuteLimit - $current); $resetTime = time() + $this->rateLimiter->getTtl($clientIp); - $response->addHeader('x-ratelimit-limit', (string) $perMinuteLimit); - $response->addHeader('x-ratelimit-remaining', (string) $remaining); - $response->addHeader('x-ratelimit-reset', (string) $resetTime); - $response->addHeader('x-ratelimit-reset-after', (string) $this->rateLimiter->getTtl($clientIp)); + if ($isCustom || ! $response->hasHeader('x-ratelimit-limit')) { + $response->replaceHeaders([ + 'x-ratelimit-limit' => (string) $perMinuteLimit, + 'x-ratelimit-remaining' => (string) $remaining, + 'x-ratelimit-reset' => (string) $resetTime, + 'x-ratelimit-reset-after' => (string) $this->rateLimiter->getTtl($clientIp), + ]); + } return $response; } diff --git a/tests/Feature/Cache/CustomRateLimiterTest.php b/tests/Feature/Cache/CustomRateLimiterTest.php index 73fbabcb..558d08c1 100644 --- a/tests/Feature/Cache/CustomRateLimiterTest.php +++ b/tests/Feature/Cache/CustomRateLimiterTest.php @@ -75,3 +75,32 @@ $this->get(path: '/custom') ->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS); }); + +it('does not duplicate rate limit headers when global and custom limiters both run', function (): void { + Config::set('app.port', 14337); + Config::set('cache.rate_limit.enabled', true); + Config::set('cache.rate_limit.per_minute', 60); + + Route::get('/custom-with-global', fn (): Response => response()->plain('Ok')) + ->middleware(RateLimiter::perMinute(10)); + + $this->app->run(); + + $responseHeaders = $this->get(path: '/custom-with-global') + ->assertOk() + ->getHeaders(); + + $headers = [ + 'x-ratelimit-limit', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'x-ratelimit-reset-after', + ]; + + foreach ($headers as $header) { + expect($responseHeaders[$header] ?? [])->toHaveCount(1); + } + + expect($responseHeaders['x-ratelimit-limit'])->toBe(['10']); + expect($responseHeaders['x-ratelimit-remaining'])->toBe(['9']); +}); From 14464aeee7d40aba49cae6fe5818577ced1504ff Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 18:16:51 +0000 Subject: [PATCH 02/49] feat: implement Http Client class with CRUD methods and request handling --- src/Http/Client.php | 90 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/Http/Client.php diff --git a/src/Http/Client.php b/src/Http/Client.php new file mode 100644 index 00000000..b5e0ffa6 --- /dev/null +++ b/src/Http/Client.php @@ -0,0 +1,90 @@ +client = HttpClientBuilder::buildDefault(); + $this->headers = []; + } + + public function withHeaders(array $headers): self + { + $this->headers = $headers; + + return $this; + } + + public function get(UriInterface|string $url, array|null $queryParameters = null): Response + { + return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); + } + + public function head(UriInterface|string $url, array|null $queryParameters = null): Response + { + return $this->call(HttpMethod::HEAD, $url, queryParameters: $queryParameters); + } + + public function post(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::POST, $url, $data); + } + + public function put(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::PUT, $url, $data); + } + + public function patch(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::PATCH, $url, $data); + } + + public function delete(UriInterface|string $url, Form|Arrayable|array|string $data = []): Response + { + return $this->call(HttpMethod::DELETE, $url, $data); + } + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $request = new Request($url, $method->value); + $request->setHeaders($this->headers); + + if ($queryParameters !== null) { + $request->setQueryParameters($queryParameters); + } + + if ($data !== null) { + $body = match (true) { + $data instanceof Arrayable => json_encode($data->toArray()), + is_array($data) => json_encode($data), + default => $data, + }; + + $request->setBody($body); + } + + return $this->client->request($request); + } +} From 4955f007601e043efd2f127a18c703a10b4a51ec Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 18:21:10 +0000 Subject: [PATCH 03/49] feat: enhance authorization methods in Http Client with Basic, Digest, and Token support --- src/Http/Client.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index b5e0ffa6..e417dc67 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -27,7 +27,34 @@ public function __construct() public function withHeaders(array $headers): self { - $this->headers = $headers; + $this->headers = [...$this->headers, ...$headers]; + + return $this; + } + + public function withBasicAuth( + string $username, + #[SensitiveParameter] + string $password + ): self { + $this->headers['Authorization'] = 'Basic ' . base64_encode("{$username}:{$password}"); + + return $this; + } + + public function withDigestAuth( + string $username, + #[SensitiveParameter] + string $password + ): self { + $this->headers['Authorization'] = 'Digest ' . base64_encode("{$username}:{$password}"); + + return $this; + } + + public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): self + { + $this->headers['Authorization'] = "{$type} {$token}"; return $this; } From 312a49ee8bb2bb70759ddff490cd00278630f593 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 20:12:35 +0000 Subject: [PATCH 04/49] feat: implement retry mechanism for HTTP requests with RetryRequests interceptor --- src/Http/Client.php | 33 ++++++++- src/Http/Interceptors/RetryRequests.php | 91 +++++++++++++++++++++++++ tests/Unit/Http/RetryRequestsTest.php | 63 +++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 src/Http/Interceptors/RetryRequests.php create mode 100644 tests/Unit/Http/RetryRequestsTest.php diff --git a/src/Http/Client.php b/src/Http/Client.php index e417dc67..621750fd 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -9,19 +9,23 @@ use Amp\Http\Client\Response; use Phenix\Contracts\Arrayable; use Phenix\Http\Constants\HttpMethod; +use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; class Client { protected HttpClient $client; + protected HttpClientBuilder $builder; + protected Request $request; protected array $headers = []; public function __construct() { - $this->client = HttpClientBuilder::buildDefault(); + $this->builder = new HttpClientBuilder(); + $this->client = $this->builder->build(); $this->headers = []; } @@ -59,6 +63,22 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B return $this; } + public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|null $when = null): self + { + if ($times <= 0) { + $this->client = $this->builder->retry(2)->build(); + + return $this; + } + + $this->client = $this->builder + ->retry(0) + ->intercept(new RetryRequests($times, $sleepMilliseconds, $when)) + ->build(); + + return $this; + } + public function get(UriInterface|string $url, array|null $queryParameters = null): Response { return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); @@ -95,6 +115,15 @@ protected function call( Form|Arrayable|array|string|null $data = null, array|null $queryParameters = null ): Response { + return $this->client->request($this->createRequest($method, $url, $data, $queryParameters)); + } + + private function createRequest( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Request { $request = new Request($url, $method->value); $request->setHeaders($this->headers); @@ -112,6 +141,6 @@ protected function call( $request->setBody($body); } - return $this->client->request($request); + return $request; } } diff --git a/src/Http/Interceptors/RetryRequests.php b/src/Http/Interceptors/RetryRequests.php new file mode 100644 index 00000000..ff07f6e8 --- /dev/null +++ b/src/Http/Interceptors/RetryRequests.php @@ -0,0 +1,91 @@ +attempts = max(1, $attempts); + $this->when = $when === null ? null : Closure::fromCallable($when); + } + + public function request( + Request $request, + Cancellation $cancellation, + DelegateHttpClient $httpClient + ): Response { + $exception = null; + + for ($attempt = 1; $attempt <= $this->attempts; $attempt++) { + $clonedRequest = clone $request; + + try { + return $httpClient->request($request, $cancellation); + } catch (HttpException $exception) { + if (! $this->shouldRetry($exception, $request, $attempt)) { + throw $exception; + } + + $this->delayBeforeRetry($attempt, $exception, $request, $cancellation); + + $request = $clonedRequest; + } + } + + throw $exception; + } + + private function shouldRetry(HttpException $exception, Request $request, int $attempt): bool + { + if ($attempt >= $this->attempts) { + return false; + } + + if (! $request->isIdempotent() && ! $request->isUnprocessed()) { + return false; + } + + if ($this->when !== null) { + return (bool) ($this->when)($exception, $request, $attempt); + } + + return true; + } + + private function delayBeforeRetry( + int $attempt, + HttpException $exception, + Request $request, + Cancellation $cancellation + ): void { + $milliseconds = $this->sleepMilliseconds instanceof Closure + ? ($this->sleepMilliseconds)($attempt, $exception, $request) + : $this->sleepMilliseconds; + + if (! is_numeric($milliseconds) || $milliseconds <= 0) { + return; + } + + delay(((float) $milliseconds) / 1000, cancellation: $cancellation); + } +} diff --git a/tests/Unit/Http/RetryRequestsTest.php b/tests/Unit/Http/RetryRequestsTest.php new file mode 100644 index 00000000..7c124479 --- /dev/null +++ b/tests/Unit/Http/RetryRequestsTest.php @@ -0,0 +1,63 @@ +calls++; + + if ($this->calls < 3) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $response = (new RetryRequests(3))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(3); +}); + +it('stops retrying when the retry condition rejects the exception', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + throw new HttpException('Connection failed.'); + } + }; + + expect(fn () => (new RetryRequests(3, when: fn () => false))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); From aa4e5134cf6f61bd98a3f1b4c367c5ba57e2cc1c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 20:23:04 +0000 Subject: [PATCH 05/49] refactor: move to dedicate namespace --- src/Http/{Client.php => Client/HttpClient.php} | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) rename src/Http/{Client.php => Client/HttpClient.php} (95%) diff --git a/src/Http/Client.php b/src/Http/Client/HttpClient.php similarity index 95% rename from src/Http/Client.php rename to src/Http/Client/HttpClient.php index 621750fd..c619518c 100644 --- a/src/Http/Client.php +++ b/src/Http/Client/HttpClient.php @@ -2,19 +2,25 @@ declare(strict_types=1); +namespace Phenix\Http\Client; + use Amp\Http\Client\Form; -use Amp\Http\Client\HttpClient; +use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; use Amp\Http\Client\Response; +use Closure; use Phenix\Contracts\Arrayable; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; +use SensitiveParameter; + +use function is_array; -class Client +class HttpClient { - protected HttpClient $client; + protected AmpHttpClient $client; protected HttpClientBuilder $builder; From a256f8e91861ddd0e501c7705bb62cf0887f007c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 21:06:19 +0000 Subject: [PATCH 06/49] feat: implement Response class to wrap Amp responses and provide data helpers --- src/Http/Client/HttpClient.php | 3 +- src/Http/Client/Response.php | 184 ++++++++++++++++++++++++ tests/Unit/Http/Client/ResponseTest.php | 56 ++++++++ 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/Http/Client/Response.php create mode 100644 tests/Unit/Http/Client/ResponseTest.php diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index c619518c..25547cc3 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -8,7 +8,6 @@ use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; -use Amp\Http\Client\Response; use Closure; use Phenix\Contracts\Arrayable; use Phenix\Http\Constants\HttpMethod; @@ -121,7 +120,7 @@ protected function call( Form|Arrayable|array|string|null $data = null, array|null $queryParameters = null ): Response { - return $this->client->request($this->createRequest($method, $url, $data, $queryParameters)); + return new Response($this->client->request($this->createRequest($method, $url, $data, $queryParameters))); } private function createRequest( diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php new file mode 100644 index 00000000..b42c809e --- /dev/null +++ b/src/Http/Client/Response.php @@ -0,0 +1,184 @@ +body = $this->response->getBody()->buffer(); + } + + public function getClientResponse(): ClientResponse + { + return $this->response; + } + + public function body(): string + { + return $this->body; + } + + public function json(string|null $key = null, Closure|array|string|null $default = null, int $flags = 0): mixed + { + $data = json_decode($this->body, true, flags: $flags); + + if ($data === null) { + return value($default); + } + + if ($key === null) { + return $data; + } + + if (! is_array($data)) { + return value($default); + } + + return Arr::get($data, $key, $default); + } + + public function object(): object + { + return (object) (json_decode($this->body) ?? []); + } + + public function collect(string|null $key = null): Collection + { + $data = $this->json($key, []); + + return Collection::fromArray(is_array($data) ? $data : [$data]); + } + + public function status(): int + { + return $this->response->getStatus(); + } + + public function successful(): bool + { + return $this->response->isSuccessful(); + } + + public function redirect(): bool + { + return $this->response->isRedirect(); + } + + public function failed(): bool + { + return $this->clientError() || $this->response->isServerError(); + } + + public function clientError(): bool + { + return $this->response->isClientError(); + } + + public function header(string $header): string|null + { + return $this->response->getHeader($header); + } + + public function headers(): array + { + return $this->response->getHeaders(); + } + + public function ok(): bool + { + return $this->hasStatus(HttpStatus::OK); + } + + public function created(): bool + { + return $this->hasStatus(HttpStatus::CREATED); + } + + public function accepted(): bool + { + return $this->hasStatus(HttpStatus::ACCEPTED); + } + + public function noContent(): bool + { + return $this->hasStatus(HttpStatus::NO_CONTENT) && $this->body === ''; + } + + public function movedPermanently(): bool + { + return $this->hasStatus(HttpStatus::MOVED_PERMANENTLY); + } + + public function found(): bool + { + return $this->hasStatus(HttpStatus::FOUND); + } + + public function badRequest(): bool + { + return $this->hasStatus(HttpStatus::BAD_REQUEST); + } + + public function unauthorized(): bool + { + return $this->hasStatus(HttpStatus::UNAUTHORIZED); + } + + public function paymentRequired(): bool + { + return $this->hasStatus(HttpStatus::PAYMENT_REQUIRED); + } + + public function forbidden(): bool + { + return $this->hasStatus(HttpStatus::FORBIDDEN); + } + + public function notFound(): bool + { + return $this->hasStatus(HttpStatus::NOT_FOUND); + } + + public function requestTimeout(): bool + { + return $this->hasStatus(HttpStatus::REQUEST_TIMEOUT); + } + + public function conflict(): bool + { + return $this->hasStatus(HttpStatus::CONFLICT); + } + + public function unprocessableEntity(): bool + { + return $this->hasStatus(HttpStatus::UNPROCESSABLE_ENTITY); + } + + public function tooManyRequests(): bool + { + return $this->hasStatus(HttpStatus::TOO_MANY_REQUESTS); + } + + public function serverError(): bool + { + return $this->hasStatus(HttpStatus::INTERNAL_SERVER_ERROR); + } + + private function hasStatus(HttpStatus $status): bool + { + return $this->status() === $status->value; + } +} diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php new file mode 100644 index 00000000..a891df32 --- /dev/null +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -0,0 +1,56 @@ + 'application/json'], + '{"user":{"name":"Ada"},"tags":["php","amp"]}', + new Request('https://phenix.test') + ); + + $response = new Response($ampResponse); + + expect($response->getClientResponse())->toBe($ampResponse) + ->and($response->body())->toBe('{"user":{"name":"Ada"},"tags":["php","amp"]}') + ->and($response->json('user.name'))->toBe('Ada') + ->and($response->json('missing', 'fallback'))->toBe('fallback') + ->and($response->object()->user->name)->toBe('Ada') + ->and($response->collect('tags'))->toBeInstanceOf(Collection::class) + ->and($response->collect('tags')->toArray())->toBe(['php', 'amp']) + ->and($response->status())->toBe(201) + ->and($response->created())->toBeTrue() + ->and($response->successful())->toBeTrue() + ->and($response->failed())->toBeFalse() + ->and($response->header('content-type'))->toBe('application/json') + ->and($response->headers())->toHaveKey('content-type'); +}); + +it('reports common status helpers', function (): void { + $request = new Request('https://phenix.test'); + + expect((new Response(new AmpResponse('1.1', 200, null, [], '', $request)))->ok())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 202, null, [], '', $request)))->accepted())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 204, null, [], '', $request)))->noContent())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 301, null, [], '', $request)))->movedPermanently())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 302, null, [], '', $request)))->found())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 400, null, [], '', $request)))->badRequest())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 401, null, [], '', $request)))->unauthorized())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 402, null, [], '', $request)))->paymentRequired())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 403, null, [], '', $request)))->forbidden())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 404, null, [], '', $request)))->notFound())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 408, null, [], '', $request)))->requestTimeout())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 409, null, [], '', $request)))->conflict())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 422, null, [], '', $request)))->unprocessableEntity())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 429, null, [], '', $request)))->tooManyRequests())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->serverError())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->failed())->toBeTrue(); +}); From 296c18dd949739ddf06759e88ca3c9dfedbffb85 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 21:31:42 +0000 Subject: [PATCH 07/49] feat: add serverError method and enhance onError handling in Response class --- src/Http/Client/Response.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index b42c809e..6850d339 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -87,6 +87,11 @@ public function clientError(): bool return $this->response->isClientError(); } + public function serverError(): bool + { + return $this->response->isServerError(); + } + public function header(string $header): string|null { return $this->response->getHeader($header); @@ -172,9 +177,14 @@ public function tooManyRequests(): bool return $this->hasStatus(HttpStatus::TOO_MANY_REQUESTS); } - public function serverError(): bool + public function onError(Closure $closure): self { - return $this->hasStatus(HttpStatus::INTERNAL_SERVER_ERROR); + if ($this->failed()) { + $closure($this); + } + + return $this; + } } private function hasStatus(HttpStatus $status): bool From 8ed50bae1ffcd5b2ac19686614bad23cfea23dda Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 21:35:03 +0000 Subject: [PATCH 08/49] feat: add RequestException class and implement throw methods in Response class for error handling --- .../Client/Exceptions/RequestException.php | 26 +++++++++ src/Http/Client/Response.php | 20 +++++++ tests/Unit/Http/Client/ResponseTest.php | 53 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/Http/Client/Exceptions/RequestException.php diff --git a/src/Http/Client/Exceptions/RequestException.php b/src/Http/Client/Exceptions/RequestException.php new file mode 100644 index 00000000..acd535d1 --- /dev/null +++ b/src/Http/Client/Exceptions/RequestException.php @@ -0,0 +1,26 @@ +status() + ), $response->status()); + } + + public function response(): Response + { + return $this->response; + } +} diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index 6850d339..99bf4fd6 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -7,6 +7,7 @@ use Amp\Http\Client\Response as ClientResponse; use Closure; use Phenix\Data\Collection; +use Phenix\Http\Client\Exceptions\RequestException; use Phenix\Http\Constants\HttpStatus; use Phenix\Util\Arr; @@ -185,6 +186,25 @@ public function onError(Closure $closure): self return $this; } + + public function throw(): self + { + if ($this->failed()) { + throw new RequestException($this); + } + + return $this; + } + + public function throwIf(Closure|bool $condition): self + { + $condition = $condition instanceof Closure ? $condition($this) : $condition; + + if ($condition) { + return $this->throw(); + } + + return $this; } private function hasStatus(HttpStatus $status): bool diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php index a891df32..00216b21 100644 --- a/tests/Unit/Http/Client/ResponseTest.php +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -5,6 +5,7 @@ use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; use Phenix\Data\Collection; +use Phenix\Http\Client\Exceptions\RequestException; use Phenix\Http\Client\Response; it('wraps amp responses and exposes response data helpers', function (): void { @@ -54,3 +55,55 @@ ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->serverError())->toBeTrue() ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->failed())->toBeTrue(); }); + +it('throws request exceptions for failed responses', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 404, + null, + [], + 'Missing', + new Request('https://phenix.test') + )); + + try { + $response->throw(); + } catch (RequestException $exception) { + expect($exception->response())->toBe($response) + ->and($exception->getCode())->toBe(404) + ->and($exception->getMessage())->toBe('HTTP request returned status code 404.'); + + return; + } + + expect(false)->toBeTrue(); +}); + +it('does not throw for successful responses', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'Ok', + new Request('https://phenix.test') + )); + + expect($response->throw())->toBe($response) + ->and($response->throwIf(true))->toBe($response); +}); + +it('throws conditionally with throw if', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 500, + null, + [], + 'Server error', + new Request('https://phenix.test') + )); + + expect($response->throwIf(false))->toBe($response) + ->and(fn () => $response->throwIf(fn (Response $response): bool => $response->serverError())) + ->toThrow(RequestException::class); +}); From 040d778e95c633a87b423eb1ae0c2fa887d4050b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 22:46:00 +0000 Subject: [PATCH 09/49] feat: implement request pooling mechanism in HttpClient with concurrency control --- src/Http/Client/HttpClient.php | 40 ++++++++ src/Http/Client/Pool.php | 43 +++++++++ tests/Unit/Http/Client/HttpClientPoolTest.php | 92 +++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/Http/Client/Pool.php create mode 100644 tests/Unit/Http/Client/HttpClientPoolTest.php diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 25547cc3..0ec78e68 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -8,6 +8,8 @@ use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; +use Amp\Sync\LocalSemaphore; +use Amp\Sync\Semaphore; use Closure; use Phenix\Contracts\Arrayable; use Phenix\Http\Constants\HttpMethod; @@ -15,6 +17,8 @@ use Psr\Http\Message\UriInterface; use SensitiveParameter; +use function Amp\async; +use function Amp\Future\await; use function is_array; class HttpClient @@ -114,6 +118,24 @@ public function delete(UriInterface|string $url, Form|Arrayable|array|string $da return $this->call(HttpMethod::DELETE, $url, $data); } + /** + * @param Closure(Pool): array $closure + * @param int|null $concurrency + * @return array + */ + public function pool(Closure $closure, int|null $concurrency = 0): array + { + $requests = $closure(new Pool()); + $semaphore = $concurrency !== null && $concurrency > 0 ? new LocalSemaphore($concurrency) : null; + $futures = []; + + foreach ($requests as $key => $request) { + $futures[$key] = async(fn (): Response => $this->executePoolRequest($request, $semaphore)); + } + + return await($futures); + } + protected function call( HttpMethod $method, UriInterface|string $url, @@ -148,4 +170,22 @@ private function createRequest( return $request; } + + /** + * @param Closure(HttpClient): Response $request + */ + private function executePoolRequest(Closure $request, Semaphore|null $semaphore): Response + { + if ($semaphore === null) { + return $request($this); + } + + $lock = $semaphore->acquire(); + + try { + return $request($this); + } finally { + $lock->release(); + } + } } diff --git a/src/Http/Client/Pool.php b/src/Http/Client/Pool.php new file mode 100644 index 00000000..a8da3e66 --- /dev/null +++ b/src/Http/Client/Pool.php @@ -0,0 +1,43 @@ + $client->get($url, $queryParameters); + } + + public function head(UriInterface|string $url, array|null $queryParameters = null): Closure + { + return fn (HttpClient $client): Response => $client->head($url, $queryParameters); + } + + public function post(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->post($url, $data); + } + + public function put(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->put($url, $data); + } + + public function patch(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->patch($url, $data); + } + + public function delete(UriInterface|string $url, Form|Arrayable|array|string $data = []): Closure + { + return fn (HttpClient $client): Response => $client->delete($url, $data); + } +} diff --git a/tests/Unit/Http/Client/HttpClientPoolTest.php b/tests/Unit/Http/Client/HttpClientPoolTest.php new file mode 100644 index 00000000..b5451308 --- /dev/null +++ b/tests/Unit/Http/Client/HttpClientPoolTest.php @@ -0,0 +1,92 @@ +pool(fn (Pool $pool): array => [ + 'google' => $pool->get('https://google.com'), + 'github' => $pool->get('https://github.com'), + ]); + + expect(array_keys($responses))->toBe(['google', 'github']) + ->and($responses['google'])->toBeInstanceOf(Response::class) + ->and($responses['google']->body())->toBe('https://google.com') + ->and($responses['github']->body())->toBe('https://github.com'); +}); + +it('limits pooled request concurrency', function (): void { + $active = 0; + $maxActive = 0; + + $client = new class ($active, $maxActive) extends HttpClient { + public function __construct(private int &$active, private int &$maxActive) + { + parent::__construct(); + } + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $this->active++; + $this->maxActive = max($this->maxActive, $this->active); + + delay(0.01); + + $this->active--; + + return new Response(new AmpResponse( + '1.1', + 200, + null, + [], + (string) $url, + new Request((string) $url) + )); + } + }; + + $responses = $client->pool(fn (Pool $pool): array => [ + $pool->get('https://phenix.test/1'), + $pool->get('https://phenix.test/2'), + $pool->get('https://phenix.test/3'), + $pool->get('https://phenix.test/4'), + ], concurrency: 2); + + expect($responses)->toHaveCount(4) + ->and($maxActive)->toBeLessThanOrEqual(2); +}); From 1e89a484c2e67abc55e2ddb9f9e1ceaeccb699b6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 20 May 2026 23:20:50 +0000 Subject: [PATCH 10/49] feat: add StreamResponse class and implement streaming functionality in HttpClient --- src/Http/Client/HttpClient.php | 47 +++++++- src/Http/Client/StreamResponse.php | 112 +++++++++++++++++ tests/Unit/Http/Client/StreamResponseTest.php | 114 ++++++++++++++++++ 3 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/Http/Client/StreamResponse.php create mode 100644 tests/Unit/Http/Client/StreamResponseTest.php diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 0ec78e68..54b112ee 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -118,6 +118,28 @@ public function delete(UriInterface|string $url, Form|Arrayable|array|string $da return $this->call(HttpMethod::DELETE, $url, $data); } + public function stream( + UriInterface|string $url, + Closure|null $callback = null, + array|null $queryParameters = null, + int|null $bodySizeLimit = null, + float|null $transferTimeout = null + ): mixed { + $response = $this->streamCall( + method: HttpMethod::GET, + url: $url, + queryParameters: $queryParameters, + bodySizeLimit: $bodySizeLimit, + transferTimeout: $transferTimeout + ); + + if ($callback !== null) { + return $callback($response); + } + + return $response; + } + /** * @param Closure(Pool): array $closure * @param int|null $concurrency @@ -145,7 +167,28 @@ protected function call( return new Response($this->client->request($this->createRequest($method, $url, $data, $queryParameters))); } - private function createRequest( + protected function streamCall( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null, + int|null $bodySizeLimit = null, + float|null $transferTimeout = null + ): StreamResponse { + $request = $this->createRequest($method, $url, $data, $queryParameters); + + if ($bodySizeLimit !== null) { + $request->setBodySizeLimit($bodySizeLimit); + } + + if ($transferTimeout !== null) { + $request->setTransferTimeout($transferTimeout); + } + + return new StreamResponse($this->client->request($request)); + } + + protected function createRequest( HttpMethod $method, UriInterface|string $url, Form|Arrayable|array|string|null $data = null, @@ -174,7 +217,7 @@ private function createRequest( /** * @param Closure(HttpClient): Response $request */ - private function executePoolRequest(Closure $request, Semaphore|null $semaphore): Response + protected function executePoolRequest(Closure $request, Semaphore|null $semaphore): Response { if ($semaphore === null) { return $request($this); diff --git a/src/Http/Client/StreamResponse.php b/src/Http/Client/StreamResponse.php new file mode 100644 index 00000000..9db0ddb4 --- /dev/null +++ b/src/Http/Client/StreamResponse.php @@ -0,0 +1,112 @@ +response; + } + + public function read(): string|null + { + return $this->response->getBody()->read(); + } + + public function each(Closure $closure): self + { + $bytes = 0; + + while (($chunk = $this->read()) !== null) { + $bytes += strlen($chunk); + $closure($chunk, $bytes, $this); + } + + return $this; + } + + public function save(string $path, Closure|null $progress = null): int + { + $file = File::openFile($path, 'w'); + $bytes = 0; + + try { + while (($chunk = $this->read()) !== null) { + $file->write($chunk); + $bytes += strlen($chunk); + + if ($progress !== null) { + $progress($bytes, $chunk, $this); + } + } + } finally { + $file->close(); + } + + return $bytes; + } + + public function status(): int + { + return $this->response->getStatus(); + } + + public function successful(): bool + { + return $this->response->isSuccessful(); + } + + public function redirect(): bool + { + return $this->response->isRedirect(); + } + + public function failed(): bool + { + return $this->response->isClientError() || $this->response->isServerError(); + } + + public function clientError(): bool + { + return $this->response->isClientError(); + } + + public function serverError(): bool + { + return $this->response->isServerError(); + } + + public function header(string $header): string|null + { + return $this->response->getHeader($header); + } + + public function headers(): array + { + return $this->response->getHeaders(); + } + + public function ok(): bool + { + return $this->hasStatus(HttpStatus::OK); + } + + private function hasStatus(HttpStatus $status): bool + { + return $this->status() === $status->value; + } +} diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php new file mode 100644 index 00000000..542b5937 --- /dev/null +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -0,0 +1,114 @@ + 'application/octet-stream'], + 'phenix-stream', + new Request('https://phenix.test/download') + )); + + expect($response->status())->toBe(200) + ->and($response->ok())->toBeTrue() + ->and($response->header('content-type'))->toBe('application/octet-stream') + ->and($response->read())->toBe('phenix-stream') + ->and($response->read())->toBeNull(); +}); + +it('iterates streamed chunks and reports bytes read', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + 'abc', + new Request('https://phenix.test/download') + )); + + $chunks = []; + + $result = $response->each(function (string $chunk, int $bytes) use (&$chunks): void { + $chunks[] = [$chunk, $bytes]; + }); + + expect($result)->toBe($response) + ->and($chunks)->toBe([['abc', 3]]); +}); + +it('saves streamed responses to disk', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + 'download-body', + new Request('https://phenix.test/download') + )); + + $path = tempnam(sys_get_temp_dir(), 'phenix-stream-'); + $progress = []; + + $bytes = $response->save($path, function (int $bytes) use (&$progress): void { + $progress[] = $bytes; + }); + + expect($bytes)->toBe(13) + ->and(File::get($path))->toBe('download-body') + ->and($progress)->toBe([13]); + + unlink($path); +}); + +it('streams requests through the http client callback', function (): void { + $client = new class () extends HttpClient { + public int|null $bodySizeLimit = null; + + public float|null $transferTimeout = null; + + protected function streamCall( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null, + int|null $bodySizeLimit = null, + float|null $transferTimeout = null + ): StreamResponse { + $this->bodySizeLimit = $bodySizeLimit; + $this->transferTimeout = $transferTimeout; + + return new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + (string) $url, + new Request((string) $url) + )); + } + }; + + $body = $client->stream( + 'https://phenix.test/download', + fn (StreamResponse $response): string|null => $response->read(), + bodySizeLimit: 128 * 1024 * 1024, + transferTimeout: 120 + ); + + expect($body)->toBe('https://phenix.test/download') + ->and($client->bodySizeLimit)->toBe(128 * 1024 * 1024) + ->and($client->transferTimeout)->toBe(120.0); +}); From 0166644c68fe763e94d949829dc6d17e3756c6c4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 14:41:13 +0000 Subject: [PATCH 11/49] feat: enhance StreamResponse with read locking and temporary file handling --- src/Http/Client/StreamResponse.php | 72 ++++++++++++++++--- tests/Unit/Http/Client/StreamResponseTest.php | 63 ++++++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/src/Http/Client/StreamResponse.php b/src/Http/Client/StreamResponse.php index 9db0ddb4..3a08b571 100644 --- a/src/Http/Client/StreamResponse.php +++ b/src/Http/Client/StreamResponse.php @@ -4,17 +4,25 @@ namespace Phenix\Http\Client; +use Amp\ByteStream\Payload; use Amp\Http\Client\Response as ClientResponse; use Closure; +use LogicException; use Phenix\Facades\File; use Phenix\Http\Constants\HttpStatus; +use Throwable; use function strlen; class StreamResponse { + private Payload $body; + + private bool $readLocked = false; + public function __construct(private readonly ClientResponse $response) { + $this->body = $response->getBody(); } public function getClientResponse(): ClientResponse @@ -24,16 +32,27 @@ public function getClientResponse(): ClientResponse public function read(): string|null { - return $this->response->getBody()->read(); + if ($this->readLocked) { + throw new LogicException('The response stream cannot be read from inside a streaming callback.'); + } + + return $this->body->read(); } public function each(Closure $closure): self { - $bytes = 0; + $totalBytesRead = 0; while (($chunk = $this->read()) !== null) { - $bytes += strlen($chunk); - $closure($chunk, $bytes, $this); + $totalBytesRead += strlen($chunk); + + $this->readLocked = true; + + try { + $closure($chunk, $totalBytesRead, $this); + } finally { + $this->readLocked = false; + } } return $this; @@ -41,23 +60,45 @@ public function each(Closure $closure): self public function save(string $path, Closure|null $progress = null): int { - $file = File::openFile($path, 'w'); - $bytes = 0; + $completed = false; + $totalBytesWritten = 0; + $temporaryPath = $this->temporaryPath($path); + $file = File::openFile($temporaryPath, 'w'); try { while (($chunk = $this->read()) !== null) { $file->write($chunk); - $bytes += strlen($chunk); + $totalBytesWritten += strlen($chunk); if ($progress !== null) { - $progress($bytes, $chunk, $this); + $this->readLocked = true; + + try { + $progress($totalBytesWritten, $chunk, $this); + } finally { + $this->readLocked = false; + } } } + + $completed = true; } finally { $file->close(); + + if (! $completed) { + $this->discardTemporaryFile($temporaryPath); + } } - return $bytes; + try { + File::move($temporaryPath, $path); + } catch (Throwable $exception) { + $this->discardTemporaryFile($temporaryPath); + + throw $exception; + } + + return $totalBytesWritten; } public function status(): int @@ -109,4 +150,17 @@ private function hasStatus(HttpStatus $status): bool { return $this->status() === $status->value; } + + private function temporaryPath(string $path): string + { + return dirname($path) . DIRECTORY_SEPARATOR . '.phenix-stream-' . bin2hex(random_bytes(8)) . '.tmp'; + } + + private function discardTemporaryFile(string $temporaryPath): void + { + try { + File::deleteFile($temporaryPath); + } catch (Throwable) { + } + } } diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index 542b5937..dbf3dba9 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Amp\ByteStream\ReadableIterableStream; use Amp\Http\Client\Form; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; @@ -49,6 +50,42 @@ ->and($chunks)->toBe([['abc', 3]]); }); +it('iterates streamed chunks and reports cumulative bytes read', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['abc', 'de']), + new Request('https://phenix.test/download') + )); + + $chunks = []; + + $response->each(function (string $chunk, int $totalBytesRead) use (&$chunks): void { + $chunks[] = [$chunk, $totalBytesRead]; + }); + + expect($chunks)->toBe([['abc', 3], ['de', 5]]); +}); + +it('prevents nested reads while iterating streamed chunks', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['abc', 'de']), + new Request('https://phenix.test/download') + )); + + expect(fn () => $response->each( + fn (string $chunk, int $bytes, StreamResponse $stream): string|null => $stream->read() + ))->toThrow(LogicException::class); + + expect($response->read())->toBe('de'); +}); + it('saves streamed responses to disk', function (): void { $response = new StreamResponse(new AmpResponse( '1.1', @@ -73,6 +110,32 @@ unlink($path); }); +it('removes partial files when saving a streamed response fails', function (): void { + $response = new StreamResponse(new AmpResponse( + '1.1', + 200, + null, + [], + new ReadableIterableStream(['partial', 'body']), + new Request('https://phenix.test/download') + )); + + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'phenix-stream-' . bin2hex(random_bytes(8)); + mkdir($directory); + + $path = $directory . DIRECTORY_SEPARATOR . 'download.txt'; + + expect(fn () => $response->save( + $path, + fn (): never => throw new RuntimeException('Progress failed.') + ))->toThrow(RuntimeException::class, 'Progress failed.'); + + expect(file_exists($path))->toBeFalse() + ->and(glob($directory . DIRECTORY_SEPARATOR . '.phenix-stream-*.tmp'))->toBe([]); + + rmdir($directory); +}); + it('streams requests through the http client callback', function (): void { $client = new class () extends HttpClient { public int|null $bodySizeLimit = null; From 8aa531f45197f2cc812d9ee076a8a3409056a017 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 14:41:37 +0000 Subject: [PATCH 12/49] feat: add move method to File facade and implementation in File class --- src/Facades/File.php | 1 + src/Filesystem/Contracts/File.php | 2 ++ src/Filesystem/File.php | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/src/Facades/File.php b/src/Facades/File.php index 4d486c14..f66aa776 100644 --- a/src/Facades/File.php +++ b/src/Facades/File.php @@ -14,6 +14,7 @@ * @method static bool isDirectory(string $path) * @method static bool isFile(string $path) * @method static void createDirectory(string $path, int $mode = 0755) + * @method static void move(string $from, string $to) * @method static \Amp\File\File openFile(string $path, string $mode = 'w') * @method static int getCreationTime(string $path) * @method static int getModificationTime(string $path) diff --git a/src/Filesystem/Contracts/File.php b/src/Filesystem/Contracts/File.php index 412beb03..ff52047c 100644 --- a/src/Filesystem/Contracts/File.php +++ b/src/Filesystem/Contracts/File.php @@ -17,4 +17,6 @@ public function isDirectory(string $path): bool; public function isFile(string $path): bool; public function createDirectory(string $path, int $mode = 0777): void; + + public function move(string $from, string $to): void; } diff --git a/src/Filesystem/File.php b/src/Filesystem/File.php index 4108e5a4..c271d79f 100644 --- a/src/Filesystem/File.php +++ b/src/Filesystem/File.php @@ -51,6 +51,11 @@ public function createDirectory(string $path, int $mode = 0755): void $this->driver->createDirectory($path, $mode); } + public function move(string $from, string $to): void + { + $this->driver->move($from, $to); + } + public function openFile(string $path, string $mode = 'w'): FileHandler { return $this->driver->openFile($path, $mode); From ce6630a0318b5f79b8646df30a87fca201e5ff09 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 15:09:43 +0000 Subject: [PATCH 13/49] feat: add test for moving files in File class --- tests/Unit/Filesystem/FileTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/Unit/Filesystem/FileTest.php b/tests/Unit/Filesystem/FileTest.php index 1570f04c..b3f6e28a 100644 --- a/tests/Unit/Filesystem/FileTest.php +++ b/tests/Unit/Filesystem/FileTest.php @@ -32,6 +32,25 @@ expect(file_get_contents($path))->toBe('php'); }); +it('moves files successfully', function () { + $from = sys_get_temp_dir() . '/file.txt'; + $to = sys_get_temp_dir() . '/moved-file.txt'; + + if (file_exists($to)) { + unlink($to); + } + + file_put_contents($from, 'php'); + + $file = new File(); + $file->move($from, $to); + + expect(file_exists($from))->toBeFalse() + ->and(file_get_contents($to))->toBe('php'); + + unlink($to); +}); + it('checks if file exists', function () { $path = sys_get_temp_dir() . '/file.txt'; From f740c89b70e1a35b436e9b14463dfe1c2efc71b0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 15:13:16 +0000 Subject: [PATCH 14/49] feat: add Http facade for simplified HTTP client interactions --- src/App.php | 3 +++ src/Facades/Http.php | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/Facades/Http.php diff --git a/src/App.php b/src/App.php index 0541b5b5..170b26c7 100644 --- a/src/App.php +++ b/src/App.php @@ -33,6 +33,7 @@ use Phenix\Exceptions\RuntimeError; use Phenix\Facades\Config; use Phenix\Facades\Route; +use Phenix\Http\Client\HttpClient; use Phenix\Http\Constants\Protocol; use Phenix\Http\ErrorHandler as AppErrorHandler; use Phenix\Http\ExceptionHandler as AppExceptionHandler; @@ -91,6 +92,8 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); + self::$container->add(HttpClient::class)->setShared(true); + self::$container->add(Phenix::class)->addMethodCall('registerCommands'); /** @var array $providers */ diff --git a/src/Facades/Http.php b/src/Facades/Http.php new file mode 100644 index 00000000..4b6e78f6 --- /dev/null +++ b/src/Facades/Http.php @@ -0,0 +1,47 @@ +shouldAllowMockingProtectedMethods()->makePartial(); + + App::fake(self::getKeyName(), $mock); + + return $mock->shouldReceive($method); + } +} From e57fafb0aa7273280eaf5a61dd407414954df82f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 15:13:44 +0000 Subject: [PATCH 15/49] feat: implement fake response functionality in HttpClient and add corresponding tests --- src/Http/Client/HttpClient.php | 77 +++++++++++++++++++++- tests/Unit/Http/Client/HttpClientTest.php | 80 +++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Http/Client/HttpClientTest.php diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 54b112ee..baa4dc12 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -8,6 +8,7 @@ use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; +use Amp\Http\Client\Response as AmpResponse; use Amp\Sync\LocalSemaphore; use Amp\Sync\Semaphore; use Closure; @@ -20,6 +21,7 @@ use function Amp\async; use function Amp\Future\await; use function is_array; +use function is_string; class HttpClient { @@ -31,6 +33,13 @@ class HttpClient protected array $headers = []; + protected Closure|null $fakeResponse = null; + + /** + * @var array + */ + protected array $fakeResponses = []; + public function __construct() { $this->builder = new HttpClientBuilder(); @@ -88,6 +97,23 @@ public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|n return $this; } + public function fake(Closure|null $response = null): self + { + $this->fakeResponse = $response ?? fn (): null => null; + + return $this; + } + + public function fakeWhen(Closure $condition, Closure $response): self + { + $this->fakeResponses[] = [ + 'condition' => $condition, + 'response' => $response, + ]; + + return $this; + } + public function get(UriInterface|string $url, array|null $queryParameters = null): Response { return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); @@ -164,7 +190,13 @@ protected function call( Form|Arrayable|array|string|null $data = null, array|null $queryParameters = null ): Response { - return new Response($this->client->request($this->createRequest($method, $url, $data, $queryParameters))); + $request = $this->createRequest($method, $url, $data, $queryParameters); + + if ($fake = $this->getFakeResponse($request)) { + return $fake; + } + + return new Response($this->client->request($request)); } protected function streamCall( @@ -214,6 +246,49 @@ protected function createRequest( return $request; } + private function getFakeResponse(Request $request): Response|null + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, $this)) { + return $this->normalizeFakeResponse(($fake['response'])($request, $this), $request); + } + } + + if ($this->fakeResponse !== null) { + return $this->normalizeFakeResponse(($this->fakeResponse)($request, $this), $request); + } + + return null; + } + + private function normalizeFakeResponse(mixed $response, Request $request): Response + { + if ($response instanceof Response) { + return $response; + } + + if ($response instanceof AmpResponse) { + return new Response($response); + } + + $headers = []; + $body = $response; + + if (is_array($response)) { + $headers['Content-Type'] = 'application/json'; + $body = json_encode($response); + } + + return new Response(new AmpResponse( + '1.1', + 200, + null, + $headers, + is_string($body) ? $body : '', + $request + )); + } + /** * @param Closure(HttpClient): Response $request */ diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php new file mode 100644 index 00000000..b7db34ae --- /dev/null +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -0,0 +1,80 @@ +toBeInstanceOf(Response::class) + ->and($response->ok())->toBeTrue() + ->and($response->body())->toBe(''); +}); + +it('fakes all http client requests using a closure response', function (): void { + Http::fake(fn (Request $request): array => [ + 'uri' => (string) $request->getUri(), + ]); + + $response = Http::get('https://phenix.test/users'); + + expect($response->json('uri'))->toBe('https://phenix.test/users'); +}); + +it('fakes http client requests when a condition matches', function (): void { + Http::fake(fn (): string => 'fallback'); + Http::fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/users'), + fn (): array => ['resource' => 'users'] + ); + + expect(Http::get('https://phenix.test/users')->json('resource'))->toBe('users') + ->and(Http::get('https://phenix.test/posts')->body())->toBe('fallback'); +}); + +it('accepts wrapped and amp responses from fake callbacks', function (): void { + $request = new Request('https://phenix.test/users'); + $wrapped = new Response(new AmpResponse('1.1', 200, null, [], 'wrapped', $request)); + + Http::fake(fn (): Response => $wrapped); + expect(Http::get('https://phenix.test/users'))->toBe($wrapped); + + Http::fake(fn (Request $request): AmpResponse => new AmpResponse( + '1.1', + 200, + null, + [], + 'amp', + $request + )); + + expect(Http::get('https://phenix.test/users')->body())->toBe('amp'); +}); + +it('applies mockery expectations through the http facade', function (): void { + $response = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'mocked', + new Request('https://phenix.test/users') + )); + + /** @var \Mockery\Expectation $expectation */ + $expectation = Http::expect('get'); + $expectation + ->once() + ->with('https://phenix.test/users') + ->andReturn($response); + + expect(Http::get('https://phenix.test/users'))->toBe($response); + + Mockery::close(); +}); From 12e5d79a7abb4c2624bd261c49bd76cc0e388b60 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 15:16:52 +0000 Subject: [PATCH 16/49] chore: move tests to client dir --- tests/Unit/Http/{ => Client}/RetryRequestsTest.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/Unit/Http/{ => Client}/RetryRequestsTest.php (100%) diff --git a/tests/Unit/Http/RetryRequestsTest.php b/tests/Unit/Http/Client/RetryRequestsTest.php similarity index 100% rename from tests/Unit/Http/RetryRequestsTest.php rename to tests/Unit/Http/Client/RetryRequestsTest.php From 0651e1b6c3e89fbe38bda7d98c6738be6ef4a73e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 15:42:00 +0000 Subject: [PATCH 17/49] feat: enhance HttpClient with query parameter handling, request methods, and authentication support --- tests/Unit/Http/Client/HttpClientTest.php | 126 ++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index b7db34ae..ed8f6949 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -4,9 +4,13 @@ use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; +use Phenix\Contracts\Arrayable; use Phenix\Facades\Http; +use Phenix\Http\Client\HttpClient; use Phenix\Http\Client\Response; +use function Amp\ByteStream\buffer; + it('fakes all http client requests with an empty successful response', function (): void { Http::fake(); @@ -78,3 +82,125 @@ Mockery::close(); }); + +it('builds get and head requests with query parameters', function (): void { + $client = new HttpClient(); + $requests = []; + + $client->fake(function (Request $request) use (&$requests): string { + $requests[] = [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + ]; + + return 'ok'; + }); + + expect($client->get('https://phenix.test/users', ['page' => '1'])->body())->toBe('ok') + ->and($client->head('https://phenix.test/status', ['check' => 'health'])->body())->toBe('ok') + ->and($requests)->toBe([ + ['method' => 'GET', 'uri' => 'https://phenix.test/users?page=1'], + ['method' => 'HEAD', 'uri' => 'https://phenix.test/status?check=health'], + ]); +}); + +it('builds write requests with the expected methods and bodies', function (): void { + $client = new HttpClient(); + $requests = []; + + $client->fake(function (Request $request) use (&$requests): string { + $requests[] = [ + 'method' => $request->getMethod(), + 'body' => buffer($request->getBody()->getContent()), + ]; + + return 'ok'; + }); + + $arrayable = new class () implements Arrayable { + public function toArray(): array + { + return ['arrayable' => true]; + } + }; + + $client->post('https://phenix.test/users', ['name' => 'Taylor']); + $client->put('https://phenix.test/users/1', $arrayable); + $client->patch('https://phenix.test/users/1', 'patched'); + $client->delete('https://phenix.test/users/1', ['force' => true]); + + expect($requests)->toBe([ + ['method' => 'POST', 'body' => '{"name":"Taylor"}'], + ['method' => 'PUT', 'body' => '{"arrayable":true}'], + ['method' => 'PATCH', 'body' => 'patched'], + ['method' => 'DELETE', 'body' => '{"force":true}'], + ]); +}); + +it('applies headers and authentication helpers to requests', function (): void { + $client = new HttpClient(); + $headers = []; + + $client->fake(function (Request $request) use (&$headers): string { + $headers[] = [ + 'accept' => $request->getHeader('accept'), + 'trace' => $request->getHeader('x-trace-id'), + 'authorization' => $request->getHeader('authorization'), + ]; + + return 'ok'; + }); + + $client + ->withHeaders(['Accept' => 'application/json']) + ->withHeaders(['X-Trace-Id' => 'trace-1']) + ->withBasicAuth('phenix', 'secret') + ->get('https://phenix.test/basic'); + + $client + ->withDigestAuth('digest-user', 'digest-secret') + ->get('https://phenix.test/digest'); + + $client + ->withToken('token-value', 'Token') + ->get('https://phenix.test/token'); + + expect($headers)->toBe([ + [ + 'accept' => 'application/json', + 'trace' => 'trace-1', + 'authorization' => 'Basic ' . base64_encode('phenix:secret'), + ], + [ + 'accept' => 'application/json', + 'trace' => 'trace-1', + 'authorization' => 'Digest ' . base64_encode('digest-user:digest-secret'), + ], + [ + 'accept' => 'application/json', + 'trace' => 'trace-1', + 'authorization' => 'Token token-value', + ], + ]); +}); + +it('prefers matching conditional fakes over the global fake response', function (): void { + $client = new HttpClient(); + + $client->fake(fn (): string => 'fallback'); + $client->fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/users'), + fn (): array => ['matched' => true] + ); + + expect($client->get('https://phenix.test/users')->json('matched'))->toBeTrue() + ->and($client->get('https://phenix.test/posts')->body())->toBe('fallback'); +}); + +it('configures retry fluently for custom and default retry attempts', function (): void { + $client = new HttpClient(); + + expect($client->retry(3))->toBe($client) + ->and($client->retry(0))->toBe($client) + ->and($client->retry(-1))->toBe($client); +}); From 9a6b3813627335c6dc2eba76bc41ea604260178c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 17:03:09 +0000 Subject: [PATCH 18/49] feat: add event listener and logging methods to HttpClient for enhanced functionality --- src/Facades/Http.php | 2 ++ src/Http/Client/HttpClient.php | 27 ++++++++++++++--- tests/Unit/Http/Client/HttpClientTest.php | 37 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Facades/Http.php b/src/Facades/Http.php index 4b6e78f6..f82fb180 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -24,6 +24,8 @@ * @method static \Phenix\Http\Client\HttpClient withDigestAuth(string $username, string $password) * @method static \Phenix\Http\Client\HttpClient withToken(string $token, string $type = 'Bearer') * @method static \Phenix\Http\Client\HttpClient retry(int $times, \Closure|int $sleepMilliseconds = 0, callable|null $when = null) + * @method static \Phenix\Http\Client\HttpClient listen(\Amp\Http\Client\EventListener $eventListener) + * @method static \Phenix\Http\Client\HttpClient log(string $path) * @method static \Phenix\Http\Client\HttpClient fake(\Closure|null $response = null) * @method static \Phenix\Http\Client\HttpClient fakeWhen(\Closure $condition, \Closure $response) * diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index baa4dc12..4f8000d1 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -4,6 +4,8 @@ namespace Phenix\Http\Client; +use Amp\Http\Client\EventListener; +use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\Form; use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; @@ -84,15 +86,32 @@ public function withToken(#[SensitiveParameter] string $token, string $type = 'B public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|null $when = null): self { if ($times <= 0) { - $this->client = $this->builder->retry(2)->build(); + $this->builder = $this->builder->retry(2); + $this->client = $this->builder->build(); return $this; } - $this->client = $this->builder + $this->builder = $this->builder ->retry(0) - ->intercept(new RetryRequests($times, $sleepMilliseconds, $when)) - ->build(); + ->intercept(new RetryRequests($times, $sleepMilliseconds, $when)); + + $this->client = $this->builder->build(); + + return $this; + } + + public function listen(EventListener $eventListener): self + { + $this->builder = $this->builder->listen($eventListener); + $this->client = $this->builder->build(); + + return $this; + } + + public function log(string $path): self + { + $this->listen(new LogHttpArchive($path)); return $this; } diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index ed8f6949..53a78ef4 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; use Phenix\Contracts\Arrayable; @@ -11,6 +12,15 @@ use function Amp\ByteStream\buffer; +function httpClientEventListeners(HttpClient $client): array +{ + $clientProperty = new ReflectionProperty($client, 'client'); + $ampClient = $clientProperty->getValue($client); + $eventListenersProperty = new ReflectionProperty($ampClient, 'eventListeners'); + + return $eventListenersProperty->getValue($ampClient); +} + it('fakes all http client requests with an empty successful response', function (): void { Http::fake(); @@ -204,3 +214,30 @@ public function toArray(): array ->and($client->retry(0))->toBe($client) ->and($client->retry(-1))->toBe($client); }); + +it('configures http archive logging fluently', function (): void { + $client = new HttpClient(); + + expect($client->log(sys_get_temp_dir() . '/phenix-http-client.har'))->toBe($client) + ->and(httpClientEventListeners($client)) + ->toHaveCount(1) + ->sequence( + fn ($listener) => $listener->toBeInstanceOf(LogHttpArchive::class) + ); +}); + +it('keeps logging listeners when retry is configured before or after logging', function (): void { + $firstClient = new HttpClient(); + $secondClient = new HttpClient(); + + $firstClient + ->retry(3) + ->log(sys_get_temp_dir() . '/phenix-http-client-retry-first.har'); + + $secondClient + ->log(sys_get_temp_dir() . '/phenix-http-client-log-first.har') + ->retry(3); + + expect(httpClientEventListeners($firstClient))->toHaveCount(1) + ->and(httpClientEventListeners($secondClient))->toHaveCount(1); +}); From 545e7ba79511f22a9ecfea755cfea5cf57d627b2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 19:22:54 +0000 Subject: [PATCH 19/49] feat: add TLS context and certificate configuration methods to HttpClient --- src/Facades/Http.php | 2 + src/Http/Client/HttpClient.php | 34 ++++++++++++++ tests/Unit/Http/Client/HttpClientTest.php | 55 +++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/src/Facades/Http.php b/src/Facades/Http.php index f82fb180..12abed34 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -26,6 +26,8 @@ * @method static \Phenix\Http\Client\HttpClient retry(int $times, \Closure|int $sleepMilliseconds = 0, callable|null $when = null) * @method static \Phenix\Http\Client\HttpClient listen(\Amp\Http\Client\EventListener $eventListener) * @method static \Phenix\Http\Client\HttpClient log(string $path) + * @method static \Phenix\Http\Client\HttpClient withTlsContext(\Amp\Socket\ClientTlsContext $tlsContext) + * @method static \Phenix\Http\Client\HttpClient withCertificate(string $certificate, string|null $key = null, string|null $ca = null, string|null $passphrase = null, string $peerName = '') * @method static \Phenix\Http\Client\HttpClient fake(\Closure|null $response = null) * @method static \Phenix\Http\Client\HttpClient fakeWhen(\Closure $condition, \Closure $response) * diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 4f8000d1..aa47d342 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -4,6 +4,8 @@ namespace Phenix\Http\Client; +use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\EventListener; use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\Form; @@ -11,6 +13,9 @@ use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; +use Amp\Socket\Certificate; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; use Amp\Sync\LocalSemaphore; use Amp\Sync\Semaphore; use Closure; @@ -116,6 +121,35 @@ public function log(string $path): self return $this; } + public function withTlsContext(ClientTlsContext $tlsContext): self + { + $connectContext = (new ConnectContext())->withTlsContext($tlsContext); + $connectionFactory = new DefaultConnectionFactory(connectContext: $connectContext); + + $this->builder = $this->builder->usingPool(new UnlimitedConnectionPool($connectionFactory)); + $this->client = $this->builder->build(); + + return $this; + } + + public function withCertificate( + string $certificate, + string|null $key = null, + string|null $ca = null, + #[SensitiveParameter] + string|null $passphrase = null, + string $peerName = '' + ): self { + $tlsContext = (new ClientTlsContext($peerName)) + ->withCertificate(new Certificate($certificate, $key, $passphrase)); + + if ($ca !== null) { + $tlsContext = $tlsContext->withCaFile($ca); + } + + return $this->withTlsContext($tlsContext); + } + public function fake(Closure|null $response = null): self { $this->fakeResponse = $response ?? fn (): null => null; diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index 53a78ef4..211f6bc6 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -2,9 +2,12 @@ declare(strict_types=1); +use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; +use Amp\Socket\ClientTlsContext; +use Amp\Socket\ConnectContext; use Phenix\Contracts\Arrayable; use Phenix\Facades\Http; use Phenix\Http\Client\HttpClient; @@ -21,6 +24,27 @@ function httpClientEventListeners(HttpClient $client): array return $eventListenersProperty->getValue($ampClient); } +function httpClientConnectContext(HttpClient $client): ConnectContext +{ + $builderProperty = new ReflectionProperty($client, 'builder'); + $builder = $builderProperty->getValue($client); + + $poolProperty = new ReflectionProperty($builder, 'pool'); + $pool = $poolProperty->getValue($builder); + + $innerPoolProperty = new ReflectionProperty($pool, 'pool'); + $innerPool = $innerPoolProperty->getValue($pool); + + $connectionFactoryProperty = new ReflectionProperty($innerPool, 'connectionFactory'); + $connectionFactory = $connectionFactoryProperty->getValue($innerPool); + + expect($connectionFactory)->toBeInstanceOf(DefaultConnectionFactory::class); + + $connectContextProperty = new ReflectionProperty($connectionFactory, 'connectContext'); + + return $connectContextProperty->getValue($connectionFactory); +} + it('fakes all http client requests with an empty successful response', function (): void { Http::fake(); @@ -241,3 +265,34 @@ public function toArray(): array expect(httpClientEventListeners($firstClient))->toHaveCount(1) ->and(httpClientEventListeners($secondClient))->toHaveCount(1); }); + +it('configures a custom tls context fluently', function (): void { + $client = new HttpClient(); + $tlsContext = (new ClientTlsContext('api.phenix.test')) + ->withCaFile('/tmp/phenix-ca.pem'); + + expect($client->withTlsContext($tlsContext))->toBe($client) + ->and(httpClientConnectContext($client)->getTlsContext())->toBe($tlsContext); +}); + +it('configures certificate based tls fluently', function (): void { + $client = new HttpClient(); + + $client->withCertificate( + certificate: '/tmp/client-cert.pem', + key: '/tmp/client-key.pem', + ca: '/tmp/ca.pem', + passphrase: 'secret', + peerName: 'api.phenix.test' + ); + + $tlsContext = httpClientConnectContext($client)->getTlsContext(); + $certificate = $tlsContext?->getCertificate(); + + expect($tlsContext)->toBeInstanceOf(ClientTlsContext::class) + ->and($tlsContext?->getPeerName())->toBe('api.phenix.test') + ->and($tlsContext?->getCaFile())->toBe('/tmp/ca.pem') + ->and($certificate?->getCertFile())->toBe('/tmp/client-cert.pem') + ->and($certificate?->getKeyFile())->toBe('/tmp/client-key.pem') + ->and($certificate?->getPassphrase())->toBe('secret'); +}); From 8c69771158b14c124c239f4d4b251fa7cde73ed2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 19:28:53 +0000 Subject: [PATCH 20/49] feat: implement IntersectRequests trait for handling fake responses in HttpClient --- .../Client/Concerns/IntersectRequests.php | 83 +++++++++++++++++++ src/Http/Client/HttpClient.php | 72 +--------------- 2 files changed, 86 insertions(+), 69 deletions(-) create mode 100644 src/Http/Client/Concerns/IntersectRequests.php diff --git a/src/Http/Client/Concerns/IntersectRequests.php b/src/Http/Client/Concerns/IntersectRequests.php new file mode 100644 index 00000000..45b66b6f --- /dev/null +++ b/src/Http/Client/Concerns/IntersectRequests.php @@ -0,0 +1,83 @@ + + */ + protected array $fakeResponses = []; + + public function fake(Closure|null $response = null): self + { + $this->fakeResponse = $response ?? fn (): null => null; + + return $this; + } + + public function fakeWhen(Closure $condition, Closure $response): self + { + $this->fakeResponses[] = [ + 'condition' => $condition, + 'response' => $response, + ]; + + return $this; + } + + protected function getFakeResponse(Request $request): Response|null + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, $this)) { + return $this->normalizeFakeResponse(($fake['response'])($request, $this), $request); + } + } + + if ($this->fakeResponse !== null) { + return $this->normalizeFakeResponse(($this->fakeResponse)($request, $this), $request); + } + + return null; + } + + protected function normalizeFakeResponse(mixed $response, Request $request): Response + { + if ($response instanceof Response) { + return $response; + } + + if ($response instanceof AmpResponse) { + return new Response($response); + } + + $headers = []; + $body = $response; + + if (is_array($response)) { + $headers['Content-Type'] = 'application/json'; + $body = json_encode($response); + } + + return new Response(new AmpResponse( + '1.1', + 200, + null, + $headers, + is_string($body) ? $body : '', + $request + )); + } +} diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index aa47d342..e18ab56c 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -12,7 +12,6 @@ use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; -use Amp\Http\Client\Response as AmpResponse; use Amp\Socket\Certificate; use Amp\Socket\ClientTlsContext; use Amp\Socket\ConnectContext; @@ -20,6 +19,7 @@ use Amp\Sync\Semaphore; use Closure; use Phenix\Contracts\Arrayable; +use Phenix\Http\Client\Concerns\IntersectRequests; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; @@ -28,10 +28,11 @@ use function Amp\async; use function Amp\Future\await; use function is_array; -use function is_string; class HttpClient { + use IntersectRequests; + protected AmpHttpClient $client; protected HttpClientBuilder $builder; @@ -40,13 +41,6 @@ class HttpClient protected array $headers = []; - protected Closure|null $fakeResponse = null; - - /** - * @var array - */ - protected array $fakeResponses = []; - public function __construct() { $this->builder = new HttpClientBuilder(); @@ -150,23 +144,6 @@ public function withCertificate( return $this->withTlsContext($tlsContext); } - public function fake(Closure|null $response = null): self - { - $this->fakeResponse = $response ?? fn (): null => null; - - return $this; - } - - public function fakeWhen(Closure $condition, Closure $response): self - { - $this->fakeResponses[] = [ - 'condition' => $condition, - 'response' => $response, - ]; - - return $this; - } - public function get(UriInterface|string $url, array|null $queryParameters = null): Response { return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); @@ -299,49 +276,6 @@ protected function createRequest( return $request; } - private function getFakeResponse(Request $request): Response|null - { - foreach ($this->fakeResponses as $fake) { - if (($fake['condition'])($request, $this)) { - return $this->normalizeFakeResponse(($fake['response'])($request, $this), $request); - } - } - - if ($this->fakeResponse !== null) { - return $this->normalizeFakeResponse(($this->fakeResponse)($request, $this), $request); - } - - return null; - } - - private function normalizeFakeResponse(mixed $response, Request $request): Response - { - if ($response instanceof Response) { - return $response; - } - - if ($response instanceof AmpResponse) { - return new Response($response); - } - - $headers = []; - $body = $response; - - if (is_array($response)) { - $headers['Content-Type'] = 'application/json'; - $body = json_encode($response); - } - - return new Response(new AmpResponse( - '1.1', - 200, - null, - $headers, - is_string($body) ? $body : '', - $request - )); - } - /** * @param Closure(HttpClient): Response $request */ From daef50aec4dea1474563d658fead7ac6dfe3a91e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 19:29:43 +0000 Subject: [PATCH 21/49] chore: rename trait --- .../Concerns/{IntersectRequests.php => CaptureRequests.php} | 2 +- src/Http/Client/HttpClient.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/Http/Client/Concerns/{IntersectRequests.php => CaptureRequests.php} (98%) diff --git a/src/Http/Client/Concerns/IntersectRequests.php b/src/Http/Client/Concerns/CaptureRequests.php similarity index 98% rename from src/Http/Client/Concerns/IntersectRequests.php rename to src/Http/Client/Concerns/CaptureRequests.php index 45b66b6f..c50bebe8 100644 --- a/src/Http/Client/Concerns/IntersectRequests.php +++ b/src/Http/Client/Concerns/CaptureRequests.php @@ -12,7 +12,7 @@ use function is_array; use function is_string; -trait IntersectRequests +trait CaptureRequests { protected Closure|null $fakeResponse = null; diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index e18ab56c..7c5d04fb 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -19,7 +19,7 @@ use Amp\Sync\Semaphore; use Closure; use Phenix\Contracts\Arrayable; -use Phenix\Http\Client\Concerns\IntersectRequests; +use Phenix\Http\Client\Concerns\CaptureRequests; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; @@ -31,7 +31,7 @@ class HttpClient { - use IntersectRequests; + use CaptureRequests; protected AmpHttpClient $client; From 5d7104bfc749bdda6e2db15a8c40d38d6b3b6330 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 19:49:49 +0000 Subject: [PATCH 22/49] feat: introduce ProtocolVersion enum and update fake response handling in CaptureRequests trait --- src/Http/Client/Concerns/CaptureRequests.php | 3 ++- src/Http/Client/Constants/ProtocolVersion.php | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/Http/Client/Constants/ProtocolVersion.php diff --git a/src/Http/Client/Concerns/CaptureRequests.php b/src/Http/Client/Concerns/CaptureRequests.php index c50bebe8..f783c952 100644 --- a/src/Http/Client/Concerns/CaptureRequests.php +++ b/src/Http/Client/Concerns/CaptureRequests.php @@ -7,6 +7,7 @@ use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; use Closure; +use Phenix\Http\Client\Constants\ProtocolVersion; use Phenix\Http\Client\Response; use function is_array; @@ -72,7 +73,7 @@ protected function normalizeFakeResponse(mixed $response, Request $request): Res } return new Response(new AmpResponse( - '1.1', + ProtocolVersion::V1_1->value, 200, null, $headers, diff --git a/src/Http/Client/Constants/ProtocolVersion.php b/src/Http/Client/Constants/ProtocolVersion.php new file mode 100644 index 00000000..84163adf --- /dev/null +++ b/src/Http/Client/Constants/ProtocolVersion.php @@ -0,0 +1,14 @@ + Date: Thu, 21 May 2026 19:50:32 +0000 Subject: [PATCH 23/49] style: php cs --- src/Http/Client/Constants/ProtocolVersion.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Client/Constants/ProtocolVersion.php b/src/Http/Client/Constants/ProtocolVersion.php index 84163adf..5591941d 100644 --- a/src/Http/Client/Constants/ProtocolVersion.php +++ b/src/Http/Client/Constants/ProtocolVersion.php @@ -11,4 +11,4 @@ enum ProtocolVersion: string case V1_1 = '1.1'; case V2 = '2'; -} \ No newline at end of file +} From 1f01009f1bdbb768a86e0c5fba9d3cea46075cdc Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 19:54:07 +0000 Subject: [PATCH 24/49] feat: implement HasHttpStatus trait for standardized HTTP status handling in Response --- src/Http/Client/Concerns/HasHttpStatus.php | 90 ++++++++++++++++++++++ src/Http/Client/Response.php | 86 +-------------------- 2 files changed, 94 insertions(+), 82 deletions(-) create mode 100644 src/Http/Client/Concerns/HasHttpStatus.php diff --git a/src/Http/Client/Concerns/HasHttpStatus.php b/src/Http/Client/Concerns/HasHttpStatus.php new file mode 100644 index 00000000..55c03224 --- /dev/null +++ b/src/Http/Client/Concerns/HasHttpStatus.php @@ -0,0 +1,90 @@ +hasStatus(HttpStatus::OK); + } + + public function created(): bool + { + return $this->hasStatus(HttpStatus::CREATED); + } + + public function accepted(): bool + { + return $this->hasStatus(HttpStatus::ACCEPTED); + } + + public function noContent(): bool + { + return $this->hasStatus(HttpStatus::NO_CONTENT) && $this->body === ''; + } + + public function movedPermanently(): bool + { + return $this->hasStatus(HttpStatus::MOVED_PERMANENTLY); + } + + public function found(): bool + { + return $this->hasStatus(HttpStatus::FOUND); + } + + public function badRequest(): bool + { + return $this->hasStatus(HttpStatus::BAD_REQUEST); + } + + public function unauthorized(): bool + { + return $this->hasStatus(HttpStatus::UNAUTHORIZED); + } + + public function paymentRequired(): bool + { + return $this->hasStatus(HttpStatus::PAYMENT_REQUIRED); + } + + public function forbidden(): bool + { + return $this->hasStatus(HttpStatus::FORBIDDEN); + } + + public function notFound(): bool + { + return $this->hasStatus(HttpStatus::NOT_FOUND); + } + + public function requestTimeout(): bool + { + return $this->hasStatus(HttpStatus::REQUEST_TIMEOUT); + } + + public function conflict(): bool + { + return $this->hasStatus(HttpStatus::CONFLICT); + } + + public function unprocessableEntity(): bool + { + return $this->hasStatus(HttpStatus::UNPROCESSABLE_ENTITY); + } + + public function tooManyRequests(): bool + { + return $this->hasStatus(HttpStatus::TOO_MANY_REQUESTS); + } + + private function hasStatus(HttpStatus $status): bool + { + return $this->status() === $status->value; + } +} diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index 99bf4fd6..0ccdc795 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -7,15 +7,17 @@ use Amp\Http\Client\Response as ClientResponse; use Closure; use Phenix\Data\Collection; +use Phenix\Http\Client\Concerns\HasHttpStatus; use Phenix\Http\Client\Exceptions\RequestException; -use Phenix\Http\Constants\HttpStatus; use Phenix\Util\Arr; use function is_array; class Response { - private readonly string $body; + use HasHttpStatus; + + protected readonly string $body; public function __construct(private readonly ClientResponse $response) { @@ -103,81 +105,6 @@ public function headers(): array return $this->response->getHeaders(); } - public function ok(): bool - { - return $this->hasStatus(HttpStatus::OK); - } - - public function created(): bool - { - return $this->hasStatus(HttpStatus::CREATED); - } - - public function accepted(): bool - { - return $this->hasStatus(HttpStatus::ACCEPTED); - } - - public function noContent(): bool - { - return $this->hasStatus(HttpStatus::NO_CONTENT) && $this->body === ''; - } - - public function movedPermanently(): bool - { - return $this->hasStatus(HttpStatus::MOVED_PERMANENTLY); - } - - public function found(): bool - { - return $this->hasStatus(HttpStatus::FOUND); - } - - public function badRequest(): bool - { - return $this->hasStatus(HttpStatus::BAD_REQUEST); - } - - public function unauthorized(): bool - { - return $this->hasStatus(HttpStatus::UNAUTHORIZED); - } - - public function paymentRequired(): bool - { - return $this->hasStatus(HttpStatus::PAYMENT_REQUIRED); - } - - public function forbidden(): bool - { - return $this->hasStatus(HttpStatus::FORBIDDEN); - } - - public function notFound(): bool - { - return $this->hasStatus(HttpStatus::NOT_FOUND); - } - - public function requestTimeout(): bool - { - return $this->hasStatus(HttpStatus::REQUEST_TIMEOUT); - } - - public function conflict(): bool - { - return $this->hasStatus(HttpStatus::CONFLICT); - } - - public function unprocessableEntity(): bool - { - return $this->hasStatus(HttpStatus::UNPROCESSABLE_ENTITY); - } - - public function tooManyRequests(): bool - { - return $this->hasStatus(HttpStatus::TOO_MANY_REQUESTS); - } - public function onError(Closure $closure): self { if ($this->failed()) { @@ -206,9 +133,4 @@ public function throwIf(Closure|bool $condition): self return $this; } - - private function hasStatus(HttpStatus $status): bool - { - return $this->status() === $status->value; - } } From 0ab8afd437e1404bfe3db995b0ae47db80963a9e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 20:01:29 +0000 Subject: [PATCH 25/49] refactor: simplify json method return logic in Response class --- src/Http/Client/Response.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index 0ccdc795..33b122ff 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -46,11 +46,9 @@ public function json(string|null $key = null, Closure|array|string|null $default return $data; } - if (! is_array($data)) { - return value($default); - } - - return Arr::get($data, $key, $default); + return is_array($data) + ? Arr::get($data, $key, $default) + : value($default); } public function object(): object From 0f686c25ad9d46b915ac84604070c2b6891dd723 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 20:04:27 +0000 Subject: [PATCH 26/49] refactor: simplify retry condition logic in shouldRetry method --- src/Http/Interceptors/RetryRequests.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Http/Interceptors/RetryRequests.php b/src/Http/Interceptors/RetryRequests.php index ff07f6e8..9812a270 100644 --- a/src/Http/Interceptors/RetryRequests.php +++ b/src/Http/Interceptors/RetryRequests.php @@ -65,11 +65,7 @@ private function shouldRetry(HttpException $exception, Request $request, int $at return false; } - if ($this->when !== null) { - return (bool) ($this->when)($exception, $request, $attempt); - } - - return true; + return $this->when === null || (bool) ($this->when)($exception, $request, $attempt); } private function delayBeforeRetry( From 5a44e27b9f89d081845d5078353b09aa23381091 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 21:11:38 +0000 Subject: [PATCH 27/49] fix: report throwable in discardTemporaryFile method for better error handling --- src/Http/Client/StreamResponse.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Http/Client/StreamResponse.php b/src/Http/Client/StreamResponse.php index 3a08b571..617ebe37 100644 --- a/src/Http/Client/StreamResponse.php +++ b/src/Http/Client/StreamResponse.php @@ -160,7 +160,8 @@ private function discardTemporaryFile(string $temporaryPath): void { try { File::deleteFile($temporaryPath); - } catch (Throwable) { + } catch (Throwable $th) { + report($th); } } } From ff0872c6b0eae34385f2e29fd06b94bebfedd91a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 21:28:44 +0000 Subject: [PATCH 28/49] refactor: add HasAuthorization trait for handling various authorization methods in HttpClient --- src/Http/Client/Concerns/HasAuthorization.php | 37 +++++++++++++++++++ src/Http/Client/HttpClient.php | 29 +-------------- 2 files changed, 39 insertions(+), 27 deletions(-) create mode 100644 src/Http/Client/Concerns/HasAuthorization.php diff --git a/src/Http/Client/Concerns/HasAuthorization.php b/src/Http/Client/Concerns/HasAuthorization.php new file mode 100644 index 00000000..56a4b67b --- /dev/null +++ b/src/Http/Client/Concerns/HasAuthorization.php @@ -0,0 +1,37 @@ +headers['Authorization'] = 'Basic ' . base64_encode("{$username}:{$password}"); + + return $this; + } + + public function withDigestAuth( + string $username, + #[SensitiveParameter] + string $password + ): self { + $this->headers['Authorization'] = 'Digest ' . base64_encode("{$username}:{$password}"); + + return $this; + } + + public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): self + { + $this->headers['Authorization'] = "{$type} {$token}"; + + return $this; + } +} diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 7c5d04fb..a5bbed07 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -20,6 +20,7 @@ use Closure; use Phenix\Contracts\Arrayable; use Phenix\Http\Client\Concerns\CaptureRequests; +use Phenix\Http\Client\Concerns\HasAuthorization; use Phenix\Http\Constants\HttpMethod; use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; @@ -32,6 +33,7 @@ class HttpClient { use CaptureRequests; + use HasAuthorization; protected AmpHttpClient $client; @@ -55,33 +57,6 @@ public function withHeaders(array $headers): self return $this; } - public function withBasicAuth( - string $username, - #[SensitiveParameter] - string $password - ): self { - $this->headers['Authorization'] = 'Basic ' . base64_encode("{$username}:{$password}"); - - return $this; - } - - public function withDigestAuth( - string $username, - #[SensitiveParameter] - string $password - ): self { - $this->headers['Authorization'] = 'Digest ' . base64_encode("{$username}:{$password}"); - - return $this; - } - - public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): self - { - $this->headers['Authorization'] = "{$type} {$token}"; - - return $this; - } - public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|null $when = null): self { if ($times <= 0) { From dcc949a1f70235e7f2d5c86dfdc6e618ac1a1b62 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 21:38:56 +0000 Subject: [PATCH 29/49] test: add response state and headers tests for redirect and client error scenarios --- tests/Unit/Http/Client/ResponseTest.php | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php index 00216b21..e6c9831b 100644 --- a/tests/Unit/Http/Client/ResponseTest.php +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -56,6 +56,37 @@ ->and((new Response(new AmpResponse('1.1', 500, null, [], '', $request)))->failed())->toBeTrue(); }); +it('exposes response state and headers', function (): void { + $redirect = new Response(new AmpResponse( + '1.1', + 302, + null, + ['Location' => 'https://phenix.test/next'], + '', + new Request('https://phenix.test') + )); + + $clientError = new Response(new AmpResponse( + '1.1', + 404, + null, + ['Content-Type' => 'application/json'], + '{"message":"Not found"}', + new Request('https://phenix.test/missing') + )); + + expect($redirect->redirect())->toBeTrue() + ->and($redirect->successful())->toBeFalse() + ->and($redirect->failed())->toBeFalse() + ->and($redirect->header('location'))->toBe('https://phenix.test/next') + ->and($redirect->headers())->toBe(['location' => ['https://phenix.test/next']]) + ->and($clientError->clientError())->toBeTrue() + ->and($clientError->serverError())->toBeFalse() + ->and($clientError->failed())->toBeTrue() + ->and($clientError->header('content-type'))->toBe('application/json') + ->and($clientError->headers())->toBe(['content-type' => ['application/json']]); +}); + it('throws request exceptions for failed responses', function (): void { $response = new Response(new AmpResponse( '1.1', From 1af7e1ef07f8f86cc11cd792d6ef3ea38d7af9a0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 21:39:02 +0000 Subject: [PATCH 30/49] test: add request callbacks for all supported HTTP methods in HttpClientPoolTest --- tests/Unit/Http/Client/HttpClientPoolTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/Unit/Http/Client/HttpClientPoolTest.php b/tests/Unit/Http/Client/HttpClientPoolTest.php index b5451308..e6935f26 100644 --- a/tests/Unit/Http/Client/HttpClientPoolTest.php +++ b/tests/Unit/Http/Client/HttpClientPoolTest.php @@ -90,3 +90,73 @@ protected function call( expect($responses)->toHaveCount(4) ->and($maxActive)->toBeLessThanOrEqual(2); }); + +it('creates request callbacks for every supported http method', function (): void { + $client = new class () extends HttpClient { + public array $calls = []; + + protected function call( + HttpMethod $method, + UriInterface|string $url, + Form|Arrayable|array|string|null $data = null, + array|null $queryParameters = null + ): Response { + $this->calls[] = [ + 'method' => $method, + 'url' => (string) $url, + 'data' => $data, + 'query' => $queryParameters, + ]; + + return new Response(new AmpResponse( + '1.1', + 200, + null, + [], + (string) $url, + new Request((string) $url) + )); + } + }; + + $pool = new Pool(); + + ($pool->head('https://phenix.test/head', ['status' => 'pending']))($client); + ($pool->post('https://phenix.test/post', ['name' => 'Phenix']))($client); + ($pool->put('https://phenix.test/put', ['name' => 'Framework']))($client); + ($pool->patch('https://phenix.test/patch', ['enabled' => true]))($client); + ($pool->delete('https://phenix.test/delete', ['force' => true]))($client); + + expect($client->calls)->toBe([ + [ + 'method' => HttpMethod::HEAD, + 'url' => 'https://phenix.test/head', + 'data' => null, + 'query' => ['status' => 'pending'], + ], + [ + 'method' => HttpMethod::POST, + 'url' => 'https://phenix.test/post', + 'data' => ['name' => 'Phenix'], + 'query' => null, + ], + [ + 'method' => HttpMethod::PUT, + 'url' => 'https://phenix.test/put', + 'data' => ['name' => 'Framework'], + 'query' => null, + ], + [ + 'method' => HttpMethod::PATCH, + 'url' => 'https://phenix.test/patch', + 'data' => ['enabled' => true], + 'query' => null, + ], + [ + 'method' => HttpMethod::DELETE, + 'url' => 'https://phenix.test/delete', + 'data' => ['force' => true], + 'query' => null, + ], + ]); +}); From dc7844680432c7da37a31970b8072739c639c785 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 21:43:04 +0000 Subject: [PATCH 31/49] test: add additional retry scenarios for RetryRequests including max attempts and non-idempotent requests --- src/Http/Interceptors/RetryRequests.php | 2 +- tests/Unit/Http/Client/RetryRequestsTest.php | 123 +++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/Http/Interceptors/RetryRequests.php b/src/Http/Interceptors/RetryRequests.php index 9812a270..89cf0c2d 100644 --- a/src/Http/Interceptors/RetryRequests.php +++ b/src/Http/Interceptors/RetryRequests.php @@ -14,7 +14,7 @@ use function Amp\delay; -final class RetryRequests implements ApplicationInterceptor +class RetryRequests implements ApplicationInterceptor { private int $attempts; diff --git a/tests/Unit/Http/Client/RetryRequestsTest.php b/tests/Unit/Http/Client/RetryRequestsTest.php index 7c124479..37311cde 100644 --- a/tests/Unit/Http/Client/RetryRequestsTest.php +++ b/tests/Unit/Http/Client/RetryRequestsTest.php @@ -5,6 +5,7 @@ use Amp\Cancellation; use Amp\Http\Client\DelegateHttpClient; use Amp\Http\Client\HttpException; +use Amp\Http\Client\Internal\EventInvoker; use Amp\Http\Client\Request; use Amp\Http\Client\Response; use Amp\NullCancellation; @@ -61,3 +62,125 @@ public function request(Request $request, Cancellation $cancellation): Response ))->toThrow(HttpException::class) ->and($calls)->toBe(1); }); + +it('stops retrying when the maximum attempts are exhausted', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + throw new HttpException('Connection failed.'); + } + }; + + expect(fn () => (new RetryRequests(1))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); + +it('does not retry non idempotent requests that were already processed', function (): void { + $request = new Request('https://phenix.test', 'POST'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + $exception = new HttpException('Connection failed.'); + + EventInvoker::get()->requestFailed($request, $exception); + + throw $exception; + } + }; + + expect(fn () => (new RetryRequests(3))->request( + $request, + new NullCancellation(), + $client + ))->toThrow(HttpException::class) + ->and($calls)->toBe(1); +}); + +it('uses sleep closures before retrying requests', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + $sleepCalls = []; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + if ($this->calls === 1) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $response = (new RetryRequests( + 2, + function (int $attempt, HttpException $exception, Request $request) use (&$sleepCalls): string { + $sleepCalls[] = [$attempt, $exception->getMessage(), (string) $request->getUri()]; + + return 'invalid'; + } + ))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(2) + ->and($sleepCalls)->toBe([ + [1, 'Connection failed.', 'https://phenix.test'], + ]); +}); + +it('delays before retrying when configured with positive milliseconds', function (): void { + $request = new Request('https://phenix.test'); + $calls = 0; + + $client = new class ($calls) implements DelegateHttpClient { + public function __construct(private int &$calls) + { + } + + public function request(Request $request, Cancellation $cancellation): Response + { + $this->calls++; + + if ($this->calls === 1) { + throw new HttpException('Connection failed.'); + } + + return new Response('1.1', 200, null, [], '', $request); + } + }; + + $startedAt = microtime(true); + + $response = (new RetryRequests(2, 20))->request($request, new NullCancellation(), $client); + + expect($response->getStatus())->toBe(200) + ->and($calls)->toBe(2) + ->and(microtime(true) - $startedAt)->toBeGreaterThanOrEqual(0.015); +}); From 773772e5d2023fd7112aacd0b2ccf5547c037eba Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 22:00:57 +0000 Subject: [PATCH 32/49] test: add tests for streamed response state and headers handling in StreamResponse --- tests/Unit/Http/Client/ResponseTest.php | 41 ++++++++++++++++--- tests/Unit/Http/Client/StreamResponseTest.php | 31 ++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php index e6c9831b..7c449b4b 100644 --- a/tests/Unit/Http/Client/ResponseTest.php +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -27,12 +27,7 @@ ->and($response->object()->user->name)->toBe('Ada') ->and($response->collect('tags'))->toBeInstanceOf(Collection::class) ->and($response->collect('tags')->toArray())->toBe(['php', 'amp']) - ->and($response->status())->toBe(201) - ->and($response->created())->toBeTrue() - ->and($response->successful())->toBeTrue() - ->and($response->failed())->toBeFalse() - ->and($response->header('content-type'))->toBe('application/json') - ->and($response->headers())->toHaveKey('content-type'); + ->and($response->status())->toBe(201); }); it('reports common status helpers', function (): void { @@ -110,6 +105,40 @@ expect(false)->toBeTrue(); }); +it('runs error callbacks only for failed responses', function (): void { + $successful = new Response(new AmpResponse( + '1.1', + 200, + null, + [], + 'Ok', + new Request('https://phenix.test') + )); + + $failed = new Response(new AmpResponse( + '1.1', + 500, + null, + [], + 'Server error', + new Request('https://phenix.test') + )); + + $handled = []; + + $successfulResult = $successful->onError(function (Response $response) use (&$handled): void { + $handled[] = $response->status(); + }); + + $failedResult = $failed->onError(function (Response $response) use (&$handled): void { + $handled[] = $response->status(); + }); + + expect($successfulResult)->toBe($successful) + ->and($failedResult)->toBe($failed) + ->and($handled)->toBe([500]); +}); + it('does not throw for successful responses', function (): void { $response = new Response(new AmpResponse( '1.1', diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index dbf3dba9..768c8698 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -30,6 +30,37 @@ ->and($response->read())->toBeNull(); }); +it('exposes streamed response state and headers', function (): void { + $redirect = new StreamResponse(new AmpResponse( + '1.1', + 302, + null, + ['Location' => 'https://phenix.test/next'], + '', + new Request('https://phenix.test/download') + )); + + $clientError = new StreamResponse(new AmpResponse( + '1.1', + 404, + null, + ['Content-Type' => 'application/json'], + '', + new Request('https://phenix.test/download') + )); + + expect($redirect->redirect())->toBeTrue() + ->and($redirect->successful())->toBeFalse() + ->and($redirect->failed())->toBeFalse() + ->and($redirect->header('location'))->toBe('https://phenix.test/next') + ->and($redirect->headers())->toBe(['location' => ['https://phenix.test/next']]) + ->and($clientError->clientError())->toBeTrue() + ->and($clientError->serverError())->toBeFalse() + ->and($clientError->failed())->toBeTrue() + ->and($clientError->header('content-type'))->toBe('application/json') + ->and($clientError->headers())->toBe(['content-type' => ['application/json']]); +}); + it('iterates streamed chunks and reports bytes read', function (): void { $response = new StreamResponse(new AmpResponse( '1.1', From 2270ddec3d3aae8e579058a4f7153d9282503be2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 22:09:56 +0000 Subject: [PATCH 33/49] feat: add withClient method to HttpClient for custom client configuration --- src/Facades/Http.php | 1 + src/Http/Client/HttpClient.php | 7 +++++++ tests/Unit/Http/Client/HttpClientTest.php | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/Facades/Http.php b/src/Facades/Http.php index 12abed34..8a9014a6 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -20,6 +20,7 @@ * @method static \Phenix\Http\Client\Response patch(\Psr\Http\Message\UriInterface|string $url, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string $data = []) * @method static \Phenix\Http\Client\Response delete(\Psr\Http\Message\UriInterface|string $url, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string $data = []) * @method static \Phenix\Http\Client\HttpClient withHeaders(array $headers) + * @method static \Phenix\Http\Client\HttpClient withClient(\Amp\Http\Client\HttpClient $client) * @method static \Phenix\Http\Client\HttpClient withBasicAuth(string $username, string $password) * @method static \Phenix\Http\Client\HttpClient withDigestAuth(string $username, string $password) * @method static \Phenix\Http\Client\HttpClient withToken(string $token, string $type = 'Bearer') diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index a5bbed07..386f9913 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -57,6 +57,13 @@ public function withHeaders(array $headers): self return $this; } + public function withClient(AmpHttpClient $client): self + { + $this->client = $client; + + return $this; + } + public function retry(int $times, Closure|int $sleepMilliseconds = 0, callable|null $when = null): self { if ($times <= 0) { diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index 211f6bc6..2cb4b638 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -2,8 +2,11 @@ declare(strict_types=1); +use Amp\Cancellation; use Amp\Http\Client\Connection\DefaultConnectionFactory; +use Amp\Http\Client\DelegateHttpClient; use Amp\Http\Client\EventListener\LogHttpArchive; +use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; use Amp\Socket\ClientTlsContext; @@ -95,6 +98,24 @@ function httpClientConnectContext(HttpClient $client): ConnectContext expect(Http::get('https://phenix.test/users')->body())->toBe('amp'); }); +it('sends requests through the amp client when no fake is configured', function (): void { + $client = new HttpClient(); + + $delegate = new class () implements DelegateHttpClient { + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + return new AmpResponse('1.1', 200, null, [], 'amp-response', $request); + } + }; + + expect($client->withClient(new AmpHttpClient($delegate, [])))->toBe($client); + + $response = $client->get('https://phenix.test/live', ['page' => '1']); + + expect($response)->toBeInstanceOf(Response::class) + ->and($response->body())->toBe('amp-response'); +}); + it('applies mockery expectations through the http facade', function (): void { $response = new Response(new AmpResponse( '1.1', From 8bb30d22bcc66e8afa73ad615ab15ea7de19d9c5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 22:19:30 +0000 Subject: [PATCH 34/49] refactor: simplify StreamResponseTest by using DelegateHttpClient for request handling --- tests/Unit/Http/Client/StreamResponseTest.php | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index 768c8698..1e98190f 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -3,15 +3,14 @@ declare(strict_types=1); use Amp\ByteStream\ReadableIterableStream; -use Amp\Http\Client\Form; +use Amp\Cancellation; +use Amp\Http\Client\DelegateHttpClient; +use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; -use Phenix\Contracts\Arrayable; use Phenix\Facades\File; use Phenix\Http\Client\HttpClient; use Phenix\Http\Client\StreamResponse; -use Phenix\Http\Constants\HttpMethod; -use Psr\Http\Message\UriInterface; it('streams response chunks without buffering the wrapper', function (): void { $response = new StreamResponse(new AmpResponse( @@ -168,41 +167,23 @@ }); it('streams requests through the http client callback', function (): void { - $client = new class () extends HttpClient { - public int|null $bodySizeLimit = null; - - public float|null $transferTimeout = null; - - protected function streamCall( - HttpMethod $method, - UriInterface|string $url, - Form|Arrayable|array|string|null $data = null, - array|null $queryParameters = null, - int|null $bodySizeLimit = null, - float|null $transferTimeout = null - ): StreamResponse { - $this->bodySizeLimit = $bodySizeLimit; - $this->transferTimeout = $transferTimeout; - - return new StreamResponse(new AmpResponse( - '1.1', - 200, - null, - [], - (string) $url, - new Request((string) $url) - )); + $client = new HttpClient(); + $delegate = new class () implements DelegateHttpClient { + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + return new AmpResponse('1.1', 200, null, [], 'stream-body', $request); } }; + $client->withClient(new AmpHttpClient($delegate, [])); + $body = $client->stream( 'https://phenix.test/download', fn (StreamResponse $response): string|null => $response->read(), + queryParameters: ['token' => 'abc'], bodySizeLimit: 128 * 1024 * 1024, transferTimeout: 120 ); - expect($body)->toBe('https://phenix.test/download') - ->and($client->bodySizeLimit)->toBe(128 * 1024 * 1024) - ->and($client->transferTimeout)->toBe(120.0); + expect($body)->toBe('stream-body'); }); From 30d964151dd8547c48aab28bb673e6d50d21faf9 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 22:44:34 +0000 Subject: [PATCH 35/49] feat: add stream method to HttpClient for enhanced request handling with callbacks --- src/Facades/Http.php | 1 + src/Http/Client/HttpClient.php | 7 ++- tests/Unit/Http/Client/StreamResponseTest.php | 45 ++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/Facades/Http.php b/src/Facades/Http.php index 8a9014a6..895a0477 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -19,6 +19,7 @@ * @method static \Phenix\Http\Client\Response put(\Psr\Http\Message\UriInterface|string $url, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string $data = []) * @method static \Phenix\Http\Client\Response patch(\Psr\Http\Message\UriInterface|string $url, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string $data = []) * @method static \Phenix\Http\Client\Response delete(\Psr\Http\Message\UriInterface|string $url, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string $data = []) + * @method static mixed stream(\Psr\Http\Message\UriInterface|string $url, \Closure|null $callback = null, array|null $queryParameters = null, int|null $bodySizeLimit = null, float|null $transferTimeout = null, \Phenix\Http\Constants\HttpMethod $method = \Phenix\Http\Constants\HttpMethod::GET, \Amp\Http\Client\Form|\Phenix\Contracts\Arrayable|array|string|null $data = null) * @method static \Phenix\Http\Client\HttpClient withHeaders(array $headers) * @method static \Phenix\Http\Client\HttpClient withClient(\Amp\Http\Client\HttpClient $client) * @method static \Phenix\Http\Client\HttpClient withBasicAuth(string $username, string $password) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index 386f9913..f9f050e4 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -161,11 +161,14 @@ public function stream( Closure|null $callback = null, array|null $queryParameters = null, int|null $bodySizeLimit = null, - float|null $transferTimeout = null + float|null $transferTimeout = null, + HttpMethod $method = HttpMethod::GET, + Form|Arrayable|array|string|null $data = null ): mixed { $response = $this->streamCall( - method: HttpMethod::GET, + method: $method, url: $url, + data: $data, queryParameters: $queryParameters, bodySizeLimit: $bodySizeLimit, transferTimeout: $transferTimeout diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index 1e98190f..fb8f2d01 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -11,6 +11,9 @@ use Phenix\Facades\File; use Phenix\Http\Client\HttpClient; use Phenix\Http\Client\StreamResponse; +use Phenix\Http\Constants\HttpMethod; + +use function Amp\ByteStream\buffer; it('streams response chunks without buffering the wrapper', function (): void { $response = new StreamResponse(new AmpResponse( @@ -168,9 +171,23 @@ it('streams requests through the http client callback', function (): void { $client = new HttpClient(); - $delegate = new class () implements DelegateHttpClient { + $captured = []; + + $delegate = new class ($captured) implements DelegateHttpClient { + public function __construct(private array &$captured) + { + } + public function request(Request $request, Cancellation $cancellation): AmpResponse { + $this->captured[] = [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + 'body' => buffer($request->getBody()->getContent()), + 'bodySizeLimit' => $request->getBodySizeLimit(), + 'transferTimeout' => $request->getTransferTimeout(), + ]; + return new AmpResponse('1.1', 200, null, [], 'stream-body', $request); } }; @@ -185,5 +202,29 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon transferTimeout: 120 ); - expect($body)->toBe('stream-body'); + $postBody = $client->stream( + 'https://phenix.test/export', + fn (StreamResponse $response): string|null => $response->read(), + method: HttpMethod::POST, + data: ['format' => 'csv'] + ); + + expect($body)->toBe('stream-body') + ->and($postBody)->toBe('stream-body') + ->and($captured)->toBe([ + [ + 'method' => 'GET', + 'uri' => 'https://phenix.test/download?token=abc', + 'body' => '', + 'bodySizeLimit' => 128 * 1024 * 1024, + 'transferTimeout' => 120.0, + ], + [ + 'method' => 'POST', + 'uri' => 'https://phenix.test/export', + 'body' => '{"format":"csv"}', + 'bodySizeLimit' => 10485760, + 'transferTimeout' => 10.0, + ], + ]); }); From c0fa730f2527ca3cfd21194dad2d01ad5b6296f1 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 22:48:49 +0000 Subject: [PATCH 36/49] refactor: remove unused event listener function and simplify logging tests in HttpClientTest --- tests/Unit/Http/Client/HttpClientTest.php | 27 +++++------------------ 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index 2cb4b638..8b1ec95a 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -5,7 +5,6 @@ use Amp\Cancellation; use Amp\Http\Client\Connection\DefaultConnectionFactory; use Amp\Http\Client\DelegateHttpClient; -use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; @@ -18,15 +17,6 @@ use function Amp\ByteStream\buffer; -function httpClientEventListeners(HttpClient $client): array -{ - $clientProperty = new ReflectionProperty($client, 'client'); - $ampClient = $clientProperty->getValue($client); - $eventListenersProperty = new ReflectionProperty($ampClient, 'eventListeners'); - - return $eventListenersProperty->getValue($ampClient); -} - function httpClientConnectContext(HttpClient $client): ConnectContext { $builderProperty = new ReflectionProperty($client, 'builder'); @@ -263,28 +253,23 @@ public function toArray(): array it('configures http archive logging fluently', function (): void { $client = new HttpClient(); - expect($client->log(sys_get_temp_dir() . '/phenix-http-client.har'))->toBe($client) - ->and(httpClientEventListeners($client)) - ->toHaveCount(1) - ->sequence( - fn ($listener) => $listener->toBeInstanceOf(LogHttpArchive::class) - ); + expect($client->log(sys_get_temp_dir() . '/phenix-http-client.har'))->toBe($client); }); -it('keeps logging listeners when retry is configured before or after logging', function (): void { +it('chains logging and retry fluently regardless of order', function (): void { $firstClient = new HttpClient(); $secondClient = new HttpClient(); - $firstClient + $firstResult = $firstClient ->retry(3) ->log(sys_get_temp_dir() . '/phenix-http-client-retry-first.har'); - $secondClient + $secondResult = $secondClient ->log(sys_get_temp_dir() . '/phenix-http-client-log-first.har') ->retry(3); - expect(httpClientEventListeners($firstClient))->toHaveCount(1) - ->and(httpClientEventListeners($secondClient))->toHaveCount(1); + expect($firstResult)->toBe($firstClient) + ->and($secondResult)->toBe($secondClient); }); it('configures a custom tls context fluently', function (): void { From 191982da61d929b1f4720c2322752b53d509a136 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 23:14:23 +0000 Subject: [PATCH 37/49] test: add created status helper assertion in ResponseTest --- tests/Unit/Http/Client/ResponseTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php index 7c449b4b..cfeed833 100644 --- a/tests/Unit/Http/Client/ResponseTest.php +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -34,6 +34,7 @@ $request = new Request('https://phenix.test'); expect((new Response(new AmpResponse('1.1', 200, null, [], '', $request)))->ok())->toBeTrue() + ->and((new Response(new AmpResponse('1.1', 201, null, [], '', $request)))->created())->toBeTrue() ->and((new Response(new AmpResponse('1.1', 202, null, [], '', $request)))->accepted())->toBeTrue() ->and((new Response(new AmpResponse('1.1', 204, null, [], '', $request)))->noContent())->toBeTrue() ->and((new Response(new AmpResponse('1.1', 301, null, [], '', $request)))->movedPermanently())->toBeTrue() From 8890846df5f6d1cc1a9f2b800f7e71e399c38c6e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 23:14:32 +0000 Subject: [PATCH 38/49] test: enhance StreamResponseTest to validate response status and content type --- tests/Unit/Http/Client/StreamResponseTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index fb8f2d01..2941f993 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -16,16 +16,19 @@ use function Amp\ByteStream\buffer; it('streams response chunks without buffering the wrapper', function (): void { - $response = new StreamResponse(new AmpResponse( + $ampResponse = new AmpResponse( '1.1', 200, null, ['Content-Type' => 'application/octet-stream'], 'phenix-stream', new Request('https://phenix.test/download') - )); + ); + + $response = new StreamResponse($ampResponse); - expect($response->status())->toBe(200) + expect($response->getClientResponse())->toBe($ampResponse) + ->and($response->status())->toBe(200) ->and($response->ok())->toBeTrue() ->and($response->header('content-type'))->toBe('application/octet-stream') ->and($response->read())->toBe('phenix-stream') From aedf6a1e638e5c9a05414c74f5428970d1944c3d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 23:20:16 +0000 Subject: [PATCH 39/49] refactor: move interceptos to Client namespace --- src/Http/Client/HttpClient.php | 2 +- src/Http/{ => Client}/Interceptors/RetryRequests.php | 2 +- tests/Unit/Http/Client/ResponseTest.php | 9 +++++++++ tests/Unit/Http/Client/RetryRequestsTest.php | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) rename src/Http/{ => Client}/Interceptors/RetryRequests.php (98%) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index f9f050e4..ec671492 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -21,8 +21,8 @@ use Phenix\Contracts\Arrayable; use Phenix\Http\Client\Concerns\CaptureRequests; use Phenix\Http\Client\Concerns\HasAuthorization; +use Phenix\Http\Client\Interceptors\RetryRequests; use Phenix\Http\Constants\HttpMethod; -use Phenix\Http\Interceptors\RetryRequests; use Psr\Http\Message\UriInterface; use SensitiveParameter; diff --git a/src/Http/Interceptors/RetryRequests.php b/src/Http/Client/Interceptors/RetryRequests.php similarity index 98% rename from src/Http/Interceptors/RetryRequests.php rename to src/Http/Client/Interceptors/RetryRequests.php index 89cf0c2d..b2361b7f 100644 --- a/src/Http/Interceptors/RetryRequests.php +++ b/src/Http/Client/Interceptors/RetryRequests.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Phenix\Http\Interceptors; +namespace Phenix\Http\Client\Interceptors; use Amp\Cancellation; use Amp\Http\Client\ApplicationInterceptor; diff --git a/tests/Unit/Http/Client/ResponseTest.php b/tests/Unit/Http/Client/ResponseTest.php index cfeed833..e64892d1 100644 --- a/tests/Unit/Http/Client/ResponseTest.php +++ b/tests/Unit/Http/Client/ResponseTest.php @@ -30,6 +30,15 @@ ->and($response->status())->toBe(201); }); +it('returns defaults for invalid json and full decoded payloads without a key', function (): void { + $request = new Request('https://phenix.test'); + $invalidJson = new Response(new AmpResponse('1.1', 200, null, [], 'invalid-json', $request)); + $validJson = new Response(new AmpResponse('1.1', 200, null, [], '{"active":true}', $request)); + + expect($invalidJson->json(default: 'fallback'))->toBe('fallback') + ->and($validJson->json())->toBe(['active' => true]); +}); + it('reports common status helpers', function (): void { $request = new Request('https://phenix.test'); diff --git a/tests/Unit/Http/Client/RetryRequestsTest.php b/tests/Unit/Http/Client/RetryRequestsTest.php index 37311cde..0ebf9e40 100644 --- a/tests/Unit/Http/Client/RetryRequestsTest.php +++ b/tests/Unit/Http/Client/RetryRequestsTest.php @@ -9,7 +9,7 @@ use Amp\Http\Client\Request; use Amp\Http\Client\Response; use Amp\NullCancellation; -use Phenix\Http\Interceptors\RetryRequests; +use Phenix\Http\Client\Interceptors\RetryRequests; it('retries failed requests until they succeed', function (): void { $request = new Request('https://phenix.test'); From 27bbba944ffa65fdeff4761b2408cb054a0c484f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 21 May 2026 23:38:52 +0000 Subject: [PATCH 40/49] fix: prevent unnecessary retries in RetryRequests interceptor --- src/Http/Client/Interceptors/RetryRequests.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Http/Client/Interceptors/RetryRequests.php b/src/Http/Client/Interceptors/RetryRequests.php index b2361b7f..e33d189a 100644 --- a/src/Http/Client/Interceptors/RetryRequests.php +++ b/src/Http/Client/Interceptors/RetryRequests.php @@ -42,6 +42,10 @@ public function request( try { return $httpClient->request($request, $cancellation); } catch (HttpException $exception) { + if ($attempt >= $this->attempts) { + continue; + } + if (! $this->shouldRetry($exception, $request, $attempt)) { throw $exception; } @@ -57,10 +61,6 @@ public function request( private function shouldRetry(HttpException $exception, Request $request, int $attempt): bool { - if ($attempt >= $this->attempts) { - return false; - } - if (! $request->isIdempotent() && ! $request->isUnprocessed()) { return false; } From 3a33853311a4f6e3cc572ecb14a15af271f43cb5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 22 May 2026 15:23:16 +0000 Subject: [PATCH 41/49] refactor: remove shared setting for HttpClient in container --- src/App.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.php b/src/App.php index 170b26c7..dc51295e 100644 --- a/src/App.php +++ b/src/App.php @@ -92,7 +92,7 @@ public function setup(): void \Phenix\Runtime\Config::build(...) )->setShared(true); - self::$container->add(HttpClient::class)->setShared(true); + self::$container->add(HttpClient::class); self::$container->add(Phenix::class)->addMethodCall('registerCommands'); From f96a3f15483efd099af254758293e685510be5d2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 22 May 2026 17:31:03 +0000 Subject: [PATCH 42/49] refactor: remove withDigestAuth method from HttpClient and related tests --- src/Facades/Http.php | 1 - src/Http/Client/Concerns/HasAuthorization.php | 10 ---------- tests/Unit/Http/Client/HttpClientTest.php | 9 --------- 3 files changed, 20 deletions(-) diff --git a/src/Facades/Http.php b/src/Facades/Http.php index 895a0477..5cccc594 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -23,7 +23,6 @@ * @method static \Phenix\Http\Client\HttpClient withHeaders(array $headers) * @method static \Phenix\Http\Client\HttpClient withClient(\Amp\Http\Client\HttpClient $client) * @method static \Phenix\Http\Client\HttpClient withBasicAuth(string $username, string $password) - * @method static \Phenix\Http\Client\HttpClient withDigestAuth(string $username, string $password) * @method static \Phenix\Http\Client\HttpClient withToken(string $token, string $type = 'Bearer') * @method static \Phenix\Http\Client\HttpClient retry(int $times, \Closure|int $sleepMilliseconds = 0, callable|null $when = null) * @method static \Phenix\Http\Client\HttpClient listen(\Amp\Http\Client\EventListener $eventListener) diff --git a/src/Http/Client/Concerns/HasAuthorization.php b/src/Http/Client/Concerns/HasAuthorization.php index 56a4b67b..fd56afe3 100644 --- a/src/Http/Client/Concerns/HasAuthorization.php +++ b/src/Http/Client/Concerns/HasAuthorization.php @@ -18,16 +18,6 @@ public function withBasicAuth( return $this; } - public function withDigestAuth( - string $username, - #[SensitiveParameter] - string $password - ): self { - $this->headers['Authorization'] = 'Digest ' . base64_encode("{$username}:{$password}"); - - return $this; - } - public function withToken(#[SensitiveParameter] string $token, string $type = 'Bearer'): self { $this->headers['Authorization'] = "{$type} {$token}"; diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index 8b1ec95a..a7c10fbf 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -202,10 +202,6 @@ public function toArray(): array ->withBasicAuth('phenix', 'secret') ->get('https://phenix.test/basic'); - $client - ->withDigestAuth('digest-user', 'digest-secret') - ->get('https://phenix.test/digest'); - $client ->withToken('token-value', 'Token') ->get('https://phenix.test/token'); @@ -216,11 +212,6 @@ public function toArray(): array 'trace' => 'trace-1', 'authorization' => 'Basic ' . base64_encode('phenix:secret'), ], - [ - 'accept' => 'application/json', - 'trace' => 'trace-1', - 'authorization' => 'Digest ' . base64_encode('digest-user:digest-secret'), - ], [ 'accept' => 'application/json', 'trace' => 'trace-1', From 57917b0f5a290315be3e1ca1872e69d1ff985f9a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 14:51:35 +0000 Subject: [PATCH 43/49] feat: implement HttpClientTestLogger for request logging and faking in HttpClient --- src/App.php | 2 + src/Facades/Http.php | 7 +- src/Http/Client/Concerns/CaptureRequests.php | 94 ++++----- src/Http/Client/HttpClient.php | 7 + src/Http/Client/HttpClientTestLogger.php | 194 ++++++++++++++++++ src/Testing/TestCase.php | 2 + tests/Unit/Http/Client/HttpClientTest.php | 4 +- tests/Unit/Http/Client/StreamResponseTest.php | 70 +++++++ 8 files changed, 325 insertions(+), 55 deletions(-) create mode 100644 src/Http/Client/HttpClientTestLogger.php diff --git a/src/App.php b/src/App.php index dc51295e..30a78c1e 100644 --- a/src/App.php +++ b/src/App.php @@ -34,6 +34,7 @@ use Phenix\Facades\Config; use Phenix\Facades\Route; use Phenix\Http\Client\HttpClient; +use Phenix\Http\Client\HttpClientTestLogger; use Phenix\Http\Constants\Protocol; use Phenix\Http\ErrorHandler as AppErrorHandler; use Phenix\Http\ExceptionHandler as AppExceptionHandler; @@ -93,6 +94,7 @@ public function setup(): void )->setShared(true); self::$container->add(HttpClient::class); + self::$container->add(HttpClientTestLogger::class)->setShared(true); self::$container->add(Phenix::class)->addMethodCall('registerCommands'); diff --git a/src/Facades/Http.php b/src/Facades/Http.php index 5cccc594..a8060ac9 100644 --- a/src/Facades/Http.php +++ b/src/Facades/Http.php @@ -29,8 +29,11 @@ * @method static \Phenix\Http\Client\HttpClient log(string $path) * @method static \Phenix\Http\Client\HttpClient withTlsContext(\Amp\Socket\ClientTlsContext $tlsContext) * @method static \Phenix\Http\Client\HttpClient withCertificate(string $certificate, string|null $key = null, string|null $ca = null, string|null $passphrase = null, string $peerName = '') - * @method static \Phenix\Http\Client\HttpClient fake(\Closure|null $response = null) - * @method static \Phenix\Http\Client\HttpClient fakeWhen(\Closure $condition, \Closure $response) + * @method static void fake(\Closure|null $response = null) + * @method static void fakeWhen(\Closure $condition, \Closure $response) + * @method static \Phenix\Data\Collection getRequestLog() + * @method static void resetRequestLog() + * @method static void resetFaking() * * @see \Phenix\Http\Client\HttpClient */ diff --git a/src/Http/Client/Concerns/CaptureRequests.php b/src/Http/Client/Concerns/CaptureRequests.php index f783c952..ddbf6000 100644 --- a/src/Http/Client/Concerns/CaptureRequests.php +++ b/src/Http/Client/Concerns/CaptureRequests.php @@ -5,80 +5,70 @@ namespace Phenix\Http\Client\Concerns; use Amp\Http\Client\Request; -use Amp\Http\Client\Response as AmpResponse; use Closure; -use Phenix\Http\Client\Constants\ProtocolVersion; +use Phenix\App; +use Phenix\Data\Collection; +use Phenix\Http\Client\HttpClientTestLogger; use Phenix\Http\Client\Response; - -use function is_array; -use function is_string; +use Phenix\Http\Client\StreamResponse; trait CaptureRequests { - protected Closure|null $fakeResponse = null; - - /** - * @var array - */ - protected array $fakeResponses = []; + protected HttpClientTestLogger|null $requestLogger = null; - public function fake(Closure|null $response = null): self + public function fake(Closure|null $response = null): void { - $this->fakeResponse = $response ?? fn (): null => null; + if (App::isProduction()) { + return; + } - return $this; + $this->getRequestLogger()->fake($response); } - public function fakeWhen(Closure $condition, Closure $response): self + public function fakeWhen(Closure $condition, Closure $response): void { - $this->fakeResponses[] = [ - 'condition' => $condition, - 'response' => $response, - ]; + if (App::isProduction()) { + return; + } - return $this; + $this->getRequestLogger()->fakeWhen($condition, $response); } - protected function getFakeResponse(Request $request): Response|null + /** + * @return Collection + */ + public function getRequestLog(): Collection { - foreach ($this->fakeResponses as $fake) { - if (($fake['condition'])($request, $this)) { - return $this->normalizeFakeResponse(($fake['response'])($request, $this), $request); - } - } - - if ($this->fakeResponse !== null) { - return $this->normalizeFakeResponse(($this->fakeResponse)($request, $this), $request); - } + return $this->getRequestLogger()->getRequestLog(); + } - return null; + public function resetRequestLog(): void + { + $this->getRequestLogger()->resetRequestLog(); } - protected function normalizeFakeResponse(mixed $response, Request $request): Response + public function resetFaking(): void { - if ($response instanceof Response) { - return $response; - } + $this->getRequestLogger()->resetFaking(); + } - if ($response instanceof AmpResponse) { - return new Response($response); - } + protected function getRequestLogger(): HttpClientTestLogger + { + return $this->requestLogger ??= App::make(HttpClientTestLogger::class); + } - $headers = []; - $body = $response; + protected function recordRequest(Request $request): void + { + $this->getRequestLogger()->record($request); + } - if (is_array($response)) { - $headers['Content-Type'] = 'application/json'; - $body = json_encode($response); - } + protected function getFakeResponse(Request $request): Response|null + { + return $this->getRequestLogger()->getFakeResponse($request, $this); + } - return new Response(new AmpResponse( - ProtocolVersion::V1_1->value, - 200, - null, - $headers, - is_string($body) ? $body : '', - $request - )); + protected function getFakeStreamResponse(Request $request): StreamResponse|null + { + return $this->getRequestLogger()->getFakeStreamResponse($request, $this); } } diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index ec671492..fcf26085 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -206,6 +206,7 @@ protected function call( array|null $queryParameters = null ): Response { $request = $this->createRequest($method, $url, $data, $queryParameters); + $this->recordRequest($request); if ($fake = $this->getFakeResponse($request)) { return $fake; @@ -232,6 +233,12 @@ protected function streamCall( $request->setTransferTimeout($transferTimeout); } + $this->recordRequest($request); + + if ($fake = $this->getFakeStreamResponse($request)) { + return $fake; + } + return new StreamResponse($this->client->request($request)); } diff --git a/src/Http/Client/HttpClientTestLogger.php b/src/Http/Client/HttpClientTestLogger.php new file mode 100644 index 00000000..275ee28a --- /dev/null +++ b/src/Http/Client/HttpClientTestLogger.php @@ -0,0 +1,194 @@ + + */ + protected array $fakeResponses = []; + + /** + * @var Collection + */ + protected Collection $requests; + + public function fake(Closure|null $response = null): void + { + $this->faking = true; + $this->fakeResponse = $response ?? fn (): null => null; + } + + public function fakeWhen(Closure $condition, Closure $response): void + { + $this->fakeResponses[] = [ + 'condition' => $condition, + 'response' => $response, + ]; + } + + public function record(Request $request): void + { + if (! $this->shouldRecordRequests($request)) { + return; + } + + $this->getRequestLog()->add($request); + } + + /** + * @return Collection + */ + public function getRequestLog(): Collection + { + if (! isset($this->requests)) { + $this->requests = Collection::fromArray([]); + } + + return $this->requests; + } + + public function resetRequestLog(): void + { + $this->requests = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->fakeResponse = null; + $this->faking = false; + $this->fakeResponses = []; + $this->resetRequestLog(); + } + + public function shouldRecordRequests(Request $request): bool + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, null)) { + return true; + } + } + return $this->faking; + } + + public function getFakeResponse(Request $request, HttpClient|null $client = null): Response|null + { + [$matched, $response] = $this->findFakeResponse($request, $client); + + if (! $matched) { + return null; + } + + return $this->normalizeFakeResponse($response, $request); + } + + public function getFakeStreamResponse(Request $request, HttpClient|null $client = null): StreamResponse|null + { + [$matched, $response] = $this->findFakeResponse($request, $client); + + if (! $matched) { + return null; + } + + return $this->normalizeFakeStreamResponse($response, $request); + } + + /** + * @return array{bool, mixed} + */ + protected function findFakeResponse(Request $request, HttpClient|null $client): array + { + foreach ($this->fakeResponses as $fake) { + if (($fake['condition'])($request, $client)) { + return [true, ($fake['response'])($request, $client)]; + } + } + + if ($this->faking && $this->fakeResponse !== null) { + return [true, ($this->fakeResponse)($request, $client)]; + } + + return [false, null]; + } + + protected function normalizeFakeResponse(mixed $response, Request $request): Response + { + if ($response instanceof Response) { + return $response; + } + + if ($response instanceof StreamResponse) { + return new Response($response->getClientResponse()); + } + + if ($response instanceof AmpResponse) { + return new Response($response); + } + + return new Response($this->makeClientResponse($response, $request)); + } + + protected function normalizeFakeStreamResponse(mixed $response, Request $request): StreamResponse + { + if ($response instanceof StreamResponse) { + return $response; + } + + if ($response instanceof Response) { + return new StreamResponse($this->makeClientResponse( + $response->body(), + $request, + $response->status(), + $response->headers() + )); + } + + if ($response instanceof AmpResponse) { + return new StreamResponse($response); + } + + return new StreamResponse($this->makeClientResponse($response, $request)); + } + + /** + * @param array $headers + */ + protected function makeClientResponse( + mixed $response, + Request $request, + int $status = 200, + array $headers = [] + ): AmpResponse { + $body = $response; + + if (is_array($response)) { + $headers['Content-Type'] = 'application/json'; + $body = json_encode($response); + } + + return new AmpResponse( + ProtocolVersion::V1_1->value, + $status, + null, + $headers, + is_string($body) ? $body : '', + $request + ); + } +} diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 91b7caea..f24b68a4 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -14,6 +14,7 @@ use Phenix\Facades\Cache; use Phenix\Facades\Config; use Phenix\Facades\Event; +use Phenix\Facades\Http; use Phenix\Facades\Mail; use Phenix\Facades\Queue; use Phenix\Facades\View; @@ -65,6 +66,7 @@ protected function tearDown(): void Event::resetFaking(); Queue::resetFaking(); Mail::resetSendingLog(); + Http::resetFaking(); if (config('cache.default') === Store::FILE->value) { Cache::clear(); diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index a7c10fbf..49bb6b94 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -90,6 +90,7 @@ function httpClientConnectContext(HttpClient $client): ConnectContext it('sends requests through the amp client when no fake is configured', function (): void { $client = new HttpClient(); + $client->resetFaking(); $delegate = new class () implements DelegateHttpClient { public function request(Request $request, Cancellation $cancellation): AmpResponse @@ -103,7 +104,8 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon $response = $client->get('https://phenix.test/live', ['page' => '1']); expect($response)->toBeInstanceOf(Response::class) - ->and($response->body())->toBe('amp-response'); + ->and($response->body())->toBe('amp-response') + ->and($client->getRequestLog())->toBeEmpty(); }); it('applies mockery expectations through the http facade', function (): void { diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index 2941f993..bd209a81 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -9,6 +9,7 @@ use Amp\Http\Client\Request; use Amp\Http\Client\Response as AmpResponse; use Phenix\Facades\File; +use Phenix\Facades\Http; use Phenix\Http\Client\HttpClient; use Phenix\Http\Client\StreamResponse; use Phenix\Http\Constants\HttpMethod; @@ -231,3 +232,72 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon ], ]); }); + +it('fakes facade streamed requests with the global fake response', function (): void { + Http::fake(fn (Request $request): string => 'fake stream: ' . $request->getMethod()); + + $response = Http::stream('https://phenix.test/download'); + + expect($response)->toBeInstanceOf(StreamResponse::class) + ->and($response->read())->toBe('fake stream: GET') + ->and($response->read())->toBeNull() + ->and(Http::getRequestLog())->toHaveCount(1) + ->and((string) Http::getRequestLog()->first()->getUri())->toBe('https://phenix.test/download'); +}); + +it('prefers facade conditional fakes for streamed requests', function (): void { + Http::fake(fn (): string => 'fallback-stream'); + Http::fakeWhen( + fn (Request $request): bool => str_contains((string) $request->getUri(), '/download'), + fn (): string => 'matched-stream' + ); + + $matched = Http::stream('https://phenix.test/download'); + $fallback = Http::stream('https://phenix.test/archive'); + + expect($matched)->toBeInstanceOf(StreamResponse::class) + ->and($matched->read())->toBe('matched-stream') + ->and($fallback)->toBeInstanceOf(StreamResponse::class) + ->and($fallback->read())->toBe('fallback-stream'); +}); + +it('fakes streamed requests before touching the amp client', function (): void { + $client = new HttpClient(); + + $delegate = new class () implements DelegateHttpClient { + public function request(Request $request, Cancellation $cancellation): AmpResponse + { + throw new RuntimeException('The real Amp client should not receive faked stream requests.'); + } + }; + + $client + ->withClient(new AmpHttpClient($delegate, [])) + ->fake(function (Request $request): array { + return [ + 'method' => $request->getMethod(), + 'uri' => (string) $request->getUri(), + 'body' => buffer($request->getBody()->getContent()), + 'bodySizeLimit' => $request->getBodySizeLimit(), + 'transferTimeout' => $request->getTransferTimeout(), + ]; + }); + + $body = $client->stream( + 'https://phenix.test/export', + fn (StreamResponse $response): string|null => $response->read(), + queryParameters: ['token' => 'abc'], + bodySizeLimit: 128 * 1024 * 1024, + transferTimeout: 120, + method: HttpMethod::POST, + data: ['format' => 'csv'] + ); + + expect(json_decode($body, true))->toBe([ + 'method' => 'POST', + 'uri' => 'https://phenix.test/export?token=abc', + 'body' => '{"format":"csv"}', + 'bodySizeLimit' => 128 * 1024 * 1024, + 'transferTimeout' => 120, + ]); +}); From b67ff15ebe1faf2fe95ef0996b446bf2b68a4888 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 16:20:28 +0000 Subject: [PATCH 44/49] feat: enhance request handling by introducing jsonBody method and preserving content type headers --- src/Http/Client/HttpClient.php | 13 ++++++++-- tests/Unit/Http/Client/HttpClientTest.php | 26 ++++++++++++++++--- tests/Unit/Http/Client/StreamResponseTest.php | 5 ++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index fcf26085..e1ff7c4b 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -257,8 +257,8 @@ protected function createRequest( if ($data !== null) { $body = match (true) { - $data instanceof Arrayable => json_encode($data->toArray()), - is_array($data) => json_encode($data), + $data instanceof Arrayable => $this->jsonBody($request, $data->toArray()), + is_array($data) => $this->jsonBody($request, $data), default => $data, }; @@ -268,6 +268,15 @@ protected function createRequest( return $request; } + protected function jsonBody(Request $request, array $data): string + { + if (! $request->hasHeader('content-type')) { + $request->setHeader('Content-Type', 'application/json'); + } + + return json_encode($data) ?? ''; + } + /** * @param Closure(HttpClient): Response $request */ diff --git a/tests/Unit/Http/Client/HttpClientTest.php b/tests/Unit/Http/Client/HttpClientTest.php index 49bb6b94..95c83e63 100644 --- a/tests/Unit/Http/Client/HttpClientTest.php +++ b/tests/Unit/Http/Client/HttpClientTest.php @@ -159,6 +159,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon $requests[] = [ 'method' => $request->getMethod(), 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), ]; return 'ok'; @@ -177,13 +178,30 @@ public function toArray(): array $client->delete('https://phenix.test/users/1', ['force' => true]); expect($requests)->toBe([ - ['method' => 'POST', 'body' => '{"name":"Taylor"}'], - ['method' => 'PUT', 'body' => '{"arrayable":true}'], - ['method' => 'PATCH', 'body' => 'patched'], - ['method' => 'DELETE', 'body' => '{"force":true}'], + ['method' => 'POST', 'body' => '{"name":"Taylor"}', 'contentType' => 'application/json'], + ['method' => 'PUT', 'body' => '{"arrayable":true}', 'contentType' => 'application/json'], + ['method' => 'PATCH', 'body' => 'patched', 'contentType' => null], + ['method' => 'DELETE', 'body' => '{"force":true}', 'contentType' => 'application/json'], ]); }); +it('preserves explicit content type headers for json request bodies', function (): void { + $client = new HttpClient(); + $contentTypes = []; + + $client->fake(function (Request $request) use (&$contentTypes): string { + $contentTypes[] = $request->getHeader('content-type'); + + return 'ok'; + }); + + $client + ->withHeaders(['Content-Type' => 'application/vnd.api+json']) + ->post('https://phenix.test/users', ['name' => 'Taylor']); + + expect($contentTypes)->toBe(['application/vnd.api+json']); +}); + it('applies headers and authentication helpers to requests', function (): void { $client = new HttpClient(); $headers = []; diff --git a/tests/Unit/Http/Client/StreamResponseTest.php b/tests/Unit/Http/Client/StreamResponseTest.php index bd209a81..0c4dd525 100644 --- a/tests/Unit/Http/Client/StreamResponseTest.php +++ b/tests/Unit/Http/Client/StreamResponseTest.php @@ -188,6 +188,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon 'method' => $request->getMethod(), 'uri' => (string) $request->getUri(), 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), 'bodySizeLimit' => $request->getBodySizeLimit(), 'transferTimeout' => $request->getTransferTimeout(), ]; @@ -220,6 +221,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon 'method' => 'GET', 'uri' => 'https://phenix.test/download?token=abc', 'body' => '', + 'contentType' => null, 'bodySizeLimit' => 128 * 1024 * 1024, 'transferTimeout' => 120.0, ], @@ -227,6 +229,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon 'method' => 'POST', 'uri' => 'https://phenix.test/export', 'body' => '{"format":"csv"}', + 'contentType' => 'application/json', 'bodySizeLimit' => 10485760, 'transferTimeout' => 10.0, ], @@ -278,6 +281,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon 'method' => $request->getMethod(), 'uri' => (string) $request->getUri(), 'body' => buffer($request->getBody()->getContent()), + 'contentType' => $request->getHeader('content-type'), 'bodySizeLimit' => $request->getBodySizeLimit(), 'transferTimeout' => $request->getTransferTimeout(), ]; @@ -297,6 +301,7 @@ public function request(Request $request, Cancellation $cancellation): AmpRespon 'method' => 'POST', 'uri' => 'https://phenix.test/export?token=abc', 'body' => '{"format":"csv"}', + 'contentType' => 'application/json', 'bodySizeLimit' => 128 * 1024 * 1024, 'transferTimeout' => 120, ]); From 0c55a9e27a7e720c3b1b0ef45132ffbef81563c3 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 16:21:35 +0000 Subject: [PATCH 45/49] style: php cs --- src/Http/Client/HttpClientTestLogger.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/Client/HttpClientTestLogger.php b/src/Http/Client/HttpClientTestLogger.php index 275ee28a..7020e5e5 100644 --- a/src/Http/Client/HttpClientTestLogger.php +++ b/src/Http/Client/HttpClientTestLogger.php @@ -84,6 +84,7 @@ public function shouldRecordRequests(Request $request): bool return true; } } + return $this->faking; } From f966ec8a116a0d4025c2c531aefed94c3068795e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 16:39:04 +0000 Subject: [PATCH 46/49] fix: ensure jsonBody method returns an empty string on json_encode failure --- src/Http/Client/HttpClient.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index e1ff7c4b..e512ac74 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -274,7 +274,9 @@ protected function jsonBody(Request $request, array $data): string $request->setHeader('Content-Type', 'application/json'); } - return json_encode($data) ?? ''; + $encoded = json_encode($data); + + return $encoded !== false ? $encoded : ''; } /** From e13a1f00ea0fdf8f91e1786790557aaa7c7450dc Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 21:14:34 +0000 Subject: [PATCH 47/49] refactor: simplify response normalization logic in HttpClientTestLogger --- src/Http/Client/HttpClientTestLogger.php | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Http/Client/HttpClientTestLogger.php b/src/Http/Client/HttpClientTestLogger.php index 7020e5e5..d9580e54 100644 --- a/src/Http/Client/HttpClientTestLogger.php +++ b/src/Http/Client/HttpClientTestLogger.php @@ -138,11 +138,9 @@ protected function normalizeFakeResponse(mixed $response, Request $request): Res return new Response($response->getClientResponse()); } - if ($response instanceof AmpResponse) { - return new Response($response); - } - - return new Response($this->makeClientResponse($response, $request)); + return $response instanceof AmpResponse + ? new Response($response) + : new Response($this->makeClientResponse($response, $request)); } protected function normalizeFakeStreamResponse(mixed $response, Request $request): StreamResponse @@ -160,11 +158,9 @@ protected function normalizeFakeStreamResponse(mixed $response, Request $request )); } - if ($response instanceof AmpResponse) { - return new StreamResponse($response); - } - - return new StreamResponse($this->makeClientResponse($response, $request)); + return $response instanceof AmpResponse + ? new StreamResponse($response) + : new StreamResponse($this->makeClientResponse($response, $request)); } /** From 94d942a53a190179d803243ccd1ff21fe178c021 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 21:19:21 +0000 Subject: [PATCH 48/49] feat: add HasTls trait for TLS context and certificate handling in HttpClient --- src/Http/Client/Concerns/HasTls.php | 44 +++++++++++++++++++++++++++++ src/Http/Client/HttpClient.php | 37 ++---------------------- 2 files changed, 46 insertions(+), 35 deletions(-) create mode 100644 src/Http/Client/Concerns/HasTls.php diff --git a/src/Http/Client/Concerns/HasTls.php b/src/Http/Client/Concerns/HasTls.php new file mode 100644 index 00000000..3e65d35f --- /dev/null +++ b/src/Http/Client/Concerns/HasTls.php @@ -0,0 +1,44 @@ +withTlsContext($tlsContext); + $connectionFactory = new DefaultConnectionFactory(connectContext: $connectContext); + + $this->builder = $this->builder->usingPool(new UnlimitedConnectionPool($connectionFactory)); + $this->client = $this->builder->build(); + + return $this; + } + + public function withCertificate( + string $certificate, + string|null $key = null, + string|null $ca = null, + #[SensitiveParameter] + string|null $passphrase = null, + string $peerName = '' + ): self { + $tlsContext = (new ClientTlsContext($peerName)) + ->withCertificate(new Certificate($certificate, $key, $passphrase)); + + if ($ca !== null) { + $tlsContext = $tlsContext->withCaFile($ca); + } + + return $this->withTlsContext($tlsContext); + } +} diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index e512ac74..c55e823f 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -4,27 +4,22 @@ namespace Phenix\Http\Client; -use Amp\Http\Client\Connection\DefaultConnectionFactory; -use Amp\Http\Client\Connection\UnlimitedConnectionPool; use Amp\Http\Client\EventListener; use Amp\Http\Client\EventListener\LogHttpArchive; use Amp\Http\Client\Form; use Amp\Http\Client\HttpClient as AmpHttpClient; use Amp\Http\Client\HttpClientBuilder; use Amp\Http\Client\Request; -use Amp\Socket\Certificate; -use Amp\Socket\ClientTlsContext; -use Amp\Socket\ConnectContext; use Amp\Sync\LocalSemaphore; use Amp\Sync\Semaphore; use Closure; use Phenix\Contracts\Arrayable; use Phenix\Http\Client\Concerns\CaptureRequests; use Phenix\Http\Client\Concerns\HasAuthorization; +use Phenix\Http\Client\Concerns\HasTls; use Phenix\Http\Client\Interceptors\RetryRequests; use Phenix\Http\Constants\HttpMethod; use Psr\Http\Message\UriInterface; -use SensitiveParameter; use function Amp\async; use function Amp\Future\await; @@ -34,6 +29,7 @@ class HttpClient { use CaptureRequests; use HasAuthorization; + use HasTls; protected AmpHttpClient $client; @@ -97,35 +93,6 @@ public function log(string $path): self return $this; } - public function withTlsContext(ClientTlsContext $tlsContext): self - { - $connectContext = (new ConnectContext())->withTlsContext($tlsContext); - $connectionFactory = new DefaultConnectionFactory(connectContext: $connectContext); - - $this->builder = $this->builder->usingPool(new UnlimitedConnectionPool($connectionFactory)); - $this->client = $this->builder->build(); - - return $this; - } - - public function withCertificate( - string $certificate, - string|null $key = null, - string|null $ca = null, - #[SensitiveParameter] - string|null $passphrase = null, - string $peerName = '' - ): self { - $tlsContext = (new ClientTlsContext($peerName)) - ->withCertificate(new Certificate($certificate, $key, $passphrase)); - - if ($ca !== null) { - $tlsContext = $tlsContext->withCaFile($ca); - } - - return $this->withTlsContext($tlsContext); - } - public function get(UriInterface|string $url, array|null $queryParameters = null): Response { return $this->call(HttpMethod::GET, $url, queryParameters: $queryParameters); From 71be070e949f245cdfd08677e6935de493eee1f7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 23 May 2026 21:55:05 +0000 Subject: [PATCH 49/49] refactor: replace for loop with do-while for retry attempts in RetryRequests --- src/Http/Client/Interceptors/RetryRequests.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Http/Client/Interceptors/RetryRequests.php b/src/Http/Client/Interceptors/RetryRequests.php index e33d189a..885ae4c2 100644 --- a/src/Http/Client/Interceptors/RetryRequests.php +++ b/src/Http/Client/Interceptors/RetryRequests.php @@ -35,8 +35,9 @@ public function request( DelegateHttpClient $httpClient ): Response { $exception = null; + $attempt = 1; - for ($attempt = 1; $attempt <= $this->attempts; $attempt++) { + do { $clonedRequest = clone $request; try { @@ -54,7 +55,7 @@ public function request( $request = $clonedRequest; } - } + } while (++$attempt <= $this->attempts); throw $exception; }