diff --git a/.gitattributes b/.gitattributes index 7c0fa03..7950037 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,8 @@ .gitattributes export-ignore .gitignore export-ignore .scrutinizer.yml export-ignore +.styleci.yml export-ignore +.php_cs.yml export-ignore .travis.yml export-ignore CONDUCT.md export-ignore CONTRIBUTING.md export-ignore diff --git a/.github/workflows/Build-Test.yml b/.github/workflows/Build-Test.yml new file mode 100644 index 0000000..3c8d40e --- /dev/null +++ b/.github/workflows/Build-Test.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: + - '*.x' + pull_request: + +jobs: + tests: + if: "! contains(toJSON(github.event.commits.*.msg), 'skip') && ! contains(toJSON(github.event.commits.*.msg), 'ci')" #skip ci... + runs-on: ${{ matrix.operating-system }} + + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-22.04] + php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + include: + - operating-system: ubuntu-20.04 + php-versions: '7.4' + COMPOSER_FLAGS: '--prefer-stable --prefer-lowest' + PHPUNIT_FLAGS: '--coverage-clover build/coverage.xml' + + name: PHP ${{ matrix.php-versions }} - ${{ matrix.operating-system }} + + env: + extensions: curl json libxml dom + key: cache-v1 # can be any string, change to clear the extension cache. + + steps: + # Checks out a copy of your repository on the ubuntu machine + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP Action + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: ${{ env.extensions }} + coverage: xdebug + tools: 'composer:v2, pecl' + + - name: Install Composer dependencies + run: composer update ${{ matrix.COMPOSER_FLAGS }} --no-interaction + + - name: boot test server + run: vendor/bin/http_test_server > /dev/null 2>&1 & + + - name: Run tests + run: composer test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index fd4905e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,42 +0,0 @@ -language: php -sudo: false -dist: trusty - -cache: - directories: - - $HOME/.composer/cache - -php: - - 5.5 - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - hhvm - -env: - global: - - TEST_COMMAND="composer test" - -matrix: - fast_finish: true - include: - - php: 5.5 - env: COMPOSER_FLAGS="--prefer-stable --prefer-lowest" COVERAGE=true PHPUNIT_FLAGS="--coverage-clover build/coverage.xml" - allow_failures: - - php: hhvm - -before_install: - - travis_retry composer self-update - -install: - - travis_retry composer update ${COMPOSER_FLAGS} --prefer-source --no-interaction - -before_script: vendor/bin/http_test_server > /dev/null 2>&1 & - -script: - - $TEST_COMMAND - -after_success: - - if [[ "$COVERAGE" = true ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi - - if [[ "$COVERAGE" = true ]]; then php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index a338788..caef53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,81 @@ # Change Log -## Unreleased +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.7.1 - 2018-03-36 +## 2.3.3 - 2024-10-31 + +### Added + +- Added support for PHP 8.4 + +## 2.3.2 - 2024-03-03 + +### Fixed + +- Fixed running the tests with Guzzle PSR-7 and PSR-17 implementations. + +## 2.3.1 - 2023-11-03 + +### Added + +- Allow installation with Symfony 7. + +## 2.3.0 - 2023-04-28 + +### Added + +- Test with PHP 8.2 + +### Fixed + +- This client needs a PSR-17 factories implementation. Instead of requiring an implementation, + previous versions only required the interfaces which could lead to a non-functional installation. + Fixed by requiring `psr/http-factory-implementation`. + +## 2.2.1 - 2021-12-10 + +### Added + +- Symfony 6 support +- Tested with PHP 8.1 + +## 2.2.0 - 2020-12-14 + +### Added + +- PHP 8.0 support + +## 2.1.0 - 2019-12-27 + +### Added + +- Symfony 5 support + +## 2.0.0 - 2019-03-05 + +### Removed + +- HHVM support removed. + +### Changed + +- Minimal PHP version changed to 7.1. +- `Client::__construct` now expects PSR-17 factories instead of HTTPlug ones. + +### Added + +- #41: Support [PSR-17](https://www.php-fig.org/psr/psr-17/) and + [PSR-18](https://www.php-fig.org/psr/psr-18/). + + +## 1.7.1 - 2018-03-26 ### Fixed -- #36: Failure evaluating code: is_resource($handle) (string assertions are deprecated in PHP 7.2) +- #36: Failure evaluating code: `is_resource($handle)` (string assertions are deprecated in PHP 7.2) ## 1.7 - 2017-02-09 @@ -20,7 +88,7 @@ ### Fixed -- #29: Request not using CURLOPT_POSTFIELDS have content-length set to +- #29: Request not using CURLOPT_POSTFIELDS have content-length set to ### Changed @@ -56,7 +124,7 @@ ### Changed - Request body can be send with any method except GET, HEAD and TRACE. -- #25: Make discovery a hard dependency. +- #25: Make discovery a hard dependency. ## 1.4.2 - 2016-06-14 @@ -88,7 +156,7 @@ ### Removed -- #13: Remove HeaderParser. +- #13: Remove HeaderParser. ## 1.2 - 2016-03-09 @@ -125,7 +193,7 @@ First stable release. ### Changed - Root namespace changed from `Http\Curl` to `Http\Client\Curl`. -- Main client class name renamed from `CurlHttpClient` to `Client`. +- Main client class name renamed from `CurlHttpClient` to `Client`. - Minimum required [php-http/discovery](https://packagist.org/packages/php-http/discovery) version changed to 0.5. diff --git a/README.md b/README.md index fc60b7e..71ab2ea 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ [![Latest Version](https://img.shields.io/github/release/php-http/curl-client.svg?style=flat-square)](https://github.com/php-http/curl-client/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) -[![Build Status](https://img.shields.io/travis/php-http/curl-client.svg?style=flat-square)](https://travis-ci.org/php-http/curl-client) -[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/curl-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/curl-client) -[![Quality Score](https://img.shields.io/scrutinizer/g/php-http/curl-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/curl-client) +[![Tests](https://github.com/php-http/curl-client/actions/workflows/Build-Test.yml/badge.svg?branch=2.x)](https://github.com/php-http/curl-client/actions/workflows/Build-Test.yml) [![Total Downloads](https://img.shields.io/packagist/dt/php-http/curl-client.svg?style=flat-square)](https://packagist.org/packages/php-http/curl-client) The cURL client use the cURL PHP extension which must be activated in your `php.ini`. diff --git a/composer.json b/composer.json index c72b7a7..cb1d971 100644 --- a/composer.json +++ b/composer.json @@ -1,33 +1,35 @@ { "name": "php-http/curl-client", - "description": "cURL client for PHP-HTTP", + "description": "PSR-18 and HTTPlug Async client with cURL", "license": "MIT", - "keywords": ["http", "curl"], - "homepage": "http://php-http.org", - "authors": [ - { - "name": "Михаил Красильников", - "email": "m.krasilnikov@yandex.ru" - } + "keywords": [ + "curl", + "http", + "psr-18" ], + "homepage": "http://php-http.org", + "authors": [{ + "name": "Михаил Красильников", + "email": "m.krasilnikov@yandex.ru" + }], "prefer-stable": true, - "minimum-stability": "beta", - "config": { - "bin-dir": "vendor/bin" - }, + "minimum-stability": "dev", "require": { - "php": "^5.5 || ^7.0", + "php": "^7.4 || ^8.0", "ext-curl": "*", - "php-http/httplug": "^1.0", - "php-http/message-factory": "^1.0.2", + "php-http/discovery": "^1.6", + "php-http/httplug": "^2.0", "php-http/message": "^1.2", - "php-http/discovery": "^1.0" + "psr/http-client": "^1.0", + "psr/http-factory-implementation": "^1.0", + "symfony/options-resolver": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "require-dev": { - "guzzlehttp/psr7": "^1.0", - "php-http/client-integration-tests": "^0.6", - "phpunit/phpunit": "^4.8.27", - "zendframework/zend-diactoros": "^1.0" + "guzzlehttp/psr7": "^2.0", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^7.5 || ^9.4", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.1" }, "autoload": { "psr-4": { @@ -41,10 +43,16 @@ }, "provide": { "php-http/client-implementation": "1.0", - "php-http/async-client-implementation": "1.0" + "php-http/async-client-implementation": "1.0", + "psr/http-client-implementation": "1.0" }, "scripts": { "test": "vendor/bin/phpunit", "test-ci": "vendor/bin/phpunit --coverage-clover build/coverage.xml" + }, + "config": { + "allow-plugins": { + "php-http/discovery": false + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c6643dc..1df4f8a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,16 +1,35 @@ - + + + + + + + - - tests/ + + + tests + + + + tests/Unit + + + tests/Functional + + - - - + - src/ + src + diff --git a/src/Client.php b/src/Client.php index 5e8c4a6..d3fd6bd 100644 --- a/src/Client.php +++ b/src/Client.php @@ -1,20 +1,23 @@ @@ -31,55 +34,81 @@ class Client implements HttpClient, HttpAsyncClient * * @var array */ - private $options; + private $curlOptions; /** - * PSR-7 message factory. + * PSR-17 response factory. * - * @var MessageFactory + * @var ResponseFactoryInterface */ - private $messageFactory; + private $responseFactory; /** - * PSR-7 stream factory. + * PSR-17 stream factory. * - * @var StreamFactory + * @var StreamFactoryInterface */ private $streamFactory; /** * cURL synchronous requests handle. * - * @var resource|null + * @var resource|\CurlHandle|null */ - private $handle = null; + private $handle; /** * Simultaneous requests runner. * * @var MultiRunner|null */ - private $multiRunner = null; + private $multiRunner; /** - * Create new client. + * Create HTTP client. * - * @param MessageFactory|null $messageFactory HTTP Message factory - * @param StreamFactory|null $streamFactory HTTP Stream factory - * @param array $options cURL options {@link http://php.net/curl_setopt} + * @param ResponseFactoryInterface|null $responseFactory PSR-17 HTTP response factory. + * @param StreamFactoryInterface|null $streamFactory PSR-17 HTTP stream factory. + * @param array $options cURL options + * {@link http://php.net/curl_setopt}. * - * @throws \Http\Discovery\Exception\NotFoundException If factory discovery failed + * @throws NotFoundException If factory discovery failed. * - * @since 1.0 + * @since 2.0 Accepts PSR-17 factories instead of HTTPlug ones. */ public function __construct( - MessageFactory $messageFactory = null, - StreamFactory $streamFactory = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, array $options = [] ) { - $this->messageFactory = $messageFactory ?: MessageFactoryDiscovery::find(); - $this->streamFactory = $streamFactory ?: StreamFactoryDiscovery::find(); - $this->options = $options; + $this->responseFactory = $responseFactory ?: Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory(); + $resolver = new OptionsResolver(); + $resolver->setDefaults( + [ + CURLOPT_HEADER => false, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_FOLLOWLOCATION => false + ] + ); + + // Our parsing will fail if this is set to true. + $resolver->setAllowedValues( + (string)CURLOPT_HEADER, + [false] + ); + + // Our parsing will fail if this is set to true. + $resolver->setAllowedValues( + (string)CURLOPT_RETURNTRANSFER, + [false] + ); + + // We do not know what everything curl supports and might support in the future. + // Make sure that we accept everything that is in the options. + $resolver->setDefined(array_keys($options)); + + $this->curlOptions = $resolver->resolve($options); } /** @@ -93,25 +122,25 @@ public function __destruct() } /** - * Sends a PSR-7 request. + * Sends a PSR-7 request and returns a PSR-7 response. * * @param RequestInterface $request * * @return ResponseInterface * - * @throws \Http\Client\Exception\NetworkException In case of network problems - * @throws \Http\Client\Exception\RequestException On invalid request - * @throws \InvalidArgumentException For invalid header names or values - * @throws \RuntimeException If creating the body stream fails + * @throws \InvalidArgumentException For invalid header names or values. + * @throws \RuntimeException If creating the body stream fails. + * @throws Exception\NetworkException In case of network problems. + * @throws Exception\RequestException On invalid request. * * @since 1.6 \UnexpectedValueException replaced with RequestException * @since 1.6 Throw NetworkException on network errors * @since 1.0 */ - public function sendRequest(RequestInterface $request) + public function sendRequest(RequestInterface $request): ResponseInterface { $responseBuilder = $this->createResponseBuilder(); - $options = $this->createCurlOptions($request, $responseBuilder); + $requestOptions = $this->prepareRequestOptions($request, $responseBuilder); if (is_resource($this->handle)) { curl_reset($this->handle); @@ -119,7 +148,7 @@ public function sendRequest(RequestInterface $request) $this->handle = curl_init(); } - curl_setopt_array($this->handle, $options); + curl_setopt_array($this->handle, $requestOptions); curl_exec($this->handle); $errno = curl_errno($this->handle); @@ -144,77 +173,59 @@ public function sendRequest(RequestInterface $request) } /** - * Sends a PSR-7 request in an asynchronous way. - * - * @param RequestInterface $request - * - * @return Promise + * Create builder to use for building response object. * - * @throws \Http\Client\Exception\RequestException On invalid request - * @throws \InvalidArgumentException For invalid header names or values - * @throws \RuntimeException If creating the body stream fails - * - * @since 1.6 \UnexpectedValueException replaced with RequestException - * @since 1.0 + * @return ResponseBuilder */ - public function sendAsyncRequest(RequestInterface $request) + private function createResponseBuilder(): ResponseBuilder { - if (!$this->multiRunner instanceof MultiRunner) { - $this->multiRunner = new MultiRunner(); - } - - $handle = curl_init(); - $responseBuilder = $this->createResponseBuilder(); - $options = $this->createCurlOptions($request, $responseBuilder); - curl_setopt_array($handle, $options); + $body = $this->streamFactory->createStreamFromFile('php://temp', 'w+b'); - $core = new PromiseCore($request, $handle, $responseBuilder); - $promise = new CurlPromise($core, $this->multiRunner); - $this->multiRunner->add($core); + $response = $this->responseFactory + ->createResponse(200) + ->withBody($body); - return $promise; + return new ResponseBuilder($response); } /** - * Generates cURL options. + * Update cURL options for given request and hook in the response builder. * - * @param RequestInterface $request - * @param ResponseBuilder $responseBuilder + * @param RequestInterface $request Request on which to create options. + * @param ResponseBuilder $responseBuilder Builder to use for building response. * - * @throws \Http\Client\Exception\RequestException On invalid request - * @throws \InvalidArgumentException For invalid header names or values - * @throws \RuntimeException If can not read body + * @return array cURL options based on request. * - * @return array + * @throws \InvalidArgumentException For invalid header names or values. + * @throws \RuntimeException If can not read body. + * @throws Exception\RequestException On invalid request. */ - private function createCurlOptions(RequestInterface $request, ResponseBuilder $responseBuilder) - { - $options = $this->options; - - $options[CURLOPT_HEADER] = false; - $options[CURLOPT_RETURNTRANSFER] = false; - $options[CURLOPT_FOLLOWLOCATION] = false; + private function prepareRequestOptions( + RequestInterface $request, + ResponseBuilder $responseBuilder + ): array { + $curlOptions = $this->curlOptions; try { - $options[CURLOPT_HTTP_VERSION] + $curlOptions[CURLOPT_HTTP_VERSION] = $this->getProtocolVersion($request->getProtocolVersion()); } catch (\UnexpectedValueException $e) { throw new Exception\RequestException($e->getMessage(), $request); } - $options[CURLOPT_URL] = (string) $request->getUri(); + $curlOptions[CURLOPT_URL] = (string)$request->getUri(); - $options = $this->addRequestBodyOptions($request, $options); + $curlOptions = $this->addRequestBodyOptions($request, $curlOptions); - $options[CURLOPT_HTTPHEADER] = $this->createHeaders($request, $options); + $curlOptions[CURLOPT_HTTPHEADER] = $this->createHeaders($request, $curlOptions); if ($request->getUri()->getUserInfo()) { - $options[CURLOPT_USERPWD] = $request->getUri()->getUserInfo(); + $curlOptions[CURLOPT_USERPWD] = $request->getUri()->getUserInfo(); } - $options[CURLOPT_HEADERFUNCTION] = function ($ch, $data) use ($responseBuilder) { + $curlOptions[CURLOPT_HEADERFUNCTION] = function ($ch, $data) use ($responseBuilder) { $str = trim($data); if ('' !== $str) { - if (strpos(strtolower($str), 'http/') === 0) { + if (stripos($str, 'http/') === 0) { $responseBuilder->setStatus($str)->getResponse(); } else { $responseBuilder->addHeader($str); @@ -224,23 +235,23 @@ private function createCurlOptions(RequestInterface $request, ResponseBuilder $r return strlen($data); }; - $options[CURLOPT_WRITEFUNCTION] = function ($ch, $data) use ($responseBuilder) { + $curlOptions[CURLOPT_WRITEFUNCTION] = function ($ch, $data) use ($responseBuilder) { return $responseBuilder->getResponse()->getBody()->write($data); }; - return $options; + return $curlOptions; } /** * Return cURL constant for specified HTTP version. * - * @param string $requestVersion + * @param string $requestVersion HTTP version ("1.0", "1.1" or "2.0"). * - * @throws \UnexpectedValueException If unsupported version requested + * @return int Respective CURL_HTTP_VERSION_x_x constant. * - * @return int + * @throws \UnexpectedValueException If unsupported version requested. */ - private function getProtocolVersion($requestVersion) + private function getProtocolVersion(string $requestVersion): int { switch ($requestVersion) { case '1.0': @@ -260,12 +271,12 @@ private function getProtocolVersion($requestVersion) /** * Add request body related cURL options. * - * @param RequestInterface $request - * @param array $options + * @param RequestInterface $request Request on which to create options. + * @param array $curlOptions Options created by prepareRequestOptions(). * - * @return array + * @return array cURL options based on request. */ - private function addRequestBodyOptions(RequestInterface $request, array $options) + private function addRequestBodyOptions(RequestInterface $request, array $curlOptions): array { /* * Some HTTP methods cannot have payload: @@ -286,40 +297,40 @@ private function addRequestBodyOptions(RequestInterface $request, array $options // Message has non empty body. if (null === $bodySize || $bodySize > 1024 * 1024) { // Avoid full loading large or unknown size body into memory - $options[CURLOPT_UPLOAD] = true; + $curlOptions[CURLOPT_UPLOAD] = true; if (null !== $bodySize) { - $options[CURLOPT_INFILESIZE] = $bodySize; + $curlOptions[CURLOPT_INFILESIZE] = $bodySize; } - $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) { + $curlOptions[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) { return $body->read($length); }; } else { // Small body can be loaded into memory - $options[CURLOPT_POSTFIELDS] = (string) $body; + $curlOptions[CURLOPT_POSTFIELDS] = (string)$body; } } } if ($request->getMethod() === 'HEAD') { // This will set HTTP method to "HEAD". - $options[CURLOPT_NOBODY] = true; + $curlOptions[CURLOPT_NOBODY] = true; } elseif ($request->getMethod() !== 'GET') { // GET is a default method. Other methods should be specified explicitly. - $options[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); + $curlOptions[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); } - return $options; + return $curlOptions; } /** * Create headers array for CURLOPT_HTTPHEADER. * - * @param RequestInterface $request - * @param array $options cURL options + * @param RequestInterface $request Request on which to create headers. + * @param array $curlOptions Options created by prepareRequestOptions(). * * @return string[] */ - private function createHeaders(RequestInterface $request, array $options) + private function createHeaders(RequestInterface $request, array $curlOptions): array { $curlHeaders = []; $headers = $request->getHeaders(); @@ -330,16 +341,16 @@ private function createHeaders(RequestInterface $request, array $options) continue; } if ('content-length' === $header) { - if (array_key_exists(CURLOPT_POSTFIELDS, $options)) { + if (array_key_exists(CURLOPT_POSTFIELDS, $curlOptions)) { // Small body content length can be calculated here. - $values = [strlen($options[CURLOPT_POSTFIELDS])]; - } elseif (!array_key_exists(CURLOPT_READFUNCTION, $options)) { + $values = [strlen($curlOptions[CURLOPT_POSTFIELDS])]; + } elseif (!array_key_exists(CURLOPT_READFUNCTION, $curlOptions)) { // Else if there is no body, forcing "Content-length" to 0 $values = [0]; } } foreach ($values as $value) { - $curlHeaders[] = $name.': '.$value; + $curlHeaders[] = $name . ': ' . $value; } } /* @@ -352,21 +363,36 @@ private function createHeaders(RequestInterface $request, array $options) } /** - * Create new ResponseBuilder instance. + * Sends a PSR-7 request in an asynchronous way. * - * @return ResponseBuilder + * Exceptions related to processing the request are available from the returned Promise. + * + * @param RequestInterface $request + * + * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception. + * + * @throws \InvalidArgumentException For invalid header names or values. + * @throws \RuntimeException If creating the body stream fails. + * @throws Exception\RequestException On invalid request. * - * @throws \RuntimeException If creating the stream from $body fails + * @since 1.6 \UnexpectedValueException replaced with RequestException + * @since 1.0 */ - private function createResponseBuilder() + public function sendAsyncRequest(RequestInterface $request) { - try { - $body = $this->streamFactory->createStream(fopen('php://temp', 'w+b')); - } catch (\InvalidArgumentException $e) { - throw new \RuntimeException('Can not create "php://temp" stream.'); + if (!$this->multiRunner instanceof MultiRunner) { + $this->multiRunner = new MultiRunner(); } - $response = $this->messageFactory->createResponse(200, null, [], $body); - return new ResponseBuilder($response); + $handle = curl_init(); + $responseBuilder = $this->createResponseBuilder(); + $requestOptions = $this->prepareRequestOptions($request, $responseBuilder); + curl_setopt_array($handle, $requestOptions); + + $core = new PromiseCore($request, $handle, $responseBuilder); + $promise = new CurlPromise($core, $this->multiRunner); + $this->multiRunner->add($core); + + return $promise; } } diff --git a/src/CurlPromise.php b/src/CurlPromise.php index 5061af5..d69494c 100644 --- a/src/CurlPromise.php +++ b/src/CurlPromise.php @@ -1,5 +1,7 @@ core->addOnFulfilled($onFulfilled); diff --git a/src/MultiRunner.php b/src/MultiRunner.php index 545f39a..13f43a9 100644 --- a/src/MultiRunner.php +++ b/src/MultiRunner.php @@ -1,5 +1,7 @@ cores as $existed) { if ($existed === $core) { @@ -62,7 +64,7 @@ public function add(PromiseCore $core) * * @param PromiseCore $core */ - public function remove(PromiseCore $core) + public function remove(PromiseCore $core): void { foreach ($this->cores as $index => $existed) { if ($existed === $core) { @@ -79,7 +81,7 @@ public function remove(PromiseCore $core) * * @param PromiseCore|null $targetCore */ - public function wait(PromiseCore $targetCore = null) + public function wait(?PromiseCore $targetCore = null): void { do { $status = curl_multi_exec($this->multiHandle, $active); @@ -116,7 +118,7 @@ public function wait(PromiseCore $targetCore = null) * * @return PromiseCore|null */ - private function findCoreByHandle($handle) + private function findCoreByHandle($handle): ?PromiseCore { foreach ($this->cores as $core) { if ($core->getHandle() === $handle) { diff --git a/src/PromiseCore.php b/src/PromiseCore.php index 09065c4..b58776f 100644 --- a/src/PromiseCore.php +++ b/src/PromiseCore.php @@ -1,5 +1,7 @@ 7 && !$handle instanceof \CurlHandle) { throw new \InvalidArgumentException( sprintf( - 'Parameter $handle expected to be a cURL resource, %s resource given', - get_resource_type($handle) + 'Parameter $handle expected to be a cURL resource, %s given', + get_debug_type($handle) ) ); } @@ -107,7 +118,7 @@ public function __construct( * * @param callable $callback */ - public function addOnFulfilled(callable $callback) + public function addOnFulfilled(callable $callback): void { if ($this->getState() === Promise::PENDING) { $this->onFulfilled[] = $callback; @@ -124,7 +135,7 @@ public function addOnFulfilled(callable $callback) * * @param callable $callback */ - public function addOnRejected(callable $callback) + public function addOnRejected(callable $callback): void { if ($this->getState() === Promise::PENDING) { $this->onRejected[] = $callback; @@ -136,7 +147,7 @@ public function addOnRejected(callable $callback) /** * Return cURL handle. * - * @return resource + * @return resource|\CurlHandle */ public function getHandle() { @@ -148,7 +159,7 @@ public function getHandle() * * @return string */ - public function getState() + public function getState(): string { return $this->state; } @@ -158,7 +169,7 @@ public function getState() * * @return RequestInterface */ - public function getRequest() + public function getRequest(): RequestInterface { return $this->request; } @@ -166,9 +177,9 @@ public function getRequest() /** * Return the value of the promise (fulfilled). * - * @return ResponseInterface Response Object only when the Promise is fulfilled + * @return ResponseInterface Response object only when the Promise is fulfilled */ - public function getResponse() + public function getResponse(): ResponseInterface { return $this->responseBuilder->getResponse(); } @@ -179,11 +190,11 @@ public function getResponse() * If the exception is an instance of Http\Client\Exception\HttpException it will contain * the response object with the status code and the http reason. * - * @return Exception Exception Object only when the Promise is rejected + * @return \Throwable Exception Object only when the Promise is rejected * * @throws \LogicException When the promise is not rejected */ - public function getException() + public function getException(): \Throwable { if (null === $this->exception) { throw new \LogicException('Promise is not rejected'); @@ -195,7 +206,7 @@ public function getException() /** * Fulfill promise. */ - public function fulfill() + public function fulfill(): void { $this->state = Promise::FULFILLED; $response = $this->responseBuilder->getResponse(); @@ -223,7 +234,7 @@ public function fulfill() * * @param Exception $exception Reject reason */ - public function reject(Exception $exception) + public function reject(Exception $exception): void { $this->exception = $exception; $this->state = Promise::REJECTED; diff --git a/src/ResponseBuilder.php b/src/ResponseBuilder.php index 805b330..c7250e7 100644 --- a/src/ResponseBuilder.php +++ b/src/ResponseBuilder.php @@ -1,5 +1,7 @@ response = $response; } diff --git a/tests/BaseUnitTestCase.php b/tests/BaseUnitTestCase.php deleted file mode 100644 index a51346d..0000000 --- a/tests/BaseUnitTestCase.php +++ /dev/null @@ -1,75 +0,0 @@ -handle)) { - curl_close($this->handle); - } - } - - /** - * Create new request. - * - * @param string $method - * @param mixed $uri - * - * @return RequestInterface - */ - protected function createRequest($method, $uri) - { - return MessageFactoryDiscovery::find()->createRequest($method, $uri); - } - - /** - * Create new response. - * - * @return ResponseInterface - */ - protected function createResponse() - { - return MessageFactoryDiscovery::find()->createResponse(); - } - - /** - * Create PromiseCore mock. - * - * @return PromiseCore|\PHPUnit_Framework_MockObject_MockObject - */ - protected function createPromiseCore() - { - $class = new \ReflectionClass(PromiseCore::class); - $methods = $class->getMethods(\ReflectionMethod::IS_PUBLIC); - foreach ($methods as &$item) { - $item = $item->getName(); - } - unset($item); - $core = $this->getMockBuilder(PromiseCore::class)->disableOriginalConstructor() - ->setMethods($methods)->getMock(); - - return $core; - } -} diff --git a/tests/ClientTest.php b/tests/ClientTest.php deleted file mode 100644 index e21130f..0000000 --- a/tests/ClientTest.php +++ /dev/null @@ -1,83 +0,0 @@ -getMockBuilder(Client::class)->disableOriginalConstructor() - ->setMethods(['__none__'])->getMock(); - - $createHeaders = new \ReflectionMethod(Client::class, 'createHeaders'); - $createHeaders->setAccessible(true); - - $request = new Request(); - - $headers = $createHeaders->invoke($client, $request, []); - - static::assertContains('Expect:', $headers); - } - - public function testRewindStream() - { - $client = $this->getMockBuilder(Client::class)->disableOriginalConstructor() - ->setMethods(['__none__'])->getMock(); - - $bodyOptions = new \ReflectionMethod(Client::class, 'addRequestBodyOptions'); - $bodyOptions->setAccessible(true); - - $body = \GuzzleHttp\Psr7\stream_for('abcdef'); - $body->seek(3); - $request = new Request('http://foo.com', 'POST', $body); - $options = $bodyOptions->invoke($client, $request, []); - - static::assertEquals('abcdef', $options[CURLOPT_POSTFIELDS]); - } - - public function testRewindLargeStream() - { - $client = $this->getMockBuilder(Client::class)->disableOriginalConstructor() - ->setMethods(['__none__'])->getMock(); - - $bodyOptions = new \ReflectionMethod(Client::class, 'addRequestBodyOptions'); - $bodyOptions->setAccessible(true); - - $content = 'abcdef'; - while (strlen($content) < 1024 * 1024 + 100) { - $content .= '123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890'; - } - - $length = strlen($content); - $body = \GuzzleHttp\Psr7\stream_for($content); - $body->seek(40); - $request = new Request('http://foo.com', 'POST', $body); - $options = $bodyOptions->invoke($client, $request, []); - - static::assertTrue(false !== strstr($options[CURLOPT_READFUNCTION](null, null, $length), 'abcdef'), 'Steam was not rewinded'); - } - - /** - * Discovery should be used if no factory given. - */ - public function testFactoryDiscovery() - { - $client = new Client(); - - static::assertInstanceOf(Client::class, $client); - } -} diff --git a/tests/CurlPromiseTest.php b/tests/CurlPromiseTest.php deleted file mode 100644 index e128e3a..0000000 --- a/tests/CurlPromiseTest.php +++ /dev/null @@ -1,73 +0,0 @@ -createPromiseCore(); - $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor() - ->setMethods(['wait'])->getMock(); - /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ - $promise = new CurlPromise($core, $runner); - - $onFulfill = function () { - }; - $core->expects(static::once())->method('addOnFulfilled')->with($onFulfill); - $onReject = function () { - }; - $core->expects(static::once())->method('addOnRejected')->with($onReject); - $value = $promise->then($onFulfill, $onReject); - static::assertInstanceOf(Promise::class, $value); - - $core->expects(static::once())->method('getState')->willReturn('STATE'); - static::assertEquals('STATE', $promise->getState()); - } - - public function testCoreCallWaitFulfilled() - { - $core = $this->createPromiseCore(); - $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor() - ->setMethods(['wait'])->getMock(); - /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ - $promise = new CurlPromise($core, $runner); - - $runner->expects(static::once())->method('wait')->with($core); - $core->expects(static::once())->method('getState')->willReturn(Promise::FULFILLED); - $core->expects(static::once())->method('getResponse')->willReturn('RESPONSE'); - static::assertEquals('RESPONSE', $promise->wait()); - } - - public function testCoreCallWaitRejected() - { - $core = $this->createPromiseCore(); - $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor() - ->setMethods(['wait'])->getMock(); - /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ - $promise = new CurlPromise($core, $runner); - - $runner->expects(static::once())->method('wait')->with($core); - $core->expects(static::once())->method('getState')->willReturn(Promise::REJECTED); - $core->expects(static::once())->method('getException')->willReturn(new TransferException()); - - try { - $promise->wait(); - } catch (TransferException $exception) { - static::assertTrue(true); - } - } -} diff --git a/tests/Functional/HttpAsyncClientDiactorosTest.php b/tests/Functional/HttpAsyncClientDiactorosTest.php new file mode 100644 index 0000000..c8dc1cf --- /dev/null +++ b/tests/Functional/HttpAsyncClientDiactorosTest.php @@ -0,0 +1,24 @@ +createTempFile(); $fd = fopen($filename, 'ab'); @@ -97,31 +52,64 @@ public function testSendLargeFile() } /** - * Create temp file. + * {@inheritdoc} * - * @return string Filename + * @dataProvider requestProvider */ - protected function createTempFile() + public function testSendRequest($httpMethod, $uri, array $httpHeaders, $requestBody): void { - $filename = tempnam(sys_get_temp_dir(), 'tests'); - $this->tmpFiles[] = $filename; - - return $filename; + if ($requestBody !== null && in_array($httpMethod, ['GET', 'HEAD', 'TRACE'], true)) { + self::markTestSkipped('cURL can not send body using '.$httpMethod); + } + parent::testSendRequest( + $httpMethod, + $uri, + $httpHeaders, + $requestBody + ); } /** - * Create stream from file. + * {@inheritdoc} * - * @param string $filename + * @dataProvider requestWithOutcomeProvider + */ + public function testSendRequestWithOutcome( + $uriAndOutcome, + $httpVersion, + array $httpHeaders, + $requestBody + ): void { + if ($requestBody !== null) { + self::markTestSkipped('cURL can not send body using GET'); + } + parent::testSendRequestWithOutcome( + $uriAndOutcome, + $httpVersion, + $httpHeaders, + $requestBody + ); + } + + abstract protected function createFileStream(string $filename): StreamInterface; + + /** + * Create temporary file. * - * @return StreamInterface + * @return string Filename */ - abstract protected function createFileStream($filename); + protected function createTempFile(): string + { + $filename = tempnam(sys_get_temp_dir(), 'tests'); + $this->tmpFiles[] = $filename; + + return $filename; + } /** - * Tears down the fixture. + * Delete files created with createTempFile */ - protected function tearDown() + protected function tearDown(): void { parent::tearDown(); diff --git a/tests/HttpAsyncClientDiactorosTest.php b/tests/HttpAsyncClientDiactorosTest.php deleted file mode 100644 index f5b1609..0000000 --- a/tests/HttpAsyncClientDiactorosTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setExpectedException( - \InvalidArgumentException::class, - 'Parameter $handle expected to be a cURL resource, NULL given' - ); - - new PromiseCore( - $this->createRequest('GET', '/'), - null, - new ResponseBuilder($this->createResponse()) - ); - } - - /** - * Testing if handle is not a cURL resource. - */ - public function testHandleIsNotACurlResource() - { - $this->setExpectedException( - \InvalidArgumentException::class, - 'Parameter $handle expected to be a cURL resource, stream resource given' - ); - - new PromiseCore( - $this->createRequest('GET', '/'), - fopen('php://memory', 'r+b'), - new ResponseBuilder($this->createResponse()) - ); - } - - /** - * Test on fulfill actions. - */ - public function testOnFulfill() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - - $core = new PromiseCore( - $request, - $this->handle, - new ResponseBuilder($this->createResponse()) - ); - static::assertSame($request, $core->getRequest()); - static::assertSame($this->handle, $core->getHandle()); - - $core->addOnFulfilled( - function (ResponseInterface $response) { - return $response->withAddedHeader('X-Test', 'foo'); - } - ); - - $core->fulfill(); - static::assertEquals(Promise::FULFILLED, $core->getState()); - static::assertInstanceOf(ResponseInterface::class, $core->getResponse()); - static::assertEquals('foo', $core->getResponse()->getHeaderLine('X-Test')); - - $core->addOnFulfilled( - function (ResponseInterface $response) { - return $response->withAddedHeader('X-Test', 'bar'); - } - ); - static::assertEquals('foo, bar', $core->getResponse()->getHeaderLine('X-Test')); - } - - /** - * Test on reject actions. - */ - public function testOnReject() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - - $core = new PromiseCore( - $request, - $this->handle, - new ResponseBuilder($this->createResponse()) - ); - $core->addOnRejected( - function (RequestException $exception) { - throw new RequestException('Foo', $exception->getRequest(), $exception); - } - ); - - $exception = new RequestException('Error', $request); - $core->reject($exception); - static::assertEquals(Promise::REJECTED, $core->getState()); - static::assertInstanceOf(Exception::class, $core->getException()); - static::assertEquals('Foo', $core->getException()->getMessage()); - - $core->addOnRejected( - function (RequestException $exception) { - return new RequestException('Bar', $exception->getRequest(), $exception); - } - ); - static::assertEquals('Bar', $core->getException()->getMessage()); - } - - /** - * «onReject» callback can throw exception. - * - * @see https://github.com/php-http/curl-client/issues/26 - */ - public function testIssue26() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - - $core = new PromiseCore( - $request, - $this->handle, - new ResponseBuilder($this->createResponse()) - ); - $core->addOnRejected( - function (RequestException $exception) { - throw new RequestException('Foo', $exception->getRequest(), $exception); - } - ); - $core->addOnRejected( - function (RequestException $exception) { - return new RequestException('Bar', $exception->getRequest(), $exception); - } - ); - - $exception = new RequestException('Error', $request); - $core->reject($exception); - static::assertEquals(Promise::REJECTED, $core->getState()); - static::assertInstanceOf(Exception::class, $core->getException()); - static::assertEquals('Bar', $core->getException()->getMessage()); - } - - /** - * @expectedException \LogicException - */ - public function testNotRejected() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - $core = new PromiseCore( - $request, - $this->handle, - new ResponseBuilder($this->createResponse()) - ); - $core->getException(); - } -} diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..55d3e89 --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,111 @@ +expectException(InvalidOptionsException::class); + new Client( + $this->createMock(ResponseFactoryInterface::class), + $this->createMock(StreamFactoryInterface::class), + [ + CURLOPT_HEADER => true, // this won't work with our client + ] + ); + } + + /** + * "Expect" header should be empty by default. + * + * @link https://github.com/php-http/curl-client/issues/18 + */ + public function testExpectHeaderIsEmpty(): void + { + $client = $this->createMock(Client::class); + + $createHeaders = new \ReflectionMethod(Client::class, 'createHeaders'); + $createHeaders->setAccessible(true); + + $request = new Request(); + + $headers = $createHeaders->invoke($client, $request, []); + + static::assertContains('Expect:', $headers); + } + + /** + * "Expect" header should be empty when POST field is empty. + * + * @link https://github.com/php-http/curl-client/issues/18 + */ + public function testExpectHeaderIsEmpty2(): void + { + $client = $this->createMock(Client::class); + + $createHeaders = new \ReflectionMethod(Client::class, 'createHeaders'); + $createHeaders->setAccessible(true); + + $request = new Request(); + $request = $request->withHeader('content-length', '0'); + + $headers = $createHeaders->invoke($client, $request, [CURLOPT_POSTFIELDS => '']); + + self::assertContains('content-length: 0', $headers); + } + + public function testRewindLargeStream(): void + { + $client = $this->createMock(Client::class); + + $bodyOptions = new \ReflectionMethod(Client::class, 'addRequestBodyOptions'); + $bodyOptions->setAccessible(true); + + $content = 'abcdef'; + while (strlen($content) < 1024 * 1024 + 100) { + $content .= '123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890'; + } + + $length = strlen($content); + $body = Utils::streamFor($content); + $body->seek(40); + $request = new Request('http://foo.com', 'POST', $body); + $options = $bodyOptions->invoke($client, $request, []); + + static::assertNotFalse( + strpos($options[CURLOPT_READFUNCTION](null, null, $length), 'abcdef'), 'Steam was not rewinded' + ); + } + + public function testRewindStream(): void + { + $client = $this->createMock(Client::class); + + $bodyOptions = new \ReflectionMethod(Client::class, 'addRequestBodyOptions'); + $bodyOptions->setAccessible(true); + + $body = Utils::streamFor('abcdef'); + $body->seek(3); + $request = new Request('http://foo.com', 'POST', $body); + $options = $bodyOptions->invoke($client, $request, []); + + static::assertEquals('abcdef', $options[CURLOPT_POSTFIELDS]); + } +} diff --git a/tests/Unit/CurlPromiseTest.php b/tests/Unit/CurlPromiseTest.php new file mode 100644 index 0000000..617b258 --- /dev/null +++ b/tests/Unit/CurlPromiseTest.php @@ -0,0 +1,75 @@ +createMock(PromiseCore::class); + $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor() + ->setMethods(['wait'])->getMock(); + /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ + $promise = new CurlPromise($core, $runner); + + $runner->expects(self::once())->method('wait')->with($core); + $core->expects(self::once())->method('getState')->willReturn(Promise::FULFILLED); + + $response = $this->createMock(ResponseInterface::class); + $core->expects(self::once())->method('getResponse')->willReturn($response); + self::assertSame($response, $promise->wait()); + } + + public function testCoreCallWaitRejected(): void + { + $core = $this->createMock(PromiseCore::class); + $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor()->getMock(); + /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ + $promise = new CurlPromise($core, $runner); + + $runner->expects(self::once())->method('wait')->with($core); + $core->expects(self::once())->method('getState')->willReturn(Promise::REJECTED); + $core->expects(self::once())->method('getException')->willReturn(new TransferException()); + + try { + $promise->wait(); + } catch (TransferException $exception) { + self::assertTrue(true); + } + } + + public function testCoreCalls(): void + { + $core = $this->createMock(PromiseCore::class); + $runner = $this->getMockBuilder(MultiRunner::class)->disableOriginalConstructor() + ->setMethods(['wait'])->getMock(); + /** @var MultiRunner|\PHPUnit_Framework_MockObject_MockObject $runner */ + $promise = new CurlPromise($core, $runner); + + $onFulfill = function () { + }; + $core->expects(self::once())->method('addOnFulfilled')->with($onFulfill); + + $onReject = function () { + }; + $core->expects(self::once())->method('addOnRejected')->with($onReject); + + $promise->then($onFulfill, $onReject); + + $core->expects(self::once())->method('getState')->willReturn('STATE'); + self::assertEquals('STATE', $promise->getState()); + } +} diff --git a/tests/Unit/PromiseCoreTest.php b/tests/Unit/PromiseCoreTest.php new file mode 100644 index 0000000..7374640 --- /dev/null +++ b/tests/Unit/PromiseCoreTest.php @@ -0,0 +1,171 @@ +createMock(RequestInterface::class); + $responseBuilder = $this->createMock(ResponseBuilder::class); + + $this->expectException(\InvalidArgumentException::class); + if (PHP_MAJOR_VERSION > 7) { + $this->expectExceptionMessage('Parameter $handle expected to be a cURL resource, resource (stream) given'); + } else { + $this->expectExceptionMessage('Parameter $handle expected to be a cURL resource, stream resource given'); + } + + new PromiseCore($request, fopen('php://memory', 'r+b'), $responseBuilder); + } + + /** + * Testing if handle is not a resource. + */ + public function testHandleIsNotAResource(): void + { + $request = $this->createMock(RequestInterface::class); + $responseBuilder = $this->createMock(ResponseBuilder::class); + + $this->expectException(\InvalidArgumentException::class); + if (PHP_MAJOR_VERSION > 7) { + $this->expectExceptionMessage('Parameter $handle expected to be a cURL resource, null given'); + } else { + $this->expectExceptionMessage('Parameter $handle expected to be a cURL resource, NULL given'); + } + + new PromiseCore($request, null, $responseBuilder); + } + + /** + * «onReject» callback can throw exception. + * + * @see https://github.com/php-http/curl-client/issues/26 + */ + public function testIssue26(): void + { + $request = $this->createMock(RequestInterface::class); + $responseBuilder = $this->createMock(ResponseBuilder::class); + + $this->handle = curl_init(); + + $core = new PromiseCore($request, $this->handle, $responseBuilder); + $core->addOnRejected( + function (RequestException $exception) { + throw new RequestException('Foo', $exception->getRequest(), $exception); + } + ); + $core->addOnRejected( + function (RequestException $exception) { + return new RequestException('Bar', $exception->getRequest(), $exception); + } + ); + + $exception = new RequestException('Error', $request); + $core->reject($exception); + self::assertEquals(Promise::REJECTED, $core->getState()); + self::assertEquals('Bar', $core->getException()->getMessage()); + } + + public function testNotRejected(): void + { + $request = $this->createMock(RequestInterface::class); + $responseBuilder = $this->createMock(ResponseBuilder::class); + + $this->handle = curl_init(); + + $core = new PromiseCore($request, $this->handle, $responseBuilder); + $this->expectException(\LogicException::class); + $core->getException(); + } + + public function testOnFulfill(): void + { + $request = $this->createMock(RequestInterface::class); + + $stream = $this->createMock(StreamInterface::class); + $response1 = $this->createConfiguredMock(ResponseInterface::class, ['getBody' => $stream]); + $responseBuilder = $this->createConfiguredMock(ResponseBuilder::class, ['getResponse' => $response1]); + $response2 = $this->createConfiguredMock(ResponseInterface::class, ['getBody' => $stream]); + + $this->handle = curl_init(); + + $core = new PromiseCore($request, $this->handle, $responseBuilder); + + self::assertSame($request, $core->getRequest()); + self::assertSame($this->handle, $core->getHandle()); + + $core->addOnFulfilled( + function (ResponseInterface $response) use ($response1, $response2) { + self::assertSame($response1, $response); + + return $response2; + } + ); + + $core->fulfill(); + self::assertEquals(Promise::FULFILLED, $core->getState()); + } + + public function testOnReject(): void + { + $request = $this->createMock(RequestInterface::class); + $responseBuilder = $this->createMock(ResponseBuilder::class); + + $this->handle = curl_init(); + + $core = new PromiseCore($request, $this->handle, $responseBuilder); + $core->addOnRejected( + function (RequestException $exception) { + throw new RequestException('Foo', $exception->getRequest(), $exception); + } + ); + + $exception = new RequestException('Error', $request); + $core->reject($exception); + self::assertEquals(Promise::REJECTED, $core->getState()); + self::assertEquals('Foo', $core->getException()->getMessage()); + + $core->addOnRejected( + function (RequestException $exception) { + return new RequestException('Bar', $exception->getRequest(), $exception); + } + ); + self::assertEquals('Bar', $core->getException()->getMessage()); + } + + protected function tearDown(): void + { + if (is_resource($this->handle)) { + curl_close($this->handle); + } + + parent::tearDown(); + } +}