From f060562dd07aae555b3b2183ea36620551ee9df8 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:01:33 +0400 Subject: [PATCH 01/14] [#534] Add HttpClient adapter skeleton --- .../Contracts/CurlAdapterInterface.php | 19 +++++++ .../Contracts/HttpClientAdapterInterface.php | 19 +++++++ .../Contracts/MultiCurlAdapterInterface.php | 19 +++++++ src/HttpClient/Enums/HttpClientType.php | 27 ++++++++++ .../Factories/HttpClientFactory.php | 52 +++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 src/HttpClient/Contracts/CurlAdapterInterface.php create mode 100644 src/HttpClient/Contracts/HttpClientAdapterInterface.php create mode 100644 src/HttpClient/Contracts/MultiCurlAdapterInterface.php create mode 100644 src/HttpClient/Enums/HttpClientType.php create mode 100644 src/HttpClient/Factories/HttpClientFactory.php diff --git a/src/HttpClient/Contracts/CurlAdapterInterface.php b/src/HttpClient/Contracts/CurlAdapterInterface.php new file mode 100644 index 00000000..4e5bc9a9 --- /dev/null +++ b/src/HttpClient/Contracts/CurlAdapterInterface.php @@ -0,0 +1,19 @@ +resolve(); + } + + public function resolve(): HttpClient + { + if (!$this->instance) { + $this->instance = $this->createInstance(); + } + + return $this->instance; + } + + private function createInstance(): HttpClient + { + return new HttpClient(); + } +} From 4db153ea018978f6bd3d1dd2f5d9c22ed6ff965d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:58:50 +0400 Subject: [PATCH 02/14] [#534] Add vendor-backed HttpClient adapters --- src/HttpClient/Adapters/CurlAdapter.php | 166 ++++++++++++++++++ src/HttpClient/Adapters/MultiCurlAdapter.php | 129 ++++++++++++++ .../Contracts/CurlAdapterInterface.php | 61 +++++++ .../Contracts/HttpClientAdapterInterface.php | 41 +++++ .../Contracts/MultiCurlAdapterInterface.php | 29 +++ 5 files changed, 426 insertions(+) create mode 100644 src/HttpClient/Adapters/CurlAdapter.php create mode 100644 src/HttpClient/Adapters/MultiCurlAdapter.php diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php new file mode 100644 index 00000000..bc8e9d6a --- /dev/null +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -0,0 +1,166 @@ +client = $client ?? new Curl(); + } + + public function setUrl(string $url): CurlAdapterInterface + { + $this->client->setUrl($url); + + return $this; + } + + /** + * @param mixed $value + */ + public function setOpt(int $option, $value): CurlAdapterInterface + { + $this->client->setOpt($option, $value); + + return $this; + } + + /** + * @param array $options + */ + public function setOpts(array $options): CurlAdapterInterface + { + $this->client->setOpts($options); + + return $this; + } + + /** + * @param mixed $value + */ + public function setHeader(string $key, $value): CurlAdapterInterface + { + $this->client->setHeader($key, $value); + + return $this; + } + + /** + * @param array $headers + */ + public function setHeaders(array $headers): CurlAdapterInterface + { + $this->client->setHeaders($headers); + + return $this; + } + + /** + * @param mixed $data + * @return mixed + */ + public function buildPostData($data) + { + return $this->client->buildPostData($data); + } + + /** + * @return mixed + */ + public function start() + { + return $this->client->exec(); + } + + /** + * @return int|string + */ + public function getId() + { + return $this->client->getId(); + } + + public function isError(): bool + { + return $this->client->isError(); + } + + public function getErrorCode(): int + { + return $this->client->getErrorCode(); + } + + public function getErrorMessage(): ?string + { + return $this->client->getErrorMessage(); + } + + /** + * @return iterable + */ + public function getResponseHeaders(): iterable + { + return $this->client->getResponseHeaders(); + } + + /** + * @return mixed + */ + public function getResponseCookies() + { + return $this->client->getResponseCookies(); + } + + /** + * @return mixed + */ + public function getResponse() + { + return $this->client->getResponse(); + } + + /** + * @return mixed + */ + public function getInfo(?int $option = null) + { + return $option ? $this->client->getInfo($option) : $this->client->getInfo(); + } + + public function getUrl(): ?string + { + return $this->client->getUrl(); + } + + public function supportsMethod(string $method): bool + { + return method_exists($this->client, $method); + } + + /** + * @param array $arguments + * @return mixed + */ + public function callMethod(string $method, array $arguments) + { + return $this->client->$method(...$arguments); + } +} diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php new file mode 100644 index 00000000..42b0ef2a --- /dev/null +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -0,0 +1,129 @@ +client = $client ?? new MultiCurl(); + } + + public function complete(callable $callback): MultiCurlAdapterInterface + { + $this->client->complete($callback); + + return $this; + } + + public function success(callable $callback): MultiCurlAdapterInterface + { + $this->client->success($callback); + + return $this; + } + + public function error(callable $callback): MultiCurlAdapterInterface + { + $this->client->error($callback); + + return $this; + } + + /** + * @return mixed + */ + public function start() + { + return $this->client->start(); + } + + /** + * @param array $data + * @return mixed + */ + public function addGet(string $url, array $data = []) + { + return $this->client->addGet($url, $data); + } + + /** + * @param mixed $data + * @return mixed + */ + public function addPost(string $url, $data = '', bool $follow_303_with_post = false) + { + return $this->client->addPost($url, $data, $follow_303_with_post); + } + + /** + * @param mixed $value + */ + public function setHeader(string $key, $value): MultiCurlAdapterInterface + { + $this->client->setHeader($key, $value); + + return $this; + } + + /** + * @param array $headers + */ + public function setHeaders(array $headers): MultiCurlAdapterInterface + { + $this->client->setHeaders($headers); + + return $this; + } + + /** + * @param mixed $value + */ + public function setOpt(int $option, $value): MultiCurlAdapterInterface + { + $this->client->setOpt($option, $value); + + return $this; + } + + /** + * @param array $options + */ + public function setOpts(array $options): MultiCurlAdapterInterface + { + $this->client->setOpts($options); + + return $this; + } + + public function supportsMethod(string $method): bool + { + return method_exists($this->client, $method); + } + + /** + * @param array $arguments + * @return mixed + */ + public function callMethod(string $method, array $arguments) + { + return $this->client->$method(...$arguments); + } +} diff --git a/src/HttpClient/Contracts/CurlAdapterInterface.php b/src/HttpClient/Contracts/CurlAdapterInterface.php index 4e5bc9a9..4582b123 100644 --- a/src/HttpClient/Contracts/CurlAdapterInterface.php +++ b/src/HttpClient/Contracts/CurlAdapterInterface.php @@ -16,4 +16,65 @@ */ interface CurlAdapterInterface extends HttpClientAdapterInterface { + /** + * Sets request URL + */ + public function setUrl(string $url): self; + + /** + * Builds post data + * @param mixed $data + * @return mixed + */ + public function buildPostData($data); + + /** + * Gets request id + * @return int|string + */ + public function getId(); + + /** + * Checks if request has error + */ + public function isError(): bool; + + /** + * Gets error code + */ + public function getErrorCode(): int; + + /** + * Gets error message + */ + public function getErrorMessage(): ?string; + + /** + * Gets response headers + * @return iterable + */ + public function getResponseHeaders(): iterable; + + /** + * Gets response cookies + * @return mixed + */ + public function getResponseCookies(); + + /** + * Gets response + * @return mixed + */ + public function getResponse(); + + /** + * Gets curl info + * @return mixed + */ + public function getInfo(?int $option = null); + + /** + * Gets request URL + */ + public function getUrl(): ?string; } diff --git a/src/HttpClient/Contracts/HttpClientAdapterInterface.php b/src/HttpClient/Contracts/HttpClientAdapterInterface.php index 4040de15..be48c272 100644 --- a/src/HttpClient/Contracts/HttpClientAdapterInterface.php +++ b/src/HttpClient/Contracts/HttpClientAdapterInterface.php @@ -16,4 +16,45 @@ */ interface HttpClientAdapterInterface { + /** + * Starts request execution + * @return mixed + */ + public function start(); + + /** + * Sets request header + * @param mixed $value + */ + public function setHeader(string $key, $value): self; + + /** + * Sets request headers + * @param array $headers + */ + public function setHeaders(array $headers): self; + + /** + * Sets request option + * @param mixed $value + */ + public function setOpt(int $option, $value): self; + + /** + * Sets multiple request options + * @param array $options + */ + public function setOpts(array $options): self; + + /** + * Checks if adapter supports method passthrough + */ + public function supportsMethod(string $method): bool; + + /** + * Calls supported adapter method + * @param array $arguments + * @return mixed + */ + public function callMethod(string $method, array $arguments); } diff --git a/src/HttpClient/Contracts/MultiCurlAdapterInterface.php b/src/HttpClient/Contracts/MultiCurlAdapterInterface.php index f35ec143..f04ed09e 100644 --- a/src/HttpClient/Contracts/MultiCurlAdapterInterface.php +++ b/src/HttpClient/Contracts/MultiCurlAdapterInterface.php @@ -16,4 +16,33 @@ */ interface MultiCurlAdapterInterface extends HttpClientAdapterInterface { + /** + * Registers complete callback + */ + public function complete(callable $callback): self; + + /** + * Registers success callback + */ + public function success(callable $callback): self; + + /** + * Registers error callback + */ + public function error(callable $callback): self; + + /** + * Adds GET request + * @param array $data + * @return mixed + */ + public function addGet(string $url, array $data = []); + + /** + * Adds POST request + * @param mixed $data + * @return mixed + */ + public function addPost(string $url, $data = '', bool $follow_303_with_post = false); + } From 0bf45c2b17a71950d32e40ae651a3f69c9b899fb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:01:08 +0400 Subject: [PATCH 03/14] [#534] Route HttpClient through curl adapters --- src/HttpClient/Adapters/MultiCurlAdapter.php | 5 +- src/HttpClient/HttpClient.php | 98 ++++++++++++++------ 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 42b0ef2a..e4be63ab 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -12,6 +12,7 @@ use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; use Curl\MultiCurl; +use Curl\Curl; /** * Class MultiCurlAdapter @@ -28,7 +29,9 @@ public function __construct(?MultiCurl $client = null) public function complete(callable $callback): MultiCurlAdapterInterface { - $this->client->complete($callback); + $this->client->complete(function (Curl $instance) use ($callback): void { + $callback(new CurlAdapter($instance)); + }); return $this; } diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index b864b3d2..69da622d 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -10,9 +10,13 @@ namespace Quantum\HttpClient; +use Quantum\HttpClient\Contracts\HttpClientAdapterInterface; +use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; +use Quantum\HttpClient\Contracts\CurlAdapterInterface; use Quantum\HttpClient\Exceptions\HttpClientException; +use Quantum\HttpClient\Adapters\MultiCurlAdapter; +use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\App\Exceptions\BaseException; -use Curl\CaseInsensitiveArray; use Curl\MultiCurl; use ErrorException; use Curl\Curl; @@ -20,7 +24,6 @@ /** * HttpClient Class * @package Quantum\HttpClient - * @uses php-curl-class/php-curl-class * @method object addGet(string $url, array $data = []) * @method object addPost(string $url, string $data = '', bool $follow_303_with_post = false) * @method setHeader($key, $value) @@ -50,9 +53,9 @@ class HttpClient public const RESPONSE_BODY = 'body'; /** - * @var MultiCurl|Curl + * @var HttpClientAdapterInterface|null */ - private Curl|MultiCurl|null $client = null; + private ?HttpClientAdapterInterface $client = null; private string $method = 'GET'; @@ -68,12 +71,12 @@ class HttpClient private array $requestHeaders = []; /** - * @var array + * @var array */ private array $response = []; /** - * @var array + * @var array */ private array $errors = []; @@ -82,8 +85,11 @@ class HttpClient */ public function createRequest(string $url, ?Curl $client = null): HttpClient { - $this->client = $client ?: new Curl(); - $this->client->setUrl($url); + $adapter = new CurlAdapter($client); + $adapter->setUrl($url); + + $this->client = $adapter; + return $this; } @@ -92,12 +98,14 @@ public function createRequest(string $url, ?Curl $client = null): HttpClient */ public function createMultiRequest(?MultiCurl $client = null): HttpClient { - $this->client = $client ?: new MultiCurl(); + $adapter = new MultiCurlAdapter($client); - $this->client->complete(function (Curl $instance): void { + $adapter->complete(function (CurlAdapterInterface $instance): void { $this->handleResponse($instance); }); + $this->client = $adapter; + return $this; } @@ -106,14 +114,24 @@ public function createMultiRequest(?MultiCurl $client = null): HttpClient */ public function createAsyncMultiRequest(callable $success, callable $error, ?MultiCurl $client = null): HttpClient { - $this->client = $client ?: new MultiCurl(); + $adapter = new MultiCurlAdapter($client); + + $adapter->success($success); + $adapter->error($error); - $this->client->success($success); - $this->client->error($error); + $this->client = $adapter; return $this; } + /** + * Gets the current adapter + */ + public function getAdapter(): ?HttpClientAdapterInterface + { + return $this->client; + } + /** * Sets http method * @throws BaseException @@ -157,12 +175,12 @@ public function getData() /** * Checks if the request is multi cURL - * @phpstan-assert-if-true MultiCurl $this->client - * @phpstan-assert-if-false Curl $this->client + * @phpstan-assert-if-true MultiCurlAdapterInterface $this->client + * @phpstan-assert-if-false CurlAdapterInterface $this->client */ public function isMultiRequest(): bool { - return $this->client instanceof MultiCurl; + return $this->client instanceof MultiCurlAdapterInterface; } /** @@ -251,20 +269,28 @@ public function getResponseBody() /** * Gets the entire response - * @return array + * @return array */ public function getResponse(): array { - return $this->isMultiRequest() ? $this->response : ($this->response[$this->client->getId()] ?? []); + if ($this->isMultiRequest()) { + return $this->response; + } + + return $this->response[$this->client->getId()] ?? []; } /** * Returns the errors - * @return array + * @return array */ public function getErrors(): array { - return $this->isMultiRequest() ? $this->errors : ($this->errors[$this->client->getId()] ?? []); + if ($this->isMultiRequest()) { + return $this->errors; + } + + return $this->errors[$this->client->getId()] ?? []; } /** @@ -297,17 +323,17 @@ public function url(): ?string */ public function __call(string $method, array $arguments): HttpClient { - if (is_null($this->client)) { - throw HttpClientException::requestNotCreated(); - } + $this->ensureRequestCreated(); - if (!method_exists($this->client, $method)) { + if (!$this->client->supportsMethod($method)) { throw HttpClientException::methodNotSupported($method, $this->client::class); } $this->interceptCall($method, $arguments); - $this->client->$method(...$arguments); + $this->ensureRequestCreated(); + + $this->client->callMethod($method, $arguments); return $this; } @@ -325,14 +351,14 @@ private function startSingleRequest(): void $this->client->setOpt(CURLOPT_POSTFIELDS, $this->client->buildPostData($this->data)); } - $this->client->exec(); + $this->client->start(); $this->handleResponse($this->client); } /** * Handles the response */ - private function handleResponse(Curl $instance): void + private function handleResponse(CurlAdapterInterface $instance): void { if ($instance->isError()) { $this->errors[$instance->getId()] = [ @@ -349,9 +375,10 @@ private function handleResponse(Curl $instance): void } /** + * @param iterable $headers * @return array */ - private function formatHeaders(CaseInsensitiveArray $headers): array + private function formatHeaders(iterable $headers): array { $formatted = []; @@ -364,12 +391,23 @@ private function formatHeaders(CaseInsensitiveArray $headers): array /** * @throws BaseException - * @phpstan-assert Curl $this->client + * @phpstan-assert CurlAdapterInterface $this->client */ private function ensureSingleRequest(): void { if ($this->isMultiRequest()) { - throw HttpClientException::methodNotSupported(__METHOD__, MultiCurl::class); + throw HttpClientException::methodNotSupported(__METHOD__, MultiCurlAdapter::class); + } + } + + /** + * @throws HttpClientException + * @phpstan-assert HttpClientAdapterInterface $this->client + */ + private function ensureRequestCreated(): void + { + if ($this->client === null) { + throw HttpClientException::requestNotCreated(); } } From 6de47bb5e472674fda70835e312f069a45c7582d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:58:36 +0400 Subject: [PATCH 04/14] [#534] Cover HttpClient adapter selection --- tests/Unit/HttpClient/HttpClientTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index c0ed19cd..4c2194de 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -3,6 +3,8 @@ namespace Quantum\Tests\Unit\HttpClient; use Quantum\HttpClient\Exceptions\HttpClientException; +use Quantum\HttpClient\Adapters\MultiCurlAdapter; +use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\HttpClient\HttpClient; use Quantum\Tests\Unit\AppTestCase; use Curl\CaseInsensitiveArray; @@ -66,9 +68,13 @@ public function testHttpClientIsMultiRequest(): void $this->assertFalse($this->httpClient->isMultiRequest()); + $this->assertInstanceOf(CurlAdapter::class, $this->httpClient->getAdapter()); + $this->httpClient->createMultiRequest($multi); $this->assertTrue($this->httpClient->isMultiRequest()); + + $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); } public function testHttpClientRequestNotCreated(): void @@ -235,6 +241,8 @@ public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void $this->httpClient->createAsyncMultiRequest($success, $error, $multi); $this->assertTrue($this->httpClient->isMultiRequest()); + + $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); } public function testHttpClientInfoAndUrl(): void From 9c3a3b362d9f99213cab3a80cf8da05ce3275eea Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:15:24 +0400 Subject: [PATCH 05/14] [#534] Add HttpClient adapter tests --- .../HttpClient/Adapters/CurlAdapterTest.php | 82 +++++++++++++++++ .../Adapters/MultiCurlAdapterTest.php | 90 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/Unit/HttpClient/Adapters/CurlAdapterTest.php create mode 100644 tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php new file mode 100644 index 00000000..9bde2d83 --- /dev/null +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -0,0 +1,82 @@ +shouldReceive('setUrl')->once()->with('https://example.com'); + $curl->shouldReceive('setHeader')->once()->with('Accept', 'application/json'); + $curl->shouldReceive('setHeaders')->once()->with(['X-Test' => 'yes']); + $curl->shouldReceive('setOpt')->once()->with(CURLOPT_TIMEOUT, 10); + $curl->shouldReceive('setOpts')->once()->with([CURLOPT_CONNECTTIMEOUT => 5]); + $curl->shouldReceive('buildPostData')->once()->with(['a' => 1])->andReturn('a=1'); + $curl->shouldReceive('exec')->once()->andReturn('ok'); + + $adapter = new CurlAdapter($curl); + + $this->assertSame($adapter, $adapter->setUrl('https://example.com')); + $this->assertSame($adapter, $adapter->setHeader('Accept', 'application/json')); + $this->assertSame($adapter, $adapter->setHeaders(['X-Test' => 'yes'])); + $this->assertSame($adapter, $adapter->setOpt(CURLOPT_TIMEOUT, 10)); + $this->assertSame($adapter, $adapter->setOpts([CURLOPT_CONNECTTIMEOUT => 5])); + $this->assertSame('a=1', $adapter->buildPostData(['a' => 1])); + $this->assertSame('ok', $adapter->start()); + } + + public function testCurlAdapterDelegatesResponseMethods(): void + { + $headers = new CaseInsensitiveArray(['Content-Type' => 'application/json']); + $response = (object) ['ok' => true]; + + $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('getId')->once()->andReturn(7); + $curl->shouldReceive('isError')->once()->andReturn(false); + $curl->shouldReceive('getErrorCode')->once()->andReturn(0); + $curl->shouldReceive('getErrorMessage')->once()->andReturn(null); + $curl->shouldReceive('getResponseHeaders')->once()->andReturn($headers); + $curl->shouldReceive('getResponseCookies')->once()->andReturn(['sid' => 'abc']); + $curl->shouldReceive('getResponse')->once()->andReturn($response); + $curl->shouldReceive('getInfo')->with(CURLINFO_HTTP_CODE)->once()->andReturn(200); + $curl->shouldReceive('getUrl')->once()->andReturn('https://example.com'); + + $adapter = new CurlAdapter($curl); + + $this->assertSame(7, $adapter->getId()); + $this->assertFalse($adapter->isError()); + $this->assertSame(0, $adapter->getErrorCode()); + $this->assertNull($adapter->getErrorMessage()); + $this->assertSame($headers, $adapter->getResponseHeaders()); + $this->assertSame(['sid' => 'abc'], $adapter->getResponseCookies()); + $this->assertSame($response, $adapter->getResponse()); + $this->assertSame(200, $adapter->getInfo(CURLINFO_HTTP_CODE)); + $this->assertSame('https://example.com', $adapter->getUrl()); + } + + public function testCurlAdapterSupportsAndCallsVendorMethods(): void + { + $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setTimeout')->once()->with(15)->andReturnNull(); + + $adapter = new CurlAdapter($curl); + + $this->assertTrue($adapter->supportsMethod('setTimeout')); + $this->assertFalse($adapter->supportsMethod('missingMethod')); + $this->assertNull($adapter->callMethod('setTimeout', [15])); + } +} diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php new file mode 100644 index 00000000..57509aab --- /dev/null +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -0,0 +1,90 @@ +shouldReceive('setHeader')->once()->with('Accept', 'application/json'); + $multiCurl->shouldReceive('setHeaders')->once()->with(['X-Test' => 'yes']); + $multiCurl->shouldReceive('setOpt')->once()->with(CURLOPT_TIMEOUT, 10); + $multiCurl->shouldReceive('setOpts')->once()->with([CURLOPT_CONNECTTIMEOUT => 5]); + $multiCurl->shouldReceive('addGet')->once()->with('https://example.com', ['a' => 1])->andReturn((object) ['id' => 1]); + $multiCurl->shouldReceive('addPost')->once()->with('https://example.com', 'payload', true)->andReturn((object) ['id' => 2]); + $multiCurl->shouldReceive('start')->once()->andReturnNull(); + + $adapter = new MultiCurlAdapter($multiCurl); + + $this->assertSame($adapter, $adapter->setHeader('Accept', 'application/json')); + $this->assertSame($adapter, $adapter->setHeaders(['X-Test' => 'yes'])); + $this->assertSame($adapter, $adapter->setOpt(CURLOPT_TIMEOUT, 10)); + $this->assertSame($adapter, $adapter->setOpts([CURLOPT_CONNECTTIMEOUT => 5])); + $this->assertEquals((object) ['id' => 1], $adapter->addGet('https://example.com', ['a' => 1])); + $this->assertEquals((object) ['id' => 2], $adapter->addPost('https://example.com', 'payload', true)); + $this->assertNull($adapter->start()); + } + + public function testMultiCurlAdapterRegistersCallbacks(): void + { + $success = fn () => null; + $error = fn () => null; + + $multiCurl = Mockery::mock(MultiCurl::class); + $multiCurl->shouldReceive('success')->once()->with($success)->andReturnNull(); + $multiCurl->shouldReceive('error')->once()->with($error)->andReturnNull(); + + $adapter = new MultiCurlAdapter($multiCurl); + + $this->assertSame($adapter, $adapter->success($success)); + $this->assertSame($adapter, $adapter->error($error)); + } + + public function testMultiCurlAdapterWrapsCompleteCallbackInstance(): void + { + $curl = Mockery::mock(Curl::class); + + $multiCurl = Mockery::mock(MultiCurl::class); + $multiCurl->shouldReceive('complete') + ->once() + ->andReturnUsing(function (callable $callback) use ($curl): void { + $callback($curl); + }); + + $adapter = new MultiCurlAdapter($multiCurl); + $wrapped = null; + + $this->assertSame($adapter, $adapter->complete(function (CurlAdapter $instance) use (&$wrapped): void { + $wrapped = $instance; + })); + + $this->assertInstanceOf(CurlAdapter::class, $wrapped); + } + + public function testMultiCurlAdapterSupportsDocumentedMethods(): void + { + $multiCurl = Mockery::mock(MultiCurl::class); + $multiCurl->shouldReceive('addGet')->once()->with('https://example.com', [])->andReturn((object) ['id' => 1]); + + $adapter = new MultiCurlAdapter($multiCurl); + + $this->assertTrue($adapter->supportsMethod('addGet')); + $this->assertFalse($adapter->supportsMethod('missingMethod')); + $this->assertEquals((object) ['id' => 1], $adapter->callMethod('addGet', ['https://example.com', []])); + } +} From 37fc64831f54933d26c8c6d8662b6f0eba12ee4f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:27:24 +0400 Subject: [PATCH 06/14] [#534] Document HttpClient adapter refactor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51258dfe..05211c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- Refactored `HttpClient` internals behind explicit `CurlAdapter` and `MultiCurlAdapter` wrappers while preserving the existing facade methods and keeping `php-curl-class` as the underlying transport for this phase (#534) - Refactored the Lang package to resolve adapter instances through `LangFactory` configuration and load file translations lazily on first use instead of preloading them during web boot (#533) - **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) - **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) From eb5f1b57a18ed248bda890b395436b6d3d33094a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:46:11 +0400 Subject: [PATCH 07/14] [#534] Cover HttpClient factory --- .../Factories/HttpClientFactoryTest.php | 51 +++++++++++++++++++ tests/Unit/HttpClient/HttpClientTest.php | 2 + 2 files changed, 53 insertions(+) create mode 100644 tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php diff --git a/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php new file mode 100644 index 00000000..771f6cfa --- /dev/null +++ b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php @@ -0,0 +1,51 @@ +resetHttpClientFactory(); + } + + public function testHttpClientFactoryInstance(): void + { + $this->assertInstanceOf(HttpClient::class, HttpClientFactory::get()); + } + + public function testHttpClientFactoryReturnsSameInstance(): void + { + $httpClient1 = HttpClientFactory::get(); + $httpClient2 = HttpClientFactory::get(); + + $this->assertSame($httpClient1, $httpClient2); + } + + public function testHttpClientFactoryResolveReturnsSameInstance(): void + { + $factory = Di::get(HttpClientFactory::class); + + $httpClient1 = $factory->resolve(); + $httpClient2 = $factory->resolve(); + + $this->assertSame($httpClient1, $httpClient2); + } + + private function resetHttpClientFactory(): void + { + if (!Di::isRegistered(HttpClientFactory::class)) { + Di::register(HttpClientFactory::class); + } + + $factory = Di::get(HttpClientFactory::class); + $this->setPrivateProperty($factory, 'instance', null); + } +} diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 4c2194de..869c9a3d 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -32,6 +32,8 @@ public function tearDown(): void public function testHttpClientGetSetMethod(): void { + $this->assertNull($this->httpClient->getAdapter()); + $this->assertEquals('GET', $this->httpClient->getMethod()); $this->httpClient->setMethod('POST'); From 47c91ba755b9f3ef78a1eea07a9b1ff9b649e4d2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:08:31 +0400 Subject: [PATCH 08/14] [#534] Wrap multi curl request handles --- src/HttpClient/Adapters/MultiCurlAdapter.php | 13 +++++++++++-- .../HttpClient/Adapters/MultiCurlAdapterTest.php | 11 +++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index e4be63ab..85de3367 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -64,7 +64,7 @@ public function start() */ public function addGet(string $url, array $data = []) { - return $this->client->addGet($url, $data); + return $this->wrapCurlResult($this->client->addGet($url, $data)); } /** @@ -73,7 +73,7 @@ public function addGet(string $url, array $data = []) */ public function addPost(string $url, $data = '', bool $follow_303_with_post = false) { - return $this->client->addPost($url, $data, $follow_303_with_post); + return $this->wrapCurlResult($this->client->addPost($url, $data, $follow_303_with_post)); } /** @@ -129,4 +129,13 @@ public function callMethod(string $method, array $arguments) { return $this->client->$method(...$arguments); } + + /** + * @param mixed $result + * @return mixed + */ + private function wrapCurlResult($result) + { + return $result instanceof Curl ? new CurlAdapter($result) : $result; + } } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 57509aab..7e9b0542 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -20,13 +20,16 @@ public function tearDown(): void public function testMultiCurlAdapterDelegatesRequestMethods(): void { + $getCurl = Mockery::mock(Curl::class); + $postCurl = Mockery::mock(Curl::class); + $multiCurl = Mockery::mock(MultiCurl::class); $multiCurl->shouldReceive('setHeader')->once()->with('Accept', 'application/json'); $multiCurl->shouldReceive('setHeaders')->once()->with(['X-Test' => 'yes']); $multiCurl->shouldReceive('setOpt')->once()->with(CURLOPT_TIMEOUT, 10); $multiCurl->shouldReceive('setOpts')->once()->with([CURLOPT_CONNECTTIMEOUT => 5]); - $multiCurl->shouldReceive('addGet')->once()->with('https://example.com', ['a' => 1])->andReturn((object) ['id' => 1]); - $multiCurl->shouldReceive('addPost')->once()->with('https://example.com', 'payload', true)->andReturn((object) ['id' => 2]); + $multiCurl->shouldReceive('addGet')->once()->with('https://example.com', ['a' => 1])->andReturn($getCurl); + $multiCurl->shouldReceive('addPost')->once()->with('https://example.com', 'payload', true)->andReturn($postCurl); $multiCurl->shouldReceive('start')->once()->andReturnNull(); $adapter = new MultiCurlAdapter($multiCurl); @@ -35,8 +38,8 @@ public function testMultiCurlAdapterDelegatesRequestMethods(): void $this->assertSame($adapter, $adapter->setHeaders(['X-Test' => 'yes'])); $this->assertSame($adapter, $adapter->setOpt(CURLOPT_TIMEOUT, 10)); $this->assertSame($adapter, $adapter->setOpts([CURLOPT_CONNECTTIMEOUT => 5])); - $this->assertEquals((object) ['id' => 1], $adapter->addGet('https://example.com', ['a' => 1])); - $this->assertEquals((object) ['id' => 2], $adapter->addPost('https://example.com', 'payload', true)); + $this->assertInstanceOf(CurlAdapter::class, $adapter->addGet('https://example.com', ['a' => 1])); + $this->assertInstanceOf(CurlAdapter::class, $adapter->addPost('https://example.com', 'payload', true)); $this->assertNull($adapter->start()); } From 33be623f697f6bd43a6e31d4f51d8547e6d0c8ee Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:20:21 +0400 Subject: [PATCH 09/14] [#534] Preserve zero curl info option --- src/HttpClient/Adapters/CurlAdapter.php | 2 +- tests/Unit/HttpClient/Adapters/CurlAdapterTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index bc8e9d6a..0efa7f8e 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -142,7 +142,7 @@ public function getResponse() */ public function getInfo(?int $option = null) { - return $option ? $this->client->getInfo($option) : $this->client->getInfo(); + return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); } public function getUrl(): ?string diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 9bde2d83..af0cac78 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -68,6 +68,16 @@ public function testCurlAdapterDelegatesResponseMethods(): void $this->assertSame('https://example.com', $adapter->getUrl()); } + public function testCurlAdapterPassesZeroInfoOption(): void + { + $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('getInfo')->with(0)->once()->andReturn('zero'); + + $adapter = new CurlAdapter($curl); + + $this->assertSame('zero', $adapter->getInfo(0)); + } + public function testCurlAdapterSupportsAndCallsVendorMethods(): void { $curl = Mockery::mock(Curl::class); From 120777ab5b00b94255f470bccd8b25d7190d4f5d Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:36:24 +0400 Subject: [PATCH 10/14] [#534] Wrap multi curl callbacks --- src/HttpClient/Adapters/MultiCurlAdapter.php | 8 ++++-- .../Adapters/MultiCurlAdapterTest.php | 27 ++++++++++++++----- tests/Unit/HttpClient/HttpClientTest.php | 27 ++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 85de3367..825b8512 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -38,14 +38,18 @@ public function complete(callable $callback): MultiCurlAdapterInterface public function success(callable $callback): MultiCurlAdapterInterface { - $this->client->success($callback); + $this->client->success(function (Curl $instance) use ($callback): void { + $callback(new CurlAdapter($instance)); + }); return $this; } public function error(callable $callback): MultiCurlAdapterInterface { - $this->client->error($callback); + $this->client->error(function (Curl $instance) use ($callback): void { + $callback(new CurlAdapter($instance)); + }); return $this; } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 7e9b0542..f950bb45 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -45,17 +45,32 @@ public function testMultiCurlAdapterDelegatesRequestMethods(): void public function testMultiCurlAdapterRegistersCallbacks(): void { - $success = fn () => null; - $error = fn () => null; + $curl = Mockery::mock(Curl::class); $multiCurl = Mockery::mock(MultiCurl::class); - $multiCurl->shouldReceive('success')->once()->with($success)->andReturnNull(); - $multiCurl->shouldReceive('error')->once()->with($error)->andReturnNull(); + $multiCurl->shouldReceive('success') + ->once() + ->andReturnUsing(function (callable $callback) use ($curl): void { + $callback($curl); + }); + $multiCurl->shouldReceive('error') + ->once() + ->andReturnUsing(function (callable $callback) use ($curl): void { + $callback($curl); + }); $adapter = new MultiCurlAdapter($multiCurl); + $successWrapped = null; + $errorWrapped = null; - $this->assertSame($adapter, $adapter->success($success)); - $this->assertSame($adapter, $adapter->error($error)); + $this->assertSame($adapter, $adapter->success(function (CurlAdapter $instance) use (&$successWrapped): void { + $successWrapped = $instance; + })); + $this->assertSame($adapter, $adapter->error(function (CurlAdapter $instance) use (&$errorWrapped): void { + $errorWrapped = $instance; + })); + $this->assertInstanceOf(CurlAdapter::class, $successWrapped); + $this->assertInstanceOf(CurlAdapter::class, $errorWrapped); } public function testMultiCurlAdapterWrapsCompleteCallbackInstance(): void diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 869c9a3d..9bd8ab2e 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -233,18 +233,37 @@ public function testHttpClientMultiRequestAggregatesErrors(): void public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void { - $success = fn () => null; - $error = fn () => null; + $curl = Mockery::mock(Curl::class); + $successWrapped = null; + $errorWrapped = null; + $success = function (CurlAdapter $instance) use (&$successWrapped): void { + $successWrapped = $instance; + }; + $error = function (CurlAdapter $instance) use (&$errorWrapped): void { + $errorWrapped = $instance; + }; $multi = Mockery::mock(MultiCurl::class); - $multi->shouldReceive('success')->once()->with($success)->andReturnSelf(); - $multi->shouldReceive('error')->once()->with($error)->andReturnSelf(); + $multi->shouldReceive('success') + ->once() + ->andReturnUsing(function (callable $callback) use ($curl): void { + $callback($curl); + }); + $multi->shouldReceive('error') + ->once() + ->andReturnUsing(function (callable $callback) use ($curl): void { + $callback($curl); + }); $this->httpClient->createAsyncMultiRequest($success, $error, $multi); $this->assertTrue($this->httpClient->isMultiRequest()); $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); + + $this->assertInstanceOf(CurlAdapter::class, $successWrapped); + + $this->assertInstanceOf(CurlAdapter::class, $errorWrapped); } public function testHttpClientInfoAndUrl(): void From 48db62c8fce450701ca9c8e28eed031afbda0a09 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:58:41 +0400 Subject: [PATCH 11/14] [#534] Correct HttpClient request assertions --- src/HttpClient/HttpClient.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index 69da622d..d635f067 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -176,7 +176,7 @@ public function getData() /** * Checks if the request is multi cURL * @phpstan-assert-if-true MultiCurlAdapterInterface $this->client - * @phpstan-assert-if-false CurlAdapterInterface $this->client + * @phpstan-assert-if-false CurlAdapterInterface|null $this->client */ public function isMultiRequest(): bool { @@ -270,9 +270,12 @@ public function getResponseBody() /** * Gets the entire response * @return array + * @throws BaseException */ public function getResponse(): array { + $this->ensureRequestCreated(); + if ($this->isMultiRequest()) { return $this->response; } @@ -283,9 +286,12 @@ public function getResponse(): array /** * Returns the errors * @return array + * @throws BaseException */ public function getErrors(): array { + $this->ensureRequestCreated(); + if ($this->isMultiRequest()) { return $this->errors; } From 4d583a96c3c53b3892f0eb8ce2e4e0763f813204 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:06:59 +0400 Subject: [PATCH 12/14] [#534] Preserve empty HttpClient results --- src/HttpClient/HttpClient.php | 10 ++++++---- tests/Unit/HttpClient/HttpClientTest.php | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index d635f067..e0f5530d 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -270,11 +270,12 @@ public function getResponseBody() /** * Gets the entire response * @return array - * @throws BaseException */ public function getResponse(): array { - $this->ensureRequestCreated(); + if ($this->client === null) { + return []; + } if ($this->isMultiRequest()) { return $this->response; @@ -286,11 +287,12 @@ public function getResponse(): array /** * Returns the errors * @return array - * @throws BaseException */ public function getErrors(): array { - $this->ensureRequestCreated(); + if ($this->client === null) { + return []; + } if ($this->isMultiRequest()) { return $this->errors; diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 9bd8ab2e..892dd88b 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -86,6 +86,13 @@ public function testHttpClientRequestNotCreated(): void $this->httpClient->start(); } + public function testHttpClientReturnsEmptyResponseAndErrorsBeforeRequestCreated(): void + { + $this->assertSame([], $this->httpClient->getResponse()); + + $this->assertSame([], $this->httpClient->getErrors()); + } + public function testHttpClientEnsureSingleRequestThrowsOnMulti(): void { $multi = Mockery::mock(MultiCurl::class); From da74cce0903a5a9fc49fb42811f3757ec2910bf2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:11:12 +0400 Subject: [PATCH 13/14] [#534] Guard single HttpClient requests --- src/HttpClient/HttpClient.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index e0f5530d..049e0843 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -403,6 +403,8 @@ private function formatHeaders(iterable $headers): array */ private function ensureSingleRequest(): void { + $this->ensureRequestCreated(); + if ($this->isMultiRequest()) { throw HttpClientException::methodNotSupported(__METHOD__, MultiCurlAdapter::class); } From e99f21f6b580157923a872ad652c6ab04dbf82a1 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:22:00 +0400 Subject: [PATCH 14/14] [#534] Preserve zero HttpClient info option --- src/HttpClient/HttpClient.php | 2 +- tests/Unit/HttpClient/HttpClientTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index 049e0843..84d1b626 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -310,7 +310,7 @@ public function info(?int $option = null) { $this->ensureSingleRequest(); - return $option ? $this->client->getInfo($option) : $this->client->getInfo(); + return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); } /** diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 892dd88b..abe3e22c 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -297,4 +297,15 @@ public function testHttpClientInfoAndUrl(): void $this->assertEquals('https://example.com', $this->httpClient->url()); } + + public function testHttpClientPassesZeroInfoOption(): void + { + $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->once(); + $curl->shouldReceive('getInfo')->with(0)->once()->andReturn('zero'); + + $this->httpClient->createRequest('https://example.com', $curl); + + $this->assertSame('zero', $this->httpClient->info(0)); + } }