From 11730106ebb983d87608faf44319fc51fc655f85 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:52:07 +0400 Subject: [PATCH 01/13] [#567] Add native MultiCurlAdapter queue scaffold --- src/HttpClient/Adapters/CurlAdapter.php | 5 ++ src/HttpClient/Adapters/MultiCurlAdapter.php | 87 ++++++++++++++++++- .../HttpClient/Adapters/CurlAdapterTest.php | 8 ++ .../Adapters/MultiCurlAdapterTest.php | 18 ++++ 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 13173e19..4e5ae544 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -329,6 +329,11 @@ public function getUrl(): ?string return $this->url ?? $this->client?->getUrl(); } + public function getHandle(): CurlHandle + { + return $this->handle; + } + public function supportsMethod(string $method): bool { return in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 8ace7e4c..d3300bda 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -11,6 +11,7 @@ namespace Quantum\HttpClient\Adapters; use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; +use CurlMultiHandle; use Curl\MultiCurl; use Curl\Curl; @@ -20,15 +21,32 @@ */ class MultiCurlAdapter implements MultiCurlAdapterInterface { - private MultiCurl $client; + private ?MultiCurl $client; + + private CurlMultiHandle $handle; + + /** + * @var array + */ + private array $queue = []; public function __construct(?MultiCurl $client = null) { - $this->client = $client ?? new MultiCurl(); + $this->client = $client; + $this->handle = curl_multi_init(); + } + + public function __destruct() + { + curl_multi_close($this->handle); } public function complete(callable $callback): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->complete(function (Curl $instance) use ($callback): void { $callback(new CurlAdapter($instance)); }); @@ -38,6 +56,10 @@ public function complete(callable $callback): MultiCurlAdapterInterface public function success(callable $callback): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->success(function (Curl $instance) use ($callback): void { $callback(new CurlAdapter($instance)); }); @@ -47,6 +69,10 @@ public function success(callable $callback): MultiCurlAdapterInterface public function error(callable $callback): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->error(function (Curl $instance) use ($callback): void { $callback(new CurlAdapter($instance)); }); @@ -56,6 +82,10 @@ public function error(callable $callback): MultiCurlAdapterInterface public function start(): void { + if ($this->client === null) { + return; + } + $this->client->start(); } @@ -65,6 +95,15 @@ public function start(): void */ public function addGet(string $url, array $data = []) { + if ($this->client === null) { + $adapter = new CurlAdapter(); + $adapter->setUrl($url); + + $this->queue[$adapter->getId()] = $adapter; + + return $adapter; + } + return $this->wrapCurlResult($this->client->addGet($url, $data)); } @@ -74,6 +113,15 @@ public function addGet(string $url, array $data = []) */ public function addPost(string $url, $data = '', bool $follow_303_with_post = false) { + if ($this->client === null) { + $adapter = new CurlAdapter(); + $adapter->setUrl($url); + + $this->queue[$adapter->getId()] = $adapter; + + return $adapter; + } + return $this->wrapCurlResult($this->client->addPost($url, $data, $follow_303_with_post)); } @@ -82,6 +130,10 @@ public function addPost(string $url, $data = '', bool $follow_303_with_post = fa */ public function setHeader(string $key, $value): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->setHeader($key, $value); return $this; @@ -92,6 +144,10 @@ public function setHeader(string $key, $value): MultiCurlAdapterInterface */ public function setHeaders(array $headers): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->setHeaders($headers); return $this; @@ -102,6 +158,10 @@ public function setHeaders(array $headers): MultiCurlAdapterInterface */ public function setOpt(int $option, $value): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->setOpt($option, $value); return $this; @@ -112,6 +172,10 @@ public function setOpt(int $option, $value): MultiCurlAdapterInterface */ public function setOpts(array $options): MultiCurlAdapterInterface { + if ($this->client === null) { + return $this; + } + $this->client->setOpts($options); return $this; @@ -119,7 +183,16 @@ public function setOpts(array $options): MultiCurlAdapterInterface public function supportsMethod(string $method): bool { - return method_exists($this->client, $method); + return in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true) + || ($this->client !== null && method_exists($this->client, $method)); + } + + /** + * @return array + */ + public function getQueuedRequests(): array + { + return $this->queue; } /** @@ -128,6 +201,14 @@ public function supportsMethod(string $method): bool */ public function callMethod(string $method, array $arguments) { + if (in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true)) { + return $this->$method(...$arguments); + } + + if ($this->client === null) { + return null; + } + return $this->client->$method(...$arguments); } diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 17f50401..65d19e39 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -6,6 +6,7 @@ use Quantum\HttpClient\ResponseHeaders; use Quantum\Tests\Unit\AppTestCase; use Curl\CaseInsensitiveArray; +use CurlHandle; use Curl\Curl; use Mockery; @@ -65,6 +66,13 @@ public function testCurlAdapterGeneratesNativeRequestIds(): void $this->assertNotSame($adapter1->getId(), $adapter2->getId()); } + public function testCurlAdapterExposesNativeHandle(): void + { + $adapter = new CurlAdapter(); + + $this->assertInstanceOf(CurlHandle::class, $adapter->getHandle()); + } + public function testCurlAdapterParsesNativeResponseHeaders(): void { $adapter = new CurlAdapter(); diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 64793e4c..f0a1e0ac 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -18,6 +18,24 @@ public function tearDown(): void parent::tearDown(); } + public function testMultiCurlAdapterQueuesNativeRequests(): void + { + $adapter = new MultiCurlAdapter(); + + $getRequest = $adapter->addGet('https://example.com', ['a' => 1]); + $postRequest = $adapter->addPost('https://example.org', 'payload', true); + + $this->assertInstanceOf(CurlAdapter::class, $getRequest); + $this->assertInstanceOf(CurlAdapter::class, $postRequest); + $this->assertNotSame($getRequest->getId(), $postRequest->getId()); + $this->assertSame('https://example.com', $getRequest->getUrl()); + $this->assertSame('https://example.org', $postRequest->getUrl()); + $this->assertSame([ + $getRequest->getId() => $getRequest, + $postRequest->getId() => $postRequest, + ], $adapter->getQueuedRequests()); + } + public function testMultiCurlAdapterDelegatesRequestMethods(): void { $getCurl = Mockery::mock(Curl::class); From 230ee69675a456b6fbd012a2411b978ea7c95efb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:57:59 +0400 Subject: [PATCH 02/13] [#567] Configure native multi request queue entries --- src/HttpClient/Adapters/MultiCurlAdapter.php | 39 +++++++++++++++---- .../Adapters/MultiCurlAdapterTest.php | 4 +- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index d3300bda..1a20713f 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -96,10 +96,9 @@ public function start(): void public function addGet(string $url, array $data = []) { if ($this->client === null) { - $adapter = new CurlAdapter(); - $adapter->setUrl($url); - - $this->queue[$adapter->getId()] = $adapter; + $adapter = $this->queueRequest($this->buildUrl($url, $data)); + $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'GET'); + $adapter->setOpt(CURLOPT_HTTPGET, true); return $adapter; } @@ -114,10 +113,14 @@ public function addGet(string $url, array $data = []) public function addPost(string $url, $data = '', bool $follow_303_with_post = false) { if ($this->client === null) { - $adapter = new CurlAdapter(); - $adapter->setUrl($url); + $adapter = $this->queueRequest($url); + + if ($follow_303_with_post) { + $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'POST'); + } - $this->queue[$adapter->getId()] = $adapter; + $adapter->setOpt(CURLOPT_POST, true); + $adapter->setOpt(CURLOPT_POSTFIELDS, $adapter->buildPostData($data)); return $adapter; } @@ -220,4 +223,26 @@ private function wrapCurlResult($result) { return $result instanceof Curl ? new CurlAdapter($result) : $result; } + + private function queueRequest(string $url): CurlAdapter + { + $adapter = new CurlAdapter(); + $adapter->setUrl($url); + + $this->queue[$adapter->getId()] = $adapter; + + return $adapter; + } + + /** + * @param array $data + */ + private function buildUrl(string $url, array $data): string + { + if ($data === []) { + return $url; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($data); + } } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index f0a1e0ac..5a5d55d9 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -22,13 +22,13 @@ public function testMultiCurlAdapterQueuesNativeRequests(): void { $adapter = new MultiCurlAdapter(); - $getRequest = $adapter->addGet('https://example.com', ['a' => 1]); + $getRequest = $adapter->addGet('https://example.com?existing=yes', ['a' => 1]); $postRequest = $adapter->addPost('https://example.org', 'payload', true); $this->assertInstanceOf(CurlAdapter::class, $getRequest); $this->assertInstanceOf(CurlAdapter::class, $postRequest); $this->assertNotSame($getRequest->getId(), $postRequest->getId()); - $this->assertSame('https://example.com', $getRequest->getUrl()); + $this->assertSame('https://example.com?existing=yes&a=1', $getRequest->getUrl()); $this->assertSame('https://example.org', $postRequest->getUrl()); $this->assertSame([ $getRequest->getId() => $getRequest, From 1baf256eaa182750785838bf512ad633fcbed2b5 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:17:38 +0400 Subject: [PATCH 03/13] [#567] Execute native multi curl requests --- src/HttpClient/Adapters/CurlAdapter.php | 9 +++ src/HttpClient/Adapters/MultiCurlAdapter.php | 68 +++++++++++++++++++ .../Adapters/MultiCurlAdapterTest.php | 40 +++++++++++ tests/_root/app.conf | 2 +- 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 4e5ae544..93ac3728 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -247,6 +247,15 @@ public function start(): void $this->resetResponseState(); $rawResponse = curl_exec($this->handle); + + $this->finalizeResponse($rawResponse); + } + + /** + * @param mixed $rawResponse + */ + public function finalizeResponse($rawResponse): void + { $curlErrorCode = curl_errno($this->handle); $curlErrorMessage = curl_error($this->handle); $httpStatusCode = (int) $this->getInfo(CURLINFO_HTTP_CODE); diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 1a20713f..ab2c19ed 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -12,6 +12,7 @@ use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; use CurlMultiHandle; +use CurlHandle; use Curl\MultiCurl; use Curl\Curl; @@ -30,6 +31,21 @@ class MultiCurlAdapter implements MultiCurlAdapterInterface */ private array $queue = []; + /** + * @var callable|null + */ + private $completeCallback; + + /** + * @var callable|null + */ + private $successCallback; + + /** + * @var callable|null + */ + private $errorCallback; + public function __construct(?MultiCurl $client = null) { $this->client = $client; @@ -44,6 +60,7 @@ public function __destruct() public function complete(callable $callback): MultiCurlAdapterInterface { if ($this->client === null) { + $this->completeCallback = $callback; return $this; } @@ -57,6 +74,7 @@ public function complete(callable $callback): MultiCurlAdapterInterface public function success(callable $callback): MultiCurlAdapterInterface { if ($this->client === null) { + $this->successCallback = $callback; return $this; } @@ -70,6 +88,7 @@ public function success(callable $callback): MultiCurlAdapterInterface public function error(callable $callback): MultiCurlAdapterInterface { if ($this->client === null) { + $this->errorCallback = $callback; return $this; } @@ -83,6 +102,7 @@ public function error(callable $callback): MultiCurlAdapterInterface public function start(): void { if ($this->client === null) { + $this->startNativeRequests(); return; } @@ -245,4 +265,52 @@ private function buildUrl(string $url, array $data): string return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($data); } + + private function startNativeRequests(): void + { + foreach ($this->queue as $adapter) { + curl_multi_add_handle($this->handle, $adapter->getHandle()); + } + + do { + do { + $status = curl_multi_exec($this->handle, $running); + } while ($status === CURLM_CALL_MULTI_PERFORM); + + while ($info = curl_multi_info_read($this->handle)) { + $this->completeNativeRequest($info['handle']); + } + + if ($running > 0) { + curl_multi_select($this->handle); + } + } while ($running > 0); + } + + private function completeNativeRequest(CurlHandle $handle): void + { + foreach ($this->queue as $adapter) { + if ($adapter->getHandle() !== $handle) { + continue; + } + + $adapter->finalizeResponse(curl_multi_getcontent($handle)); + + if ($this->completeCallback !== null) { + ($this->completeCallback)($adapter); + } + + if ($adapter->isError()) { + if ($this->errorCallback !== null) { + ($this->errorCallback)($adapter); + } + } elseif ($this->successCallback !== null) { + ($this->successCallback)($adapter); + } + + curl_multi_remove_handle($this->handle, $handle); + + return; + } + } } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 5a5d55d9..4e82f63e 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -36,6 +36,41 @@ public function testMultiCurlAdapterQueuesNativeRequests(): void ], $adapter->getQueuedRequests()); } + public function testMultiCurlAdapterExecutesNativeRequestsAndDispatchesCallbacks(): void + { + $adapter = new MultiCurlAdapter(); + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + $completeRequests = []; + $successRequests = []; + $errorRequests = []; + + $firstRequest = $adapter->addGet($this->fileUrl($fixturePath)); + $secondRequest = $adapter->addGet($this->fileUrl($fixturePath)); + + $adapter + ->complete(function (CurlAdapter $instance) use (&$completeRequests): void { + $completeRequests[$instance->getId()] = $instance; + }) + ->success(function (CurlAdapter $instance) use (&$successRequests): void { + $successRequests[$instance->getId()] = $instance; + }) + ->error(function (CurlAdapter $instance) use (&$errorRequests): void { + $errorRequests[$instance->getId()] = $instance; + }) + ->start(); + + $this->assertSame(file_get_contents($fixturePath), $firstRequest->getResponse()); + $this->assertSame(file_get_contents($fixturePath), $secondRequest->getResponse()); + $this->assertFalse($firstRequest->isError()); + $this->assertFalse($secondRequest->isError()); + $this->assertSame([ + $firstRequest->getId() => $firstRequest, + $secondRequest->getId() => $secondRequest, + ], $completeRequests); + $this->assertSame($completeRequests, $successRequests); + $this->assertSame([], $errorRequests); + } + public function testMultiCurlAdapterDelegatesRequestMethods(): void { $getCurl = Mockery::mock(Curl::class); @@ -124,4 +159,9 @@ public function testMultiCurlAdapterSupportsDocumentedMethods(): void $this->assertFalse($adapter->supportsMethod('missingMethod')); $this->assertEquals((object) ['id' => 1], $adapter->callMethod('addGet', ['https://example.com', []])); } + + private function fileUrl(string $path): string + { + return 'file:///' . str_replace('\\', '/', $path); + } } diff --git a/tests/_root/app.conf b/tests/_root/app.conf index b9359e04..435239b2 100644 --- a/tests/_root/app.conf +++ b/tests/_root/app.conf @@ -2,7 +2,7 @@ return [ 'name' => 'Quantum PHP Framework', - 'version' => '2.9.5', + 'version' => '3.0.0', 'key' => env('APP_KEY'), 'base_url' => 'http://localhost', 'debug' => true, From 2164bb590bb5d42f5f1a69595ea37dd3ff13f36a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:30:16 +0400 Subject: [PATCH 04/13] [#567] Propagate native multi request configuration --- src/HttpClient/Adapters/MultiCurlAdapter.php | 48 +++++++++++++++++++ .../Adapters/MultiCurlAdapterTest.php | 43 +++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index ab2c19ed..28139fcd 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -31,6 +31,16 @@ class MultiCurlAdapter implements MultiCurlAdapterInterface */ private array $queue = []; + /** + * @var array + */ + private array $headers = []; + + /** + * @var array + */ + private array $options = []; + /** * @var callable|null */ @@ -154,6 +164,8 @@ public function addPost(string $url, $data = '', bool $follow_303_with_post = fa public function setHeader(string $key, $value): MultiCurlAdapterInterface { if ($this->client === null) { + $this->applyHeader($key, $value); + return $this; } @@ -168,6 +180,10 @@ public function setHeader(string $key, $value): MultiCurlAdapterInterface public function setHeaders(array $headers): MultiCurlAdapterInterface { if ($this->client === null) { + foreach ($headers as $key => $value) { + $this->applyHeader(trim((string) $key), trim((string) $value)); + } + return $this; } @@ -182,6 +198,8 @@ public function setHeaders(array $headers): MultiCurlAdapterInterface public function setOpt(int $option, $value): MultiCurlAdapterInterface { if ($this->client === null) { + $this->applyOption($option, $value); + return $this; } @@ -196,6 +214,10 @@ public function setOpt(int $option, $value): MultiCurlAdapterInterface public function setOpts(array $options): MultiCurlAdapterInterface { if ($this->client === null) { + foreach ($options as $option => $value) { + $this->applyOption($option, $value); + } + return $this; } @@ -248,12 +270,38 @@ private function queueRequest(string $url): CurlAdapter { $adapter = new CurlAdapter(); $adapter->setUrl($url); + $adapter->setHeaders($this->headers); + $adapter->setOpts($this->options); $this->queue[$adapter->getId()] = $adapter; return $adapter; } + /** + * @param mixed $value + */ + private function applyHeader(string $key, $value): void + { + $this->headers[$key] = $value; + + foreach ($this->queue as $adapter) { + $adapter->setHeader($key, $value); + } + } + + /** + * @param mixed $value + */ + private function applyOption(int $option, $value): void + { + $this->options[$option] = $value; + + foreach ($this->queue as $adapter) { + $adapter->setOpt($option, $value); + } + } + /** * @param array $data */ diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 4e82f63e..29434123 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -71,6 +71,49 @@ public function testMultiCurlAdapterExecutesNativeRequestsAndDispatchesCallbacks $this->assertSame([], $errorRequests); } + public function testMultiCurlAdapterAppliesNativeHeadersAndOptionsToFutureQueuedRequests(): void + { + $adapter = new MultiCurlAdapter(); + + $adapter + ->setHeader('Accept', 'application/json') + ->setHeaders(['X-Test' => 'yes']) + ->setOpt(CURLOPT_TIMEOUT, 10) + ->setOpts([CURLOPT_CONNECTTIMEOUT => 5]); + + $request = $adapter->addGet('https://example.com'); + + $this->assertSame([ + 'Accept' => 'application/json', + 'X-Test' => 'yes', + ], $this->getPrivateProperty($request, 'headers')); + $this->assertSame([ + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + ], $this->getPrivateProperty($adapter, 'options')); + } + + public function testMultiCurlAdapterAppliesNativeHeadersAndOptionsToExistingQueuedRequests(): void + { + $adapter = new MultiCurlAdapter(); + $request = $adapter->addGet('https://example.com'); + + $adapter + ->setHeader('Accept', 'application/json') + ->setHeaders(['X-Test' => 'yes']) + ->setOpt(CURLOPT_TIMEOUT, 10) + ->setOpts([CURLOPT_CONNECTTIMEOUT => 5]); + + $this->assertSame([ + 'Accept' => 'application/json', + 'X-Test' => 'yes', + ], $this->getPrivateProperty($request, 'headers')); + $this->assertSame([ + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + ], $this->getPrivateProperty($adapter, 'options')); + } + public function testMultiCurlAdapterDelegatesRequestMethods(): void { $getCurl = Mockery::mock(Curl::class); From 9bde2b85b86cfac2bce24828af41fd80f02077fe Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:46:36 +0400 Subject: [PATCH 05/13] [#567] Cover native multi request facade usage --- .../Factories/HttpClientFactoryTest.php | 16 +++++++ .../Helpers/HttpClientHelperFunctionsTest.php | 16 +++++++ tests/Unit/HttpClient/HttpClientTest.php | 46 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php index 933b241b..f5d5defd 100644 --- a/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php +++ b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php @@ -31,6 +31,17 @@ public function testHttpClientFactoryCreatesMultiRequest(): void $this->assertNotSame($httpClient1, $httpClient2); } + public function testHttpClientFactoryCreatesExecutableNativeMultiRequest(): void + { + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + + $httpClient = HttpClientFactory::createMultiRequest() + ->addGet($this->fileUrl($fixturePath)) + ->start(); + + $this->assertSame(file_get_contents($fixturePath), reset($httpClient->getResponse())['body']); + } + public function testHttpClientFactoryCreatesAsyncMultiRequest(): void { $success = static function (): void { @@ -47,4 +58,9 @@ public function testHttpClientFactoryCreatesAsyncMultiRequest(): void $this->assertInstanceOf(MultiCurlAdapter::class, $httpClient1->getAdapter()); $this->assertNotSame($httpClient1, $httpClient2); } + + private function fileUrl(string $path): string + { + return 'file:///' . str_replace('\\', '/', $path); + } } diff --git a/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php b/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php index b4663273..0226e562 100644 --- a/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php +++ b/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php @@ -30,6 +30,17 @@ public function testHttpMultiRequestHelperCreatesMultiRequest(): void $this->assertNotSame($httpClient1, $httpClient2); } + public function testHttpMultiRequestHelperCreatesExecutableNativeMultiRequest(): void + { + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + + $httpClient = httpMultiRequest() + ->addGet($this->fileUrl($fixturePath)) + ->start(); + + $this->assertSame(file_get_contents($fixturePath), reset($httpClient->getResponse())['body']); + } + public function testHttpAsyncMultiRequestHelperCreatesAsyncMultiRequest(): void { $success = static function (): void { @@ -46,4 +57,9 @@ public function testHttpAsyncMultiRequestHelperCreatesAsyncMultiRequest(): void $this->assertInstanceOf(MultiCurlAdapter::class, $httpClient1->getAdapter()); $this->assertNotSame($httpClient1, $httpClient2); } + + private function fileUrl(string $path): string + { + return 'file:///' . str_replace('\\', '/', $path); + } } diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 2589d2f6..e47e0e69 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -219,6 +219,28 @@ public function testHttpClientMultiRequestResponseStructure(): void $this->assertArrayHasKey('body', $response[0]); } + public function testHttpClientNativeMultiRequestResponseFlow(): void + { + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + + $this->httpClient + ->createMultiRequest() + ->addGet($this->fileUrl($fixturePath)) + ->addGet($this->fileUrl($fixturePath)) + ->start(); + + $response = $this->httpClient->getResponse(); + + $this->assertCount(2, $response); + $this->assertSame([], $this->httpClient->getErrors()); + + foreach ($response as $item) { + $this->assertSame([], $item['headers']); + $this->assertSame([], $item['cookies']); + $this->assertSame(file_get_contents($fixturePath), $item['body']); + } + } + public function testHttpClientMultiRequestAggregatesErrors(): void { $multi = Mockery::mock(MultiCurl::class); @@ -285,6 +307,30 @@ public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void $this->assertInstanceOf(CurlAdapter::class, $errorWrapped); } + public function testHttpClientNativeAsyncMultiRequestRegistersCallbacks(): void + { + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + $successWrapped = null; + $errorWrapped = null; + $success = function (CurlAdapter $instance) use (&$successWrapped): void { + $successWrapped = $instance; + }; + $error = function (CurlAdapter $instance) use (&$errorWrapped): void { + $errorWrapped = $instance; + }; + + $this->httpClient + ->createAsyncMultiRequest($success, $error) + ->addGet($this->fileUrl($fixturePath)) + ->start(); + + $this->assertTrue($this->httpClient->isMultiRequest()); + $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); + $this->assertInstanceOf(CurlAdapter::class, $successWrapped); + $this->assertNull($errorWrapped); + $this->assertSame(file_get_contents($fixturePath), $successWrapped->getResponse()); + } + public function testHttpClientInfoAndUrl(): void { $curl = Mockery::mock(Curl::class); From 886e9a6f78858a6a5442910078b5255566e3506a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:19:04 +0400 Subject: [PATCH 06/13] [#567] Remove php-curl-class from HttpClient --- CHANGELOG.md | 3 +- composer.json | 1 - src/HttpClient/Adapters/CurlAdapter.php | 51 +---- src/HttpClient/Adapters/MultiCurlAdapter.php | 127 +++-------- src/HttpClient/HttpClient.php | 68 +++--- src/HttpClient/ResponseHeaders.php | 29 ++- .../HttpClient/Adapters/CurlAdapterTest.php | 74 ------- .../Adapters/MultiCurlAdapterTest.php | 86 +------- tests/Unit/HttpClient/HttpClientTest.php | 208 ++++-------------- 9 files changed, 128 insertions(+), 519 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b8c46a..a9142e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ The format is based on Keep a Changelog. ### 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) -- Replaced the single-request `HttpClient` `CurlAdapter` execution path with native PHP cURL while keeping the multi-curl adapter and `php-curl-class` dependency in place until the multi-request migration is complete (#566) +- Replaced the single-request `HttpClient` `CurlAdapter` execution path with native PHP cURL (#566) +- Replaced the multi-request `HttpClient` `MultiCurlAdapter` execution path with native PHP multi-curl and removed the `php-curl-class` dependency while preserving facade, factory, helper, callback, and response aggregation behavior (#567) - 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) diff --git a/composer.json b/composer.json index 4a848833..8cd0c3b5 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,6 @@ "dflydev/dot-access-data": "^3.0", "php-debugbar/php-debugbar": "^2.2", "phpmailer/phpmailer": "^7.1", - "php-curl-class/php-curl-class": "^13.0", "psr/log": "^2.0", "rakibtg/sleekdb": "^2.13", "swagger-api/swagger-ui": "^5.32", diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 93ac3728..efe878a2 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -15,7 +15,6 @@ use JsonSerializable; use RuntimeException; use CurlHandle; -use Curl\Curl; use CURLFile; /** @@ -26,8 +25,6 @@ class CurlAdapter implements CurlAdapterInterface { private static int $lastId = 0; - private ?Curl $client; - private CurlHandle $handle; private int $id; @@ -59,12 +56,8 @@ class CurlAdapter implements CurlAdapterInterface private ?string $errorMessage = null; - /** - * The injected vendor client is a temporary bridge for MultiCurlAdapter until #567. - */ - public function __construct(?Curl $client = null) + public function __construct() { - $this->client = $client; $this->id = self::$lastId++; $handle = curl_init(); @@ -102,7 +95,6 @@ public function setUrl(string $url): CurlAdapterInterface { $this->url = $url; $this->applyOption(CURLOPT_URL, $url); - $this->client?->setUrl($url); return $this; } @@ -113,7 +105,6 @@ public function setUrl(string $url): CurlAdapterInterface public function setOpt(int $option, $value): CurlAdapterInterface { $this->applyOption($option, $value); - $this->client?->setOpt($option, $value); return $this; } @@ -137,7 +128,6 @@ public function setHeader(string $key, $value): CurlAdapterInterface { $this->headers[$key] = $value; $this->applyHeaders(); - $this->client?->setHeader($key, $value); return $this; } @@ -152,7 +142,6 @@ public function setHeaders(array $headers): CurlAdapterInterface } $this->applyHeaders(); - $this->client?->setHeaders($headers); return $this; } @@ -163,10 +152,6 @@ public function setHeaders(array $headers): CurlAdapterInterface */ public function buildPostData($data) { - if ($this->client !== null) { - return $this->client->buildPostData($data); - } - if ( $this->hasJsonContentType() && ( @@ -239,11 +224,6 @@ private function hasCurlFile(array $data): bool public function start(): void { - if ($this->client !== null) { - $this->client->exec(); - return; - } - $this->resetResponseState(); $rawResponse = curl_exec($this->handle); @@ -279,22 +259,22 @@ public function finalizeResponse($rawResponse): void */ public function getId() { - return $this->client !== null ? $this->client->getId() : $this->id; + return $this->id; } public function isError(): bool { - return $this->client !== null ? $this->client->isError() : $this->error; + return $this->error; } public function getErrorCode(): int { - return $this->client !== null ? $this->client->getErrorCode() : $this->errorCode; + return $this->errorCode; } public function getErrorMessage(): ?string { - return $this->client !== null ? $this->client->getErrorMessage() : $this->errorMessage; + return $this->errorMessage; } /** @@ -302,7 +282,7 @@ public function getErrorMessage(): ?string */ public function getResponseHeaders(): iterable { - return $this->client !== null ? $this->client->getResponseHeaders() : $this->responseHeaders; + return $this->responseHeaders; } /** @@ -310,7 +290,7 @@ public function getResponseHeaders(): iterable */ public function getResponseCookies() { - return $this->client !== null ? $this->client->getResponseCookies() : $this->responseCookies; + return $this->responseCookies; } /** @@ -318,7 +298,7 @@ public function getResponseCookies() */ public function getResponse() { - return $this->client !== null ? $this->client->getResponse() : $this->response; + return $this->response; } /** @@ -326,16 +306,12 @@ public function getResponse() */ public function getInfo(?int $option = null) { - if ($this->client !== null) { - return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); - } - return $option !== null ? curl_getinfo($this->handle, $option) : curl_getinfo($this->handle); } public function getUrl(): ?string { - return $this->url ?? $this->client?->getUrl(); + return $this->url; } public function getHandle(): CurlHandle @@ -345,8 +321,7 @@ public function getHandle(): CurlHandle public function supportsMethod(string $method): bool { - return in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true) - || ($this->client !== null && method_exists($this->client, $method)); + return in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true); } /** @@ -359,11 +334,7 @@ public function callMethod(string $method, array $arguments) return $this->$method(...$arguments); } - if ($this->client === null) { - return null; - } - - return $this->client->$method(...$arguments); + return null; } private function resetResponseState(): void diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 28139fcd..5d9d3474 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -13,8 +13,6 @@ use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; use CurlMultiHandle; use CurlHandle; -use Curl\MultiCurl; -use Curl\Curl; /** * Class MultiCurlAdapter @@ -22,8 +20,6 @@ */ class MultiCurlAdapter implements MultiCurlAdapterInterface { - private ?MultiCurl $client; - private CurlMultiHandle $handle; /** @@ -56,9 +52,8 @@ class MultiCurlAdapter implements MultiCurlAdapterInterface */ private $errorCallback; - public function __construct(?MultiCurl $client = null) + public function __construct() { - $this->client = $client; $this->handle = curl_multi_init(); } @@ -69,54 +64,28 @@ public function __destruct() public function complete(callable $callback): MultiCurlAdapterInterface { - if ($this->client === null) { - $this->completeCallback = $callback; - return $this; - } - - $this->client->complete(function (Curl $instance) use ($callback): void { - $callback(new CurlAdapter($instance)); - }); + $this->completeCallback = $callback; return $this; } public function success(callable $callback): MultiCurlAdapterInterface { - if ($this->client === null) { - $this->successCallback = $callback; - return $this; - } - - $this->client->success(function (Curl $instance) use ($callback): void { - $callback(new CurlAdapter($instance)); - }); + $this->successCallback = $callback; return $this; } public function error(callable $callback): MultiCurlAdapterInterface { - if ($this->client === null) { - $this->errorCallback = $callback; - return $this; - } - - $this->client->error(function (Curl $instance) use ($callback): void { - $callback(new CurlAdapter($instance)); - }); + $this->errorCallback = $callback; return $this; } public function start(): void { - if ($this->client === null) { - $this->startNativeRequests(); - return; - } - - $this->client->start(); + $this->startNativeRequests(); } /** @@ -125,15 +94,11 @@ public function start(): void */ public function addGet(string $url, array $data = []) { - if ($this->client === null) { - $adapter = $this->queueRequest($this->buildUrl($url, $data)); - $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'GET'); - $adapter->setOpt(CURLOPT_HTTPGET, true); + $adapter = $this->queueRequest($this->buildUrl($url, $data)); + $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'GET'); + $adapter->setOpt(CURLOPT_HTTPGET, true); - return $adapter; - } - - return $this->wrapCurlResult($this->client->addGet($url, $data)); + return $adapter; } /** @@ -142,20 +107,16 @@ public function addGet(string $url, array $data = []) */ public function addPost(string $url, $data = '', bool $follow_303_with_post = false) { - if ($this->client === null) { - $adapter = $this->queueRequest($url); - - if ($follow_303_with_post) { - $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'POST'); - } - - $adapter->setOpt(CURLOPT_POST, true); - $adapter->setOpt(CURLOPT_POSTFIELDS, $adapter->buildPostData($data)); + $adapter = $this->queueRequest($url); - return $adapter; + if ($follow_303_with_post) { + $adapter->setOpt(CURLOPT_CUSTOMREQUEST, 'POST'); } - return $this->wrapCurlResult($this->client->addPost($url, $data, $follow_303_with_post)); + $adapter->setOpt(CURLOPT_POST, true); + $adapter->setOpt(CURLOPT_POSTFIELDS, $adapter->buildPostData($data)); + + return $adapter; } /** @@ -163,13 +124,7 @@ public function addPost(string $url, $data = '', bool $follow_303_with_post = fa */ public function setHeader(string $key, $value): MultiCurlAdapterInterface { - if ($this->client === null) { - $this->applyHeader($key, $value); - - return $this; - } - - $this->client->setHeader($key, $value); + $this->applyHeader($key, $value); return $this; } @@ -179,16 +134,10 @@ public function setHeader(string $key, $value): MultiCurlAdapterInterface */ public function setHeaders(array $headers): MultiCurlAdapterInterface { - if ($this->client === null) { - foreach ($headers as $key => $value) { - $this->applyHeader(trim((string) $key), trim((string) $value)); - } - - return $this; + foreach ($headers as $key => $value) { + $this->applyHeader(trim((string) $key), trim((string) $value)); } - $this->client->setHeaders($headers); - return $this; } @@ -197,13 +146,7 @@ public function setHeaders(array $headers): MultiCurlAdapterInterface */ public function setOpt(int $option, $value): MultiCurlAdapterInterface { - if ($this->client === null) { - $this->applyOption($option, $value); - - return $this; - } - - $this->client->setOpt($option, $value); + $this->applyOption($option, $value); return $this; } @@ -213,23 +156,16 @@ public function setOpt(int $option, $value): MultiCurlAdapterInterface */ public function setOpts(array $options): MultiCurlAdapterInterface { - if ($this->client === null) { - foreach ($options as $option => $value) { - $this->applyOption($option, $value); - } - - return $this; + foreach ($options as $option => $value) { + $this->applyOption($option, $value); } - $this->client->setOpts($options); - return $this; } public function supportsMethod(string $method): bool { - return in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true) - || ($this->client !== null && method_exists($this->client, $method)); + return in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true); } /** @@ -250,20 +186,7 @@ public function callMethod(string $method, array $arguments) return $this->$method(...$arguments); } - if ($this->client === null) { - return null; - } - - return $this->client->$method(...$arguments); - } - - /** - * @param mixed $result - * @return mixed - */ - private function wrapCurlResult($result) - { - return $result instanceof Curl ? new CurlAdapter($result) : $result; + return null; } private function queueRequest(string $url): CurlAdapter @@ -320,6 +243,8 @@ private function startNativeRequests(): void curl_multi_add_handle($this->handle, $adapter->getHandle()); } + $running = 0; + do { do { $status = curl_multi_exec($this->handle, $running); diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index 84d1b626..b2f12d53 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -17,9 +17,7 @@ use Quantum\HttpClient\Adapters\MultiCurlAdapter; use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\App\Exceptions\BaseException; -use Curl\MultiCurl; use ErrorException; -use Curl\Curl; /** * HttpClient Class @@ -55,7 +53,7 @@ class HttpClient /** * @var HttpClientAdapterInterface|null */ - private ?HttpClientAdapterInterface $client = null; + private ?HttpClientAdapterInterface $adapter = null; private string $method = 'GET'; @@ -83,12 +81,12 @@ class HttpClient /** * Creates request */ - public function createRequest(string $url, ?Curl $client = null): HttpClient + public function createRequest(string $url): HttpClient { - $adapter = new CurlAdapter($client); + $adapter = new CurlAdapter(); $adapter->setUrl($url); - $this->client = $adapter; + $this->adapter = $adapter; return $this; } @@ -96,15 +94,15 @@ public function createRequest(string $url, ?Curl $client = null): HttpClient /** * Creates multi request */ - public function createMultiRequest(?MultiCurl $client = null): HttpClient + public function createMultiRequest(): HttpClient { - $adapter = new MultiCurlAdapter($client); + $adapter = new MultiCurlAdapter(); $adapter->complete(function (CurlAdapterInterface $instance): void { $this->handleResponse($instance); }); - $this->client = $adapter; + $this->adapter = $adapter; return $this; } @@ -112,14 +110,14 @@ public function createMultiRequest(?MultiCurl $client = null): HttpClient /** * Creates async multi request */ - public function createAsyncMultiRequest(callable $success, callable $error, ?MultiCurl $client = null): HttpClient + public function createAsyncMultiRequest(callable $success, callable $error): HttpClient { - $adapter = new MultiCurlAdapter($client); + $adapter = new MultiCurlAdapter(); $adapter->success($success); $adapter->error($error); - $this->client = $adapter; + $this->adapter = $adapter; return $this; } @@ -129,7 +127,7 @@ public function createAsyncMultiRequest(callable $success, callable $error, ?Mul */ public function getAdapter(): ?HttpClientAdapterInterface { - return $this->client; + return $this->adapter; } /** @@ -175,12 +173,12 @@ public function getData() /** * Checks if the request is multi cURL - * @phpstan-assert-if-true MultiCurlAdapterInterface $this->client - * @phpstan-assert-if-false CurlAdapterInterface|null $this->client + * @phpstan-assert-if-true MultiCurlAdapterInterface $this->adapter + * @phpstan-assert-if-false CurlAdapterInterface|null $this->adapter */ public function isMultiRequest(): bool { - return $this->client instanceof MultiCurlAdapterInterface; + return $this->adapter instanceof MultiCurlAdapterInterface; } /** @@ -190,12 +188,12 @@ public function isMultiRequest(): bool */ public function start(): HttpClient { - if (!$this->client) { + if (!$this->adapter) { throw HttpClientException::requestNotCreated(); } if ($this->isMultiRequest()) { - $this->client->start(); + $this->adapter->start(); } else { $this->startSingleRequest(); } @@ -264,7 +262,7 @@ public function getResponseBody() { $this->ensureSingleRequest(); - return $this->response[$this->client->getId()][self::RESPONSE_BODY] ?? null; + return $this->response[$this->adapter->getId()][self::RESPONSE_BODY] ?? null; } /** @@ -273,7 +271,7 @@ public function getResponseBody() */ public function getResponse(): array { - if ($this->client === null) { + if ($this->adapter === null) { return []; } @@ -281,7 +279,7 @@ public function getResponse(): array return $this->response; } - return $this->response[$this->client->getId()] ?? []; + return $this->response[$this->adapter->getId()] ?? []; } /** @@ -290,7 +288,7 @@ public function getResponse(): array */ public function getErrors(): array { - if ($this->client === null) { + if ($this->adapter === null) { return []; } @@ -298,7 +296,7 @@ public function getErrors(): array return $this->errors; } - return $this->errors[$this->client->getId()] ?? []; + return $this->errors[$this->adapter->getId()] ?? []; } /** @@ -310,7 +308,7 @@ public function info(?int $option = null) { $this->ensureSingleRequest(); - return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); + return $option !== null ? $this->adapter->getInfo($option) : $this->adapter->getInfo(); } /** @@ -321,7 +319,7 @@ public function url(): ?string { $this->ensureSingleRequest(); - return $this->client->getUrl(); + return $this->adapter->getUrl(); } /** @@ -333,15 +331,15 @@ public function __call(string $method, array $arguments): HttpClient { $this->ensureRequestCreated(); - if (!$this->client->supportsMethod($method)) { - throw HttpClientException::methodNotSupported($method, $this->client::class); + if (!$this->adapter->supportsMethod($method)) { + throw HttpClientException::methodNotSupported($method, $this->adapter::class); } $this->interceptCall($method, $arguments); $this->ensureRequestCreated(); - $this->client->callMethod($method, $arguments); + $this->adapter->callMethod($method, $arguments); return $this; } @@ -353,14 +351,14 @@ private function startSingleRequest(): void { $this->ensureSingleRequest(); - $this->client->setOpt(CURLOPT_CUSTOMREQUEST, $this->method); + $this->adapter->setOpt(CURLOPT_CUSTOMREQUEST, $this->method); if ($this->data) { - $this->client->setOpt(CURLOPT_POSTFIELDS, $this->client->buildPostData($this->data)); + $this->adapter->setOpt(CURLOPT_POSTFIELDS, $this->adapter->buildPostData($this->data)); } - $this->client->start(); - $this->handleResponse($this->client); + $this->adapter->start(); + $this->handleResponse($this->adapter); } /** @@ -399,7 +397,7 @@ private function formatHeaders(iterable $headers): array /** * @throws BaseException - * @phpstan-assert CurlAdapterInterface $this->client + * @phpstan-assert CurlAdapterInterface $this->adapter */ private function ensureSingleRequest(): void { @@ -412,11 +410,11 @@ private function ensureSingleRequest(): void /** * @throws HttpClientException - * @phpstan-assert HttpClientAdapterInterface $this->client + * @phpstan-assert HttpClientAdapterInterface $this->adapter */ private function ensureRequestCreated(): void { - if ($this->client === null) { + if ($this->adapter === null) { throw HttpClientException::requestNotCreated(); } } diff --git a/src/HttpClient/ResponseHeaders.php b/src/HttpClient/ResponseHeaders.php index aeada677..5e0dcc1f 100644 --- a/src/HttpClient/ResponseHeaders.php +++ b/src/HttpClient/ResponseHeaders.php @@ -17,23 +17,23 @@ /** * Class ResponseHeaders * @package Quantum\HttpClient - * @implements ArrayAccess - * @implements Iterator + * @implements ArrayAccess + * @implements Iterator */ class ResponseHeaders implements ArrayAccess, Countable, Iterator { /** - * @var array + * @var array */ private array $data = []; /** - * @var array + * @var array */ private array $keys = []; /** - * @param array|null $headers + * @param array|null $headers */ public function __construct(?array $headers = null) { @@ -45,47 +45,46 @@ public function __construct(?array $headers = null) } /** - * @param int|string|null $offset + * @param string|null $offset * @param mixed $value */ public function offsetSet($offset, $value): void { if ($offset === null) { - $this->data[] = $value; return; } - $normalizedOffset = strtolower((string) $offset); + $normalizedOffset = strtolower($offset); $this->data[$normalizedOffset] = $value; $this->keys[$normalizedOffset] = $offset; } /** - * @param int|string $offset + * @param string $offset */ public function offsetExists($offset): bool { - return array_key_exists(strtolower((string) $offset), $this->data); + return array_key_exists(strtolower($offset), $this->data); } /** - * @param int|string $offset + * @param string $offset */ public function offsetUnset($offset): void { - $normalizedOffset = strtolower((string) $offset); + $normalizedOffset = strtolower($offset); unset($this->data[$normalizedOffset]); unset($this->keys[$normalizedOffset]); } /** - * @param int|string $offset + * @param string $offset * @return mixed|null */ public function offsetGet($offset) { - return $this->data[strtolower((string) $offset)] ?? null; + return $this->data[strtolower($offset)] ?? null; } public function count(): int @@ -107,7 +106,7 @@ public function next(): void } /** - * @return int|string|null + * @return string|null */ public function key() { diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 65d19e39..a421c69a 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -5,9 +5,7 @@ use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\HttpClient\ResponseHeaders; use Quantum\Tests\Unit\AppTestCase; -use Curl\CaseInsensitiveArray; use CurlHandle; -use Curl\Curl; use Mockery; class CurlAdapterTest extends AppTestCase @@ -226,78 +224,6 @@ public function testCurlAdapterKeepsNativeErrorMessageNullOnSuccess(): void $this->assertSame(file_get_contents($fixturePath), $adapter->getResponse()); } - public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): void - { - $headers = new CaseInsensitiveArray(['Content-Type' => 'application/json']); - $response = (object) ['ok' => true]; - - $curl = Mockery::mock(Curl::class); - $curl->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('buildPostData')->once()->with(['a' => 1])->andReturn('payload'); - $curl->shouldReceive('exec')->once(); - $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); - $adapter = new CurlAdapter($curl); - $adapter - ->setUrl('https://example.com') - ->setHeader('Accept', 'application/json') - ->setHeaders(['X-Test' => 'yes']) - ->setOpt(CURLOPT_TIMEOUT, 10) - ->start(); - - $this->assertSame('payload', $adapter->buildPostData(['a' => 1])); - $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 testCurlAdapterGetsUrlFromWrappedVendorClient(): void - { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('getUrl')->once()->andReturn('https://example.com/from-multi'); - - $adapter = new CurlAdapter($curl); - - $this->assertSame('https://example.com/from-multi', $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); - $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])); - } - public function testCurlAdapterSupportsAndCallsNativeFacadeMethods(): void { $adapter = new CurlAdapter(); diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 29434123..4bb90df2 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -5,8 +5,6 @@ use Quantum\HttpClient\Adapters\MultiCurlAdapter; use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\Tests\Unit\AppTestCase; -use Curl\MultiCurl; -use Curl\Curl; use Mockery; class MultiCurlAdapterTest extends AppTestCase @@ -114,93 +112,13 @@ public function testMultiCurlAdapterAppliesNativeHeadersAndOptionsToExistingQueu ], $this->getPrivateProperty($adapter, 'options')); } - 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($getCurl); - $multiCurl->shouldReceive('addPost')->once()->with('https://example.com', 'payload', true)->andReturn($postCurl); - $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->assertInstanceOf(CurlAdapter::class, $adapter->addGet('https://example.com', ['a' => 1])); - $this->assertInstanceOf(CurlAdapter::class, $adapter->addPost('https://example.com', 'payload', true)); - - $adapter->start(); - } - - public function testMultiCurlAdapterRegistersCallbacks(): void - { - $curl = Mockery::mock(Curl::class); - - $multiCurl = Mockery::mock(MultiCurl::class); - $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(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 - { - $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); + $adapter = new MultiCurlAdapter(); $this->assertTrue($adapter->supportsMethod('addGet')); $this->assertFalse($adapter->supportsMethod('missingMethod')); - $this->assertEquals((object) ['id' => 1], $adapter->callMethod('addGet', ['https://example.com', []])); + $this->assertInstanceOf(CurlAdapter::class, $adapter->callMethod('addGet', ['https://example.com', []])); } private function fileUrl(string $path): string diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index e47e0e69..035732c8 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -7,9 +7,6 @@ use Quantum\HttpClient\Adapters\CurlAdapter; use Quantum\HttpClient\HttpClient; use Quantum\Tests\Unit\AppTestCase; -use Curl\CaseInsensitiveArray; -use Curl\MultiCurl; -use Curl\Curl; use Mockery; class HttpClientTest extends AppTestCase @@ -58,20 +55,13 @@ public function testHttpClientGetSetData(): void public function testHttpClientIsMultiRequest(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://example.com')->once(); - - $multi = Mockery::mock(MultiCurl::class); - - $multi->shouldReceive('complete')->once(); - - $this->httpClient->createRequest('https://example.com', $curl); + $this->httpClient->createRequest('https://example.com'); $this->assertFalse($this->httpClient->isMultiRequest()); $this->assertInstanceOf(CurlAdapter::class, $this->httpClient->getAdapter()); - $this->httpClient->createMultiRequest($multi); + $this->httpClient->createMultiRequest(); $this->assertTrue($this->httpClient->isMultiRequest()); @@ -94,11 +84,7 @@ public function testHttpClientReturnsEmptyResponseAndErrorsBeforeRequestCreated( public function testHttpClientEnsureSingleRequestThrowsOnMulti(): void { - $multi = Mockery::mock(MultiCurl::class); - - $multi->shouldReceive('complete')->once(); - - $this->httpClient->createMultiRequest($multi); + $this->httpClient->createMultiRequest(); $this->expectException(HttpClientException::class); @@ -107,26 +93,15 @@ public function testHttpClientEnsureSingleRequestThrowsOnMulti(): void public function testHttpClientSingleRequestResponseFlow(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://example.com')->once(); - $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); - $curl->shouldReceive('exec')->once(); - $curl->shouldReceive('isError')->andReturn(false); - $curl->shouldReceive('getId')->andReturn(0); - $curl->shouldReceive('getResponseHeaders') - ->andReturn(new CaseInsensitiveArray(['Content-Type' => 'text/plain'])); - $curl->shouldReceive('getResponseCookies')->andReturn(['a' => 'b']); - $curl->shouldReceive('getResponse')->andReturn('ok'); + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; $this->httpClient - ->createRequest('https://example.com', $curl) + ->createRequest($this->fileUrl($fixturePath)) ->start(); - $this->assertEquals('text/plain', $this->httpClient->getResponseHeaders('content-type')); - - $this->assertEquals('b', $this->httpClient->getResponseCookies('a')); - - $this->assertEquals('ok', $this->httpClient->getResponseBody()); + $this->assertSame([], $this->httpClient->getResponseHeaders()); + $this->assertSame([], $this->httpClient->getResponseCookies()); + $this->assertSame(file_get_contents($fixturePath), $this->httpClient->getResponseBody()); } public function testHttpClientNativeSingleRequestResponseFlow(): void @@ -144,79 +119,40 @@ public function testHttpClientNativeSingleRequestResponseFlow(): void public function testHttpClientPostRequestWithData(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://example.com')->once(); - $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'POST')->once(); - $curl->shouldReceive('buildPostData')->with(['x' => 1])->once()->andReturn('x=1'); - $curl->shouldReceive('setOpt')->with(CURLOPT_POSTFIELDS, 'x=1')->once(); - $curl->shouldReceive('exec')->once(); - $curl->shouldReceive('isError')->andReturn(false); - $curl->shouldReceive('getId')->andReturn(0); - $curl->shouldReceive('getResponseHeaders')->andReturn(new CaseInsensitiveArray()); - $curl->shouldReceive('getResponseCookies')->andReturn([]); - $curl->shouldReceive('getResponse')->andReturn((object) ['status' => 'ok']); - $this->httpClient - ->createRequest('https://example.com', $curl) + ->createRequest($this->fileUrl(PROJECT_ROOT . DS . 'app.conf')) ->setMethod('POST') - ->setData(['x' => 1]) - ->start(); + ->setData(['x' => 1]); - $this->assertEquals('ok', $this->httpClient->getResponseBody()->status); + $this->assertSame('POST', $this->httpClient->getMethod()); + $this->assertSame(['x' => 1], $this->httpClient->getData()); } public function testHttpClientSingleRequestError(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://bad.local')->once(); - $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); - $curl->shouldReceive('exec')->once(); - $curl->shouldReceive('isError')->andReturn(true); - $curl->shouldReceive('getId')->andReturn(0); - $curl->shouldReceive('getErrorCode')->andReturn(6); - $curl->shouldReceive('getErrorMessage')->andReturn('DNS error'); - $curl->shouldReceive('getResponseHeaders')->andReturn(new CaseInsensitiveArray()); - $curl->shouldReceive('getResponseCookies')->andReturn([]); - $curl->shouldReceive('getResponse')->andReturn(null); - $this->httpClient - ->createRequest('https://bad.local', $curl) + ->createRequest($this->fileUrl(PROJECT_ROOT . DS . 'missing.conf')) ->start(); $errors = $this->httpClient->getErrors(); - $this->assertEquals(6, $errors['code']); - - $this->assertEquals('DNS error', $errors['message']); + $this->assertNotSame(0, $errors['code']); + $this->assertNotEmpty($errors['message']); } public function testHttpClientMultiRequestResponseStructure(): void { - $multi = Mockery::mock(MultiCurl::class); - $multi->shouldReceive('complete') - ->once() - ->andReturnUsing(function ($callback): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('isError')->andReturn(false); - $curl->shouldReceive('getId')->andReturn(0); - $curl->shouldReceive('getResponseHeaders')->andReturn(new CaseInsensitiveArray()); - $curl->shouldReceive('getResponseCookies')->andReturn([]); - $curl->shouldReceive('getResponse')->andReturn('ok'); - - $callback($curl); - }); - - $this->httpClient->createMultiRequest($multi); + $this->httpClient + ->createMultiRequest() + ->addGet($this->fileUrl(PROJECT_ROOT . DS . 'app.conf')) + ->start(); $response = $this->httpClient->getResponse(); + $id = array_key_first($response); - $this->assertArrayHasKey(0, $response); - - $this->assertArrayHasKey('headers', $response[0]); - - $this->assertArrayHasKey('cookies', $response[0]); - - $this->assertArrayHasKey('body', $response[0]); + $this->assertArrayHasKey('headers', $response[$id]); + $this->assertArrayHasKey('cookies', $response[$id]); + $this->assertArrayHasKey('body', $response[$id]); } public function testHttpClientNativeMultiRequestResponseFlow(): void @@ -243,71 +179,22 @@ public function testHttpClientNativeMultiRequestResponseFlow(): void public function testHttpClientMultiRequestAggregatesErrors(): void { - $multi = Mockery::mock(MultiCurl::class); - $multi->shouldReceive('complete') - ->once() - ->andReturnUsing(function ($callback): void { - foreach ([0, 1] as $id) { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('isError')->andReturn(true); - $curl->shouldReceive('getId')->andReturn($id); - $curl->shouldReceive('getErrorCode')->andReturn(6); - $curl->shouldReceive('getErrorMessage')->andReturn('DNS error'); - $curl->shouldReceive('getResponseHeaders')->andReturn(new CaseInsensitiveArray()); - $curl->shouldReceive('getResponseCookies')->andReturn([]); - $curl->shouldReceive('getResponse')->andReturn(null); - - $callback($curl); - } - }); - - $this->httpClient->createMultiRequest($multi); + $this->httpClient + ->createMultiRequest() + ->addGet($this->fileUrl(PROJECT_ROOT . DS . 'missing-one.conf')) + ->addGet($this->fileUrl(PROJECT_ROOT . DS . 'missing-two.conf')) + ->start(); $errors = $this->httpClient->getErrors(); $this->assertCount(2, $errors); - - $this->assertEquals(6, $errors[0]['code']); - - $this->assertEquals(6, $errors[1]['code']); + foreach ($errors as $error) { + $this->assertNotSame(0, $error['code']); + $this->assertNotEmpty($error['message']); + } } public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void - { - $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() - ->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 testHttpClientNativeAsyncMultiRequestRegistersCallbacks(): void { $fixturePath = PROJECT_ROOT . DS . 'app.conf'; $successWrapped = null; @@ -325,45 +212,30 @@ public function testHttpClientNativeAsyncMultiRequestRegistersCallbacks(): void ->start(); $this->assertTrue($this->httpClient->isMultiRequest()); + $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); + $this->assertInstanceOf(CurlAdapter::class, $successWrapped); + $this->assertNull($errorWrapped); $this->assertSame(file_get_contents($fixturePath), $successWrapped->getResponse()); } public function testHttpClientInfoAndUrl(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://example.com')->once(); - $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); - $curl->shouldReceive('exec')->once(); - $curl->shouldReceive('isError')->andReturn(false); - $curl->shouldReceive('getId')->andReturn(0); - $curl->shouldReceive('getResponseHeaders')->andReturn(new CaseInsensitiveArray()); - $curl->shouldReceive('getResponseCookies')->andReturn([]); - $curl->shouldReceive('getResponse')->andReturn(''); - $curl->shouldReceive('getInfo')->andReturnUsing( - fn ($opt = null) => $opt === CURLINFO_HTTP_CODE ? 200 : ['http_code' => 200] - ); - $this->httpClient - ->createRequest('https://example.com', $curl) + ->createRequest($this->fileUrl(PROJECT_ROOT . DS . 'app.conf')) ->start(); - $this->assertEquals(200, $this->httpClient->info(CURLINFO_HTTP_CODE)); - - $this->assertEquals('https://example.com', $this->httpClient->url()); + $this->assertIsArray($this->httpClient->info()); + $this->assertSame($this->fileUrl(PROJECT_ROOT . DS . 'app.conf'), $this->httpClient->url()); } public function testHttpClientPassesZeroInfoOption(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->with('https://example.com')->once(); - $curl->shouldReceive('getInfo')->with(0)->once()->andReturn('zero'); - - $this->httpClient->createRequest('https://example.com', $curl); + $this->httpClient->createRequest($this->fileUrl(PROJECT_ROOT . DS . 'app.conf')); - $this->assertSame('zero', $this->httpClient->info(0)); + $this->assertFalse($this->httpClient->info(0)); } private function fileUrl(string $path): string From b3f9327a1633a3510d2dc2f30df09f327aadbb66 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:58:35 +0400 Subject: [PATCH 07/13] [#567] Clear completed multi curl requests --- src/HttpClient/Adapters/MultiCurlAdapter.php | 3 ++- .../Adapters/MultiCurlAdapterTest.php | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 5d9d3474..f2733a6f 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -262,7 +262,7 @@ private function startNativeRequests(): void private function completeNativeRequest(CurlHandle $handle): void { - foreach ($this->queue as $adapter) { + foreach ($this->queue as $id => $adapter) { if ($adapter->getHandle() !== $handle) { continue; } @@ -282,6 +282,7 @@ private function completeNativeRequest(CurlHandle $handle): void } curl_multi_remove_handle($this->handle, $handle); + unset($this->queue[$id]); return; } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 4bb90df2..20a517b8 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -69,6 +69,26 @@ public function testMultiCurlAdapterExecutesNativeRequestsAndDispatchesCallbacks $this->assertSame([], $errorRequests); } + public function testMultiCurlAdapterRemovesCompletedRequestsFromQueue(): void + { + $adapter = new MultiCurlAdapter(); + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + $completeRequests = []; + + $adapter->complete(function (CurlAdapter $instance) use (&$completeRequests): void { + $completeRequests[] = $instance->getId(); + }); + + $firstRequest = $adapter->addGet($this->fileUrl($fixturePath)); + $adapter->start(); + + $secondRequest = $adapter->addGet($this->fileUrl($fixturePath)); + $adapter->start(); + + $this->assertSame([$firstRequest->getId(), $secondRequest->getId()], $completeRequests); + $this->assertSame([], $adapter->getQueuedRequests()); + } + public function testMultiCurlAdapterAppliesNativeHeadersAndOptionsToFutureQueuedRequests(): void { $adapter = new MultiCurlAdapter(); From 3a972e09586b6b51064ec240f2c44633c9db4279 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:11:09 +0400 Subject: [PATCH 08/13] [#567] Back off stalled multi curl select --- src/HttpClient/Adapters/MultiCurlAdapter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index f2733a6f..db7f5b3a 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -254,8 +254,8 @@ private function startNativeRequests(): void $this->completeNativeRequest($info['handle']); } - if ($running > 0) { - curl_multi_select($this->handle); + if ($running > 0 && curl_multi_select($this->handle) === -1) { + usleep(1000); } } while ($running > 0); } From e0df4f5481e5de88bbfecb5ef2685592fd016e96 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:16:13 +0400 Subject: [PATCH 09/13] [#567] Aggregate async multi request results --- src/HttpClient/HttpClient.php | 3 +++ tests/Unit/HttpClient/HttpClientTest.php | 30 +++++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index b2f12d53..52fa85f4 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -114,6 +114,9 @@ public function createAsyncMultiRequest(callable $success, callable $error): Htt { $adapter = new MultiCurlAdapter(); + $adapter->complete(function (CurlAdapterInterface $instance): void { + $this->handleResponse($instance); + }); $adapter->success($success); $adapter->error($error); diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 035732c8..45c9e6ad 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -197,28 +197,40 @@ public function testHttpClientMultiRequestAggregatesErrors(): void public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void { $fixturePath = PROJECT_ROOT . DS . 'app.conf'; - $successWrapped = null; - $errorWrapped = null; + $missingFixturePath = PROJECT_ROOT . DS . 'missing-async.conf'; + $successWrapped = []; + $errorWrapped = []; $success = function (CurlAdapter $instance) use (&$successWrapped): void { - $successWrapped = $instance; + $successWrapped[$instance->getId()] = $instance; }; $error = function (CurlAdapter $instance) use (&$errorWrapped): void { - $errorWrapped = $instance; + $errorWrapped[$instance->getId()] = $instance; }; $this->httpClient ->createAsyncMultiRequest($success, $error) ->addGet($this->fileUrl($fixturePath)) - ->start(); + ->addGet($this->fileUrl($missingFixturePath)); $this->assertTrue($this->httpClient->isMultiRequest()); - $this->assertInstanceOf(MultiCurlAdapter::class, $this->httpClient->getAdapter()); - $this->assertInstanceOf(CurlAdapter::class, $successWrapped); + $queuedRequests = array_values($this->httpClient->getAdapter()->getQueuedRequests()); + [$successRequest, $errorRequest] = $queuedRequests; + + $this->httpClient->start(); + + $this->assertSame([$successRequest->getId() => $successRequest], $successWrapped); + $this->assertSame([$errorRequest->getId() => $errorRequest], $errorWrapped); + $this->assertSame(file_get_contents($fixturePath), $successRequest->getResponse()); + + $response = $this->httpClient->getResponse(); + $errors = $this->httpClient->getErrors(); - $this->assertNull($errorWrapped); - $this->assertSame(file_get_contents($fixturePath), $successWrapped->getResponse()); + $this->assertSame(file_get_contents($fixturePath), $response[$successRequest->getId()]['body']); + $this->assertSame('', $response[$errorRequest->getId()]['body']); + $this->assertArrayHasKey($errorRequest->getId(), $errors); + $this->assertNotSame(0, $errors[$errorRequest->getId()]['code']); } public function testHttpClientInfoAndUrl(): void From 8065ebe2907f97ae97ed8db775987f69f9f506a1 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:19:40 +0400 Subject: [PATCH 10/13] [#567] Clean up multi request response assertions --- tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php | 4 +++- .../Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php index f5d5defd..b2d6f8a2 100644 --- a/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php +++ b/tests/Unit/HttpClient/Factories/HttpClientFactoryTest.php @@ -39,7 +39,9 @@ public function testHttpClientFactoryCreatesExecutableNativeMultiRequest(): void ->addGet($this->fileUrl($fixturePath)) ->start(); - $this->assertSame(file_get_contents($fixturePath), reset($httpClient->getResponse())['body']); + $response = $httpClient->getResponse(); + + $this->assertSame(file_get_contents($fixturePath), reset($response)['body']); } public function testHttpClientFactoryCreatesAsyncMultiRequest(): void diff --git a/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php b/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php index 0226e562..e113b27a 100644 --- a/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php +++ b/tests/Unit/HttpClient/Helpers/HttpClientHelperFunctionsTest.php @@ -38,7 +38,9 @@ public function testHttpMultiRequestHelperCreatesExecutableNativeMultiRequest(): ->addGet($this->fileUrl($fixturePath)) ->start(); - $this->assertSame(file_get_contents($fixturePath), reset($httpClient->getResponse())['body']); + $response = $httpClient->getResponse(); + + $this->assertSame(file_get_contents($fixturePath), reset($response)['body']); } public function testHttpAsyncMultiRequestHelperCreatesAsyncMultiRequest(): void From ee487fac5c0678fa03fdab83713de1c44aabe0d3 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:25:27 +0400 Subject: [PATCH 11/13] [#567] Cover adapter call method fallback --- tests/Unit/HttpClient/Adapters/CurlAdapterTest.php | 1 + tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index a421c69a..c4f929ff 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -231,6 +231,7 @@ public function testCurlAdapterSupportsAndCallsNativeFacadeMethods(): void $this->assertTrue($adapter->supportsMethod('setHeaders')); $this->assertFalse($adapter->supportsMethod('setTimeout')); $this->assertSame($adapter, $adapter->callMethod('setHeaders', [['Accept' => 'application/json']])); + $this->assertNull($adapter->callMethod('setTimeout', [])); } private function fileUrl(string $path): string diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 20a517b8..703fee34 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -139,6 +139,7 @@ public function testMultiCurlAdapterSupportsDocumentedMethods(): void $this->assertTrue($adapter->supportsMethod('addGet')); $this->assertFalse($adapter->supportsMethod('missingMethod')); $this->assertInstanceOf(CurlAdapter::class, $adapter->callMethod('addGet', ['https://example.com', []])); + $this->assertNull($adapter->callMethod('missingMethod', [])); } private function fileUrl(string $path): string From 6f0163d103d55e33d1336c37e2c200644e0696b7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:44:25 +0400 Subject: [PATCH 12/13] [#567] Share adapter method forwarding --- src/HttpClient/Adapters/CurlAdapter.php | 23 +++---------- src/HttpClient/Adapters/MultiCurlAdapter.php | 23 +++---------- src/HttpClient/Traits/AdapterTrait.php | 36 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 src/HttpClient/Traits/AdapterTrait.php diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index efe878a2..275c2004 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -11,6 +11,7 @@ namespace Quantum\HttpClient\Adapters; use Quantum\HttpClient\Contracts\CurlAdapterInterface; +use Quantum\HttpClient\Traits\AdapterTrait; use Quantum\HttpClient\ResponseHeaders; use JsonSerializable; use RuntimeException; @@ -23,6 +24,10 @@ */ class CurlAdapter implements CurlAdapterInterface { + use AdapterTrait; + + private const SUPPORTED_METHODS = ['setHeader', 'setHeaders', 'setOpt', 'setOpts']; + private static int $lastId = 0; private CurlHandle $handle; @@ -319,24 +324,6 @@ public function getHandle(): CurlHandle return $this->handle; } - public function supportsMethod(string $method): bool - { - return in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true); - } - - /** - * @param array $arguments - * @return mixed - */ - public function callMethod(string $method, array $arguments) - { - if (in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true)) { - return $this->$method(...$arguments); - } - - return null; - } - private function resetResponseState(): void { $this->rawResponseHeaders = ''; diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index db7f5b3a..40e7334c 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -11,6 +11,7 @@ namespace Quantum\HttpClient\Adapters; use Quantum\HttpClient\Contracts\MultiCurlAdapterInterface; +use Quantum\HttpClient\Traits\AdapterTrait; use CurlMultiHandle; use CurlHandle; @@ -20,6 +21,10 @@ */ class MultiCurlAdapter implements MultiCurlAdapterInterface { + use AdapterTrait; + + private const SUPPORTED_METHODS = ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts']; + private CurlMultiHandle $handle; /** @@ -163,11 +168,6 @@ public function setOpts(array $options): MultiCurlAdapterInterface return $this; } - public function supportsMethod(string $method): bool - { - return in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true); - } - /** * @return array */ @@ -176,19 +176,6 @@ public function getQueuedRequests(): array return $this->queue; } - /** - * @param array $arguments - * @return mixed - */ - public function callMethod(string $method, array $arguments) - { - if (in_array($method, ['addGet', 'addPost', 'setHeader', 'setHeaders', 'setOpt', 'setOpts'], true)) { - return $this->$method(...$arguments); - } - - return null; - } - private function queueRequest(string $url): CurlAdapter { $adapter = new CurlAdapter(); diff --git a/src/HttpClient/Traits/AdapterTrait.php b/src/HttpClient/Traits/AdapterTrait.php new file mode 100644 index 00000000..2504cc92 --- /dev/null +++ b/src/HttpClient/Traits/AdapterTrait.php @@ -0,0 +1,36 @@ + $arguments + * @return mixed + */ + public function callMethod(string $method, array $arguments) + { + if ($this->supportsMethod($method)) { + return $this->$method(...$arguments); + } + + return null; + } +} From b3dbf0f757a78b95da30cd678275903969dccfe1 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:00:59 +0400 Subject: [PATCH 13/13] [#567] Clean up multi curl handles on callback failure --- src/HttpClient/Adapters/MultiCurlAdapter.php | 26 ++++++++------- .../Adapters/MultiCurlAdapterTest.php | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/HttpClient/Adapters/MultiCurlAdapter.php b/src/HttpClient/Adapters/MultiCurlAdapter.php index 40e7334c..d3586533 100644 --- a/src/HttpClient/Adapters/MultiCurlAdapter.php +++ b/src/HttpClient/Adapters/MultiCurlAdapter.php @@ -254,23 +254,25 @@ private function completeNativeRequest(CurlHandle $handle): void continue; } - $adapter->finalizeResponse(curl_multi_getcontent($handle)); + try { + $adapter->finalizeResponse(curl_multi_getcontent($handle)); - if ($this->completeCallback !== null) { - ($this->completeCallback)($adapter); - } + if ($this->completeCallback !== null) { + ($this->completeCallback)($adapter); + } - if ($adapter->isError()) { - if ($this->errorCallback !== null) { - ($this->errorCallback)($adapter); + if ($adapter->isError()) { + if ($this->errorCallback !== null) { + ($this->errorCallback)($adapter); + } + } elseif ($this->successCallback !== null) { + ($this->successCallback)($adapter); } - } elseif ($this->successCallback !== null) { - ($this->successCallback)($adapter); + } finally { + curl_multi_remove_handle($this->handle, $handle); + unset($this->queue[$id]); } - curl_multi_remove_handle($this->handle, $handle); - unset($this->queue[$id]); - return; } } diff --git a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php index 703fee34..02b1a3ac 100644 --- a/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/MultiCurlAdapterTest.php @@ -89,6 +89,39 @@ public function testMultiCurlAdapterRemovesCompletedRequestsFromQueue(): void $this->assertSame([], $adapter->getQueuedRequests()); } + public function testMultiCurlAdapterCleansQueueWhenCallbackThrows(): void + { + $adapter = new MultiCurlAdapter(); + $fixturePath = PROJECT_ROOT . DS . 'app.conf'; + $completeRequests = []; + + $adapter + ->complete(function (): void { + throw new \RuntimeException('Callback failed'); + }) + ->addGet($this->fileUrl($fixturePath)); + + try { + $adapter->start(); + $this->fail('Expected callback exception was not thrown'); + } catch (\RuntimeException $e) { + $this->assertSame('Callback failed', $e->getMessage()); + } + + $this->assertSame([], $adapter->getQueuedRequests()); + + $adapter + ->complete(function (CurlAdapter $instance) use (&$completeRequests): void { + $completeRequests[] = $instance->getId(); + }) + ->addGet($this->fileUrl($fixturePath)); + + $adapter->start(); + + $this->assertCount(1, $completeRequests); + $this->assertSame([], $adapter->getQueuedRequests()); + } + public function testMultiCurlAdapterAppliesNativeHeadersAndOptionsToFutureQueuedRequests(): void { $adapter = new MultiCurlAdapter();