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/.gitignore b/.gitignore index e45d856..396c451 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.puli/ build/ vendor/ composer.lock diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f0e1cf3..0000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ -language: php - -sudo: false - -cache: - directories: - - $HOME/.composer/cache - -php: - - 5.5 - - 5.6 - - 7.0 - - 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" - -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 935f871..caef53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,209 @@ # 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). + +## 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) + + +## 1.7 - 2017-02-09 + +### Changed + +- #30: Make sure we rewind streams + +## 1.6.2 - 2017-01-02 + +### Fixed + +- #29: Request not using CURLOPT_POSTFIELDS have content-length set to + +### Changed + +- Use binary mode to create response body stream. + + +## 1.6.1 - 2016-11-11 + +### Fixed + +- #27: ErrorPlugin and sendAsyncRequest() incompatibility + + +## 1.6 - 2016-09-12 + +### Changed + +- `Client::sendRequest` now throws `Http\Client\Exception\NetworkException` on network errors. +- `\UnexpectedValueException` replaced with `Http\Client\Exception\RequestException` in + `Client::sendRequest` and `Client::sendAsyncRequest` + + +## 1.5.1 - 2016-08-29 + +### Fixed + +- #26: Combining CurlClient with StopwatchPlugin causes Promise onRejected handler to never be + invoked. + + +## 1.5 - 2016-08-03 + +### Changed + +- Request body can be send with any method except GET, HEAD and TRACE. +- #25: Make discovery a hard dependency. + + +## 1.4.2 - 2016-06-14 + +### Added + +- #23: "php-http/async-client-implementation" added to "provide" section. + + +## 1.4.1 - 2016-05-30 + +### Fixed + +- #22: Cannot create the client using `HttpClientDiscovery`. + + +## 1.4 - 2016-03-30 + +### Changed + +- #20: Minimize memory usage when reading large response body. + + +## 1.3 - 2016-03-14 + +### Fixed + +- #18: Invalid "Expect" header. + +### Removed + +- #13: Remove HeaderParser. + + +## 1.2 - 2016-03-09 + +### Added + +- #16: Make sure discovery can find the curl client + +### Fixed + +- #15: "Out of memory" sending large files. + + +## 1.1.0 - 2016-01-29 + +### Changed + +- Switch to php-http/message 1.0. + + +## 1.0.0 - 2016-01-28 + +First stable release. + + +## 0.7.0 - 2016-01-26 + +### Changed + +- Migrate from `php-http/discovery` and `php-http/utils` to `php-http/message`. + +## 0.6.0 - 2016-01-12 + +### Changed + +- Root namespace changed from `Http\Curl` to `Http\Client\Curl`. +- 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. + + +## 0.5.0 - 2015-12-18 + +### Changed + +- Compatibility with php-http/httplug 1.0 beta +- Switch to php-http/discovery 0.4 ## 0.4.0 - 2015-12-16 diff --git a/README.md b/README.md index ff88ea2..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`. @@ -18,71 +16,9 @@ Via Composer $ composer require php-http/curl-client ``` -## Usage - -### Using [php-http/discovery](https://packagist.org/packages/php-http/discovery): - -```php -use Http\Curl\CurlHttpClient; -use Http\Discovery\MessageFactory\GuzzleMessageFactory; -use Http\Discovery\StreamFactory\GuzzleStreamFactory; - -$messageFactory = new GuzzleMessageFactory(); -$client = new CurlHttpClient($messageFactory, new GuzzleStreamFactory()); - -$request = $messageFactory->createRequest('GET', 'http://example.com/'); -$response = $client->sendRequest($request); -``` - -### Using [mekras/httplug-diactoros-bridge](https://packagist.org/packages/mekras/httplug-diactoros-bridge): - -```php -use Http\Curl\CurlHttpClient; -use Mekras\HttplugDiactorosBridge\DiactorosMessageFactory; -use Mekras\HttplugDiactorosBridge\DiactorosStreamFactory; - -$messageFactory = new DiactorosMessageFactory(); -$client = new CurlHttpClient($messageFactory, new DiactorosStreamFactory()); - -$request = $messageFactory->createRequest('GET', 'http://example.com/'); -$response = $client->sendRequest($request); -``` - -### Configuring client - -You can use [cURL options](http://php.net/curl_setopt) to configure CurlHttpClient: - -```php -use Http\Curl\CurlHttpClient; -use Http\Discovery\MessageFactory\GuzzleMessageFactory; -use Http\Discovery\StreamFactory\GuzzleStreamFactory; - -$options = [ - CURLOPT_CONNECTTIMEOUT => 10, // The number of seconds to wait while trying to connect. - CURLOPT_SSL_VERIFYPEER => false // Stop cURL from verifying the peer's certificate -]; -$client = new CurlHttpClient(new GuzzleMessageFactory(), new GuzzleStreamFactory(), $options); -``` - -These options can not ne used: - -* CURLOPT_CUSTOMREQUEST -* CURLOPT_FOLLOWLOCATION -* CURLOPT_HEADER -* CURLOPT_HTTP_VERSION -* CURLOPT_HTTPHEADER -* CURLOPT_NOBODY -* CURLOPT_POSTFIELDS -* CURLOPT_RETURNTRANSFER -* CURLOPT_URL - -These options can be overwritten by CurlHttpClient: - -* CURLOPT_USERPWD - ## Documentation -Please see the [official documentation](http://php-http.readthedocs.org/en/latest/). +Please see the [official documentation](http://docs.php-http.org/en/latest/clients/curl-client.html). ## Testing diff --git a/composer.json b/composer.json index a4ccf1d..cb1d971 100644 --- a/composer.json +++ b/composer.json @@ -1,45 +1,58 @@ { "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": "dev", "require": { - "php": ">=5.5", + "php": "^7.4 || ^8.0", "ext-curl": "*", - "php-http/httplug": "1.0.0-alpha3", - "php-http/message-factory": "^1.0" + "php-http/discovery": "^1.6", + "php-http/httplug": "^2.0", + "php-http/message": "^1.2", + "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/adapter-integration-tests": "dev-master#48e5c0ee7b19772ab00060498f91886268b945c4", - "php-http/discovery": "dev-master#fd0cbe2ed125cc4f8fcc0378aff9874ce206e858", - "phpunit/phpunit": "^4.8@stable", - "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": { - "Http\\Curl\\": "src/" + "Http\\Client\\Curl\\": "src/" } }, "autoload-dev": { "psr-4": { - "Http\\Curl\\Tests\\": "tests/" + "Http\\Client\\Curl\\Tests\\": "tests/" } }, "provide": { - "php-http/client-implementation": "1.0" + "php-http/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" }, - "prefer-stable": true, - "minimum-stability": "dev" + "config": { + "allow-plugins": { + "php-http/discovery": false + } + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c2f6ec4..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/puli.json b/puli.json new file mode 100644 index 0000000..b35768d --- /dev/null +++ b/puli.json @@ -0,0 +1,242 @@ +{ + "version": "1.0", + "name": "php-http/curl-client", + "bindings": { + "98239b8b-103b-4f47-94c7-4cba49a05a1f": { + "_class": "Puli\\Discovery\\Binding\\ClassBinding", + "class": "Http\\Client\\Curl\\Client", + "type": "Http\\Client\\HttpAsyncClient" + }, + "a6a79968-2aa5-427c-bbe1-a581d9a48321": { + "_class": "Puli\\Discovery\\Binding\\ClassBinding", + "class": "Http\\Client\\Curl\\Client", + "type": "Http\\Client\\HttpClient" + } + }, + "config": { + "bootstrap-file": "vendor/autoload.php" + }, + "packages": { + "clue/stream-filter": { + "install-path": "vendor/clue/stream-filter", + "installer": "composer", + "env": "dev" + }, + "doctrine/instantiator": { + "install-path": "vendor/doctrine/instantiator", + "installer": "composer", + "env": "dev" + }, + "guzzlehttp/psr7": { + "install-path": "vendor/guzzlehttp/psr7", + "installer": "composer", + "env": "dev" + }, + "justinrainbow/json-schema": { + "install-path": "vendor/justinrainbow/json-schema", + "installer": "composer", + "env": "dev" + }, + "paragonie/random_compat": { + "install-path": "vendor/paragonie/random_compat", + "installer": "composer", + "env": "dev" + }, + "php-http/adapter-integration-tests": { + "install-path": "vendor/php-http/adapter-integration-tests", + "installer": "composer", + "env": "dev" + }, + "php-http/discovery": { + "install-path": "vendor/php-http/discovery", + "installer": "composer", + "env": "dev" + }, + "php-http/httplug": { + "install-path": "vendor/php-http/httplug", + "installer": "composer" + }, + "php-http/message": { + "install-path": "vendor/php-http/message", + "installer": "composer", + "env": "dev" + }, + "php-http/message-factory": { + "install-path": "vendor/php-http/message-factory", + "installer": "composer" + }, + "php-http/promise": { + "install-path": "vendor/php-http/promise", + "installer": "composer" + }, + "phpdocumentor/reflection-docblock": { + "install-path": "vendor/phpdocumentor/reflection-docblock", + "installer": "composer", + "env": "dev" + }, + "phpspec/prophecy": { + "install-path": "vendor/phpspec/prophecy", + "installer": "composer", + "env": "dev" + }, + "phpunit/php-code-coverage": { + "install-path": "vendor/phpunit/php-code-coverage", + "installer": "composer", + "env": "dev" + }, + "phpunit/php-file-iterator": { + "install-path": "vendor/phpunit/php-file-iterator", + "installer": "composer", + "env": "dev" + }, + "phpunit/php-text-template": { + "install-path": "vendor/phpunit/php-text-template", + "installer": "composer", + "env": "dev" + }, + "phpunit/php-timer": { + "install-path": "vendor/phpunit/php-timer", + "installer": "composer", + "env": "dev" + }, + "phpunit/php-token-stream": { + "install-path": "vendor/phpunit/php-token-stream", + "installer": "composer", + "env": "dev" + }, + "phpunit/phpunit": { + "install-path": "vendor/phpunit/phpunit", + "installer": "composer", + "env": "dev" + }, + "phpunit/phpunit-mock-objects": { + "install-path": "vendor/phpunit/phpunit-mock-objects", + "installer": "composer", + "env": "dev" + }, + "psr/http-message": { + "install-path": "vendor/psr/http-message", + "installer": "composer" + }, + "psr/log": { + "install-path": "vendor/psr/log", + "installer": "composer", + "env": "dev" + }, + "puli/composer-plugin": { + "install-path": "vendor/puli/composer-plugin", + "installer": "composer", + "env": "dev" + }, + "puli/discovery": { + "install-path": "vendor/puli/discovery", + "installer": "composer", + "env": "dev" + }, + "puli/repository": { + "install-path": "vendor/puli/repository", + "installer": "composer", + "env": "dev" + }, + "puli/url-generator": { + "install-path": "vendor/puli/url-generator", + "installer": "composer", + "env": "dev" + }, + "ramsey/uuid": { + "install-path": "vendor/ramsey/uuid", + "installer": "composer", + "env": "dev" + }, + "sebastian/comparator": { + "install-path": "vendor/sebastian/comparator", + "installer": "composer", + "env": "dev" + }, + "sebastian/diff": { + "install-path": "vendor/sebastian/diff", + "installer": "composer", + "env": "dev" + }, + "sebastian/environment": { + "install-path": "vendor/sebastian/environment", + "installer": "composer", + "env": "dev" + }, + "sebastian/exporter": { + "install-path": "vendor/sebastian/exporter", + "installer": "composer", + "env": "dev" + }, + "sebastian/global-state": { + "install-path": "vendor/sebastian/global-state", + "installer": "composer", + "env": "dev" + }, + "sebastian/recursion-context": { + "install-path": "vendor/sebastian/recursion-context", + "installer": "composer", + "env": "dev" + }, + "sebastian/version": { + "install-path": "vendor/sebastian/version", + "installer": "composer", + "env": "dev" + }, + "seld/jsonlint": { + "install-path": "vendor/seld/jsonlint", + "installer": "composer", + "env": "dev" + }, + "symfony/filesystem": { + "install-path": "vendor/symfony/filesystem", + "installer": "composer", + "env": "dev" + }, + "symfony/process": { + "install-path": "vendor/symfony/process", + "installer": "composer", + "env": "dev" + }, + "symfony/yaml": { + "install-path": "vendor/symfony/yaml", + "installer": "composer", + "env": "dev" + }, + "th3n3rd/cartesian-product": { + "install-path": "vendor/th3n3rd/cartesian-product", + "installer": "composer", + "env": "dev" + }, + "webmozart/assert": { + "install-path": "vendor/webmozart/assert", + "installer": "composer", + "env": "dev" + }, + "webmozart/expression": { + "install-path": "vendor/webmozart/expression", + "installer": "composer", + "env": "dev" + }, + "webmozart/glob": { + "install-path": "vendor/webmozart/glob", + "installer": "composer", + "env": "dev" + }, + "webmozart/json": { + "install-path": "vendor/webmozart/json", + "installer": "composer", + "env": "dev" + }, + "webmozart/path-util": { + "install-path": "vendor/webmozart/path-util", + "installer": "composer", + "env": "dev" + }, + "zendframework/zend-diactoros": { + "install-path": "vendor/zendframework/zend-diactoros", + "installer": "composer", + "env": "dev" + } + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..d3fd6bd --- /dev/null +++ b/src/Client.php @@ -0,0 +1,398 @@ + + * @author Blake Williams + * + * @api + * + * @since 1.0 + */ +class Client implements HttpClient, HttpAsyncClient +{ + /** + * cURL options. + * + * @var array + */ + private $curlOptions; + + /** + * PSR-17 response factory. + * + * @var ResponseFactoryInterface + */ + private $responseFactory; + + /** + * PSR-17 stream factory. + * + * @var StreamFactoryInterface + */ + private $streamFactory; + + /** + * cURL synchronous requests handle. + * + * @var resource|\CurlHandle|null + */ + private $handle; + + /** + * Simultaneous requests runner. + * + * @var MultiRunner|null + */ + private $multiRunner; + + /** + * Create HTTP client. + * + * @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 NotFoundException If factory discovery failed. + * + * @since 2.0 Accepts PSR-17 factories instead of HTTPlug ones. + */ + public function __construct( + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + array $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); + } + + /** + * Release resources if still active. + */ + public function __destruct() + { + if (is_resource($this->handle)) { + curl_close($this->handle); + } + } + + /** + * Sends a PSR-7 request and returns a PSR-7 response. + * + * @param RequestInterface $request + * + * @return ResponseInterface + * + * @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): ResponseInterface + { + $responseBuilder = $this->createResponseBuilder(); + $requestOptions = $this->prepareRequestOptions($request, $responseBuilder); + + if (is_resource($this->handle)) { + curl_reset($this->handle); + } else { + $this->handle = curl_init(); + } + + curl_setopt_array($this->handle, $requestOptions); + curl_exec($this->handle); + + $errno = curl_errno($this->handle); + switch ($errno) { + case CURLE_OK: + // All OK, no actions needed. + break; + case CURLE_COULDNT_RESOLVE_PROXY: + case CURLE_COULDNT_RESOLVE_HOST: + case CURLE_COULDNT_CONNECT: + case CURLE_OPERATION_TIMEOUTED: + case CURLE_SSL_CONNECT_ERROR: + throw new Exception\NetworkException(curl_error($this->handle), $request); + default: + throw new Exception\RequestException(curl_error($this->handle), $request); + } + + $response = $responseBuilder->getResponse(); + $response->getBody()->seek(0); + + return $response; + } + + /** + * Create builder to use for building response object. + * + * @return ResponseBuilder + */ + private function createResponseBuilder(): ResponseBuilder + { + $body = $this->streamFactory->createStreamFromFile('php://temp', 'w+b'); + + $response = $this->responseFactory + ->createResponse(200) + ->withBody($body); + + return new ResponseBuilder($response); + } + + /** + * Update cURL options for given request and hook in the response builder. + * + * @param RequestInterface $request Request on which to create options. + * @param ResponseBuilder $responseBuilder Builder to use for building response. + * + * @return array cURL options based on request. + * + * @throws \InvalidArgumentException For invalid header names or values. + * @throws \RuntimeException If can not read body. + * @throws Exception\RequestException On invalid request. + */ + private function prepareRequestOptions( + RequestInterface $request, + ResponseBuilder $responseBuilder + ): array { + $curlOptions = $this->curlOptions; + + try { + $curlOptions[CURLOPT_HTTP_VERSION] + = $this->getProtocolVersion($request->getProtocolVersion()); + } catch (\UnexpectedValueException $e) { + throw new Exception\RequestException($e->getMessage(), $request); + } + $curlOptions[CURLOPT_URL] = (string)$request->getUri(); + + $curlOptions = $this->addRequestBodyOptions($request, $curlOptions); + + $curlOptions[CURLOPT_HTTPHEADER] = $this->createHeaders($request, $curlOptions); + + if ($request->getUri()->getUserInfo()) { + $curlOptions[CURLOPT_USERPWD] = $request->getUri()->getUserInfo(); + } + + $curlOptions[CURLOPT_HEADERFUNCTION] = function ($ch, $data) use ($responseBuilder) { + $str = trim($data); + if ('' !== $str) { + if (stripos($str, 'http/') === 0) { + $responseBuilder->setStatus($str)->getResponse(); + } else { + $responseBuilder->addHeader($str); + } + } + + return strlen($data); + }; + + $curlOptions[CURLOPT_WRITEFUNCTION] = function ($ch, $data) use ($responseBuilder) { + return $responseBuilder->getResponse()->getBody()->write($data); + }; + + return $curlOptions; + } + + /** + * Return cURL constant for specified HTTP version. + * + * @param string $requestVersion HTTP version ("1.0", "1.1" or "2.0"). + * + * @return int Respective CURL_HTTP_VERSION_x_x constant. + * + * @throws \UnexpectedValueException If unsupported version requested. + */ + private function getProtocolVersion(string $requestVersion): int + { + switch ($requestVersion) { + case '1.0': + return CURL_HTTP_VERSION_1_0; + case '1.1': + return CURL_HTTP_VERSION_1_1; + case '2.0': + if (defined('CURL_HTTP_VERSION_2_0')) { + return CURL_HTTP_VERSION_2_0; + } + throw new \UnexpectedValueException('libcurl 7.33 needed for HTTP 2.0 support'); + } + + return CURL_HTTP_VERSION_NONE; + } + + /** + * Add request body related cURL options. + * + * @param RequestInterface $request Request on which to create options. + * @param array $curlOptions Options created by prepareRequestOptions(). + * + * @return array cURL options based on request. + */ + private function addRequestBodyOptions(RequestInterface $request, array $curlOptions): array + { + /* + * Some HTTP methods cannot have payload: + * + * - GET — cURL will automatically change method to PUT or POST if we set CURLOPT_UPLOAD or + * CURLOPT_POSTFIELDS. + * - HEAD — cURL treats HEAD as GET request with a same restrictions. + * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request. + */ + if (!in_array($request->getMethod(), ['GET', 'HEAD', 'TRACE'], true)) { + $body = $request->getBody(); + $bodySize = $body->getSize(); + if ($bodySize !== 0) { + if ($body->isSeekable()) { + $body->rewind(); + } + + // Message has non empty body. + if (null === $bodySize || $bodySize > 1024 * 1024) { + // Avoid full loading large or unknown size body into memory + $curlOptions[CURLOPT_UPLOAD] = true; + if (null !== $bodySize) { + $curlOptions[CURLOPT_INFILESIZE] = $bodySize; + } + $curlOptions[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) { + return $body->read($length); + }; + } else { + // Small body can be loaded into memory + $curlOptions[CURLOPT_POSTFIELDS] = (string)$body; + } + } + } + + if ($request->getMethod() === 'HEAD') { + // This will set HTTP method to "HEAD". + $curlOptions[CURLOPT_NOBODY] = true; + } elseif ($request->getMethod() !== 'GET') { + // GET is a default method. Other methods should be specified explicitly. + $curlOptions[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); + } + + return $curlOptions; + } + + /** + * Create headers array for CURLOPT_HTTPHEADER. + * + * @param RequestInterface $request Request on which to create headers. + * @param array $curlOptions Options created by prepareRequestOptions(). + * + * @return string[] + */ + private function createHeaders(RequestInterface $request, array $curlOptions): array + { + $curlHeaders = []; + $headers = $request->getHeaders(); + foreach ($headers as $name => $values) { + $header = strtolower($name); + if ('expect' === $header) { + // curl-client does not support "Expect-Continue", so dropping "expect" headers + continue; + } + if ('content-length' === $header) { + if (array_key_exists(CURLOPT_POSTFIELDS, $curlOptions)) { + // Small body content length can be calculated here. + $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; + } + } + /* + * curl-client does not support "Expect-Continue", but cURL adds "Expect" header by default. + * We can not suppress it, but we can set it to empty. + */ + $curlHeaders[] = 'Expect:'; + + return $curlHeaders; + } + + /** + * Sends a PSR-7 request in an asynchronous way. + * + * 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. + * + * @since 1.6 \UnexpectedValueException replaced with RequestException + * @since 1.0 + */ + public function sendAsyncRequest(RequestInterface $request) + { + if (!$this->multiRunner instanceof MultiRunner) { + $this->multiRunner = new MultiRunner(); + } + + $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/CurlHttpClient.php b/src/CurlHttpClient.php deleted file mode 100644 index 533cd50..0000000 --- a/src/CurlHttpClient.php +++ /dev/null @@ -1,254 +0,0 @@ - - * @author Blake Williams - * - * @api - * @since 1.0 - */ -class CurlHttpClient implements HttpClient, HttpAsyncClient -{ - /** - * cURL options - * - * @var array - */ - private $options; - - /** - * cURL response parser - * - * @var ResponseParser - */ - private $responseParser; - - /** - * cURL synchronous requests handle - * - * @var resource|null - */ - private $handle = null; - - /** - * Simultaneous requests runner - * - * @var MultiRunner|null - */ - private $multiRunner = null; - - /** - * Create new client - * - * Available options: - * - * - connection_timeout : int — connection timeout in seconds; - * - curl_options: array — custom cURL options; - * - ssl_verify_peer : bool — verify peer when using SSL; - * - timeout : int — overall timeout in seconds. - * - * @param MessageFactory $messageFactory HTTP Message factory - * @param StreamFactory $streamFactory HTTP Stream factory - * @param array $options cURL options (see http://php.net/curl_setopt) - * - * @since 1.0 - */ - public function __construct( - MessageFactory $messageFactory, - StreamFactory $streamFactory, - array $options = [] - ) { - $this->responseParser = new ResponseParser($messageFactory, $streamFactory); - $this->options = $options; - } - - /** - * Release resources if still active - */ - public function __destruct() - { - if (is_resource($this->handle)) { - curl_close($this->handle); - } - } - - /** - * Sends a PSR-7 request. - * - * @param RequestInterface $request - * - * @return ResponseInterface - * - * @throws \UnexpectedValueException if unsupported HTTP version requested - * @throws RequestException - * - * @since 1.0 - */ - public function sendRequest(RequestInterface $request) - { - $options = $this->createCurlOptions($request); - - if (is_resource($this->handle)) { - curl_reset($this->handle); - } else { - $this->handle = curl_init(); - } - - curl_setopt_array($this->handle, $options); - $raw = curl_exec($this->handle); - - if (curl_errno($this->handle) > 0) { - throw new RequestException(curl_error($this->handle), $request); - } - - $info = curl_getinfo($this->handle); - - try { - $response = $this->responseParser->parse($raw, $info); - } catch (\Exception $e) { - throw new RequestException($e->getMessage(), $request, $e); - } - return $response; - } - - /** - * Sends a PSR-7 request in an asynchronous way. - * - * @param RequestInterface $request - * - * @return Promise - * - * @throws Exception - * @throws \UnexpectedValueException if unsupported HTTP version requested - * - * @since 1.0 - */ - public function sendAsyncRequest(RequestInterface $request) - { - if (!$this->multiRunner instanceof MultiRunner) { - $this->multiRunner = new MultiRunner($this->responseParser); - } - - $handle = curl_init(); - $options = $this->createCurlOptions($request); - curl_setopt_array($handle, $options); - - $core = new PromiseCore($request, $handle); - $promise = new CurlPromise($core, $this->multiRunner); - $this->multiRunner->add($core); - - return $promise; - } - - /** - * Generates cURL options - * - * @param RequestInterface $request - * - * @throws \UnexpectedValueException if unsupported HTTP version requested - * - * @return array - */ - private function createCurlOptions(RequestInterface $request) - { - $options = $this->options; - - $options[CURLOPT_HEADER] = true; - $options[CURLOPT_RETURNTRANSFER] = true; - $options[CURLOPT_FOLLOWLOCATION] = false; - - $options[CURLOPT_HTTP_VERSION] = $this->getProtocolVersion($request->getProtocolVersion()); - $options[CURLOPT_URL] = (string) $request->getUri(); - - if (in_array($request->getMethod(), ['OPTIONS', 'POST', 'PUT'], true)) { - // cURL allows request body only for these methods. - $body = (string) $request->getBody(); - if ('' !== $body) { - $options[CURLOPT_POSTFIELDS] = $body; - } - } - - if ($request->getMethod() === 'HEAD') { - $options[CURLOPT_NOBODY] = true; - } elseif ($request->getMethod() !== 'GET') { - // GET is a default method. Other methods should be specified explicitly. - $options[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); - } - - $options[CURLOPT_HTTPHEADER] = $this->createHeaders($request, $options); - - if ($request->getUri()->getUserInfo()) { - $options[CURLOPT_USERPWD] = $request->getUri()->getUserInfo(); - } - - return $options; - } - - /** - * Return cURL constant for specified HTTP version - * - * @param string $requestVersion - * - * @throws \UnexpectedValueException if unsupported version requested - * - * @return int - */ - private function getProtocolVersion($requestVersion) - { - switch ($requestVersion) { - case '1.0': - return CURL_HTTP_VERSION_1_0; - case '1.1': - return CURL_HTTP_VERSION_1_1; - case '2.0': - if (defined('CURL_HTTP_VERSION_2_0')) { - return CURL_HTTP_VERSION_2_0; - } - throw new \UnexpectedValueException('libcurl 7.33 needed for HTTP 2.0 support'); - } - return CURL_HTTP_VERSION_NONE; - } - - /** - * Create headers array for CURLOPT_HTTPHEADER - * - * @param RequestInterface $request - * @param array $options cURL options - * - * @return string[] - */ - private function createHeaders(RequestInterface $request, array $options) - { - $curlHeaders = []; - $headers = array_keys($request->getHeaders()); - foreach ($headers as $name) { - if (strtolower($name) === 'content-length') { - $values = [0]; - if (array_key_exists(CURLOPT_POSTFIELDS, $options)) { - $values = [strlen($options[CURLOPT_POSTFIELDS])]; - } - } else { - $values = $request->getHeader($name); - } - foreach ($values as $value) { - $curlHeaders[] = $name . ': ' . $value; - } - } - return $curlHeaders; - } -} diff --git a/src/CurlPromise.php b/src/CurlPromise.php index 9d21d53..d69494c 100644 --- a/src/CurlPromise.php +++ b/src/CurlPromise.php @@ -1,7 +1,9 @@ */ class CurlPromise implements Promise { /** - * Shared promise core + * Shared promise core. * * @var PromiseCore */ private $core; /** - * Requests runner + * Requests runner. * * @var MultiRunner */ @@ -50,15 +51,15 @@ public function __construct(PromiseCore $core, MultiRunner $runner) * If you do not care about one of the cases, you can set the corresponding callable to null * The callback will be called when the response or exception arrived and never more than once. * - * @param callable $onFulfilled Called when a response will be available. - * @param callable $onRejected Called when an error happens. + * @param callable|null $onFulfilled Called when a response will be available + * @param callable|null $onRejected Called when an error happens. * - * You must always return the Response in the interface or throw an Exception. + * You must always return the Response in the interface or throw an Exception * * @return Promise Always returns a new promise which is resolved with value of the executed - * callback (onFulfilled / onRejected). + * callback (onFulfilled / onRejected) */ - public function then(callable $onFulfilled = null, callable $onRejected = null) + public function then(?callable $onFulfilled = null, ?callable $onRejected = null) { if ($onFulfilled) { $this->core->addOnFulfilled($onFulfilled); @@ -91,19 +92,20 @@ public function getState() * * @return \Psr\Http\Message\ResponseInterface|null Resolved value, null if $unwrap is set to false * - * @throws \Http\Client\Exception The rejection reason. + * @throws \Http\Client\Exception The rejection reason */ public function wait($unwrap = true) { $this->runner->wait($this->core); if ($unwrap) { - if ($this->core->getState() == self::REJECTED) { + if ($this->core->getState() === self::REJECTED) { throw $this->core->getException(); } return $this->core->getResponse(); } + return null; } } diff --git a/src/MultiRunner.php b/src/MultiRunner.php index 815ab32..13f43a9 100644 --- a/src/MultiRunner.php +++ b/src/MultiRunner.php @@ -1,50 +1,35 @@ */ class MultiRunner { /** - * cURL multi handle + * cURL multi handle. * * @var resource|null */ - private $multiHandle = null; - - /** - * cURL response parser - * - * @var ResponseParser - */ - private $responseParser; + private $multiHandle; /** - * Awaiting cores + * Awaiting cores. * * @var PromiseCore[] */ private $cores = []; /** - * Construct new runner. - * - * @param ResponseParser $responseParser - */ - public function __construct(ResponseParser $responseParser) - { - $this->responseParser = $responseParser; - } - - /** - * Release resources if still active + * Release resources if still active. */ public function __destruct() { @@ -54,11 +39,11 @@ public function __destruct() } /** - * Add promise to runner + * Add promise to runner. * * @param PromiseCore $core */ - public function add(PromiseCore $core) + public function add(PromiseCore $core): void { foreach ($this->cores as $existed) { if ($existed === $core) { @@ -75,16 +60,17 @@ public function add(PromiseCore $core) } /** - * Remove promise from runner + * Remove promise from runner. * * @param PromiseCore $core */ - public function remove(PromiseCore $core) + public function remove(PromiseCore $core): void { foreach ($this->cores as $index => $existed) { if ($existed === $core) { curl_multi_remove_handle($this->multiHandle, $core->getHandle()); unset($this->cores[$index]); + return; } } @@ -95,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); @@ -110,17 +96,7 @@ public function wait(PromiseCore $targetCore = null) } if (CURLE_OK === $info['result']) { - try { - $response = $this->responseParser->parse( - curl_multi_getcontent($core->getHandle()), - curl_getinfo($core->getHandle()) - ); - $core->fulfill($response); - } catch (\Exception $e) { - $core->reject( - new RequestException($e->getMessage(), $core->getRequest(), $e) - ); - } + $core->fulfill(); } else { $error = curl_error($core->getHandle()); $core->reject(new RequestException($error, $core->getRequest())); @@ -142,13 +118,14 @@ 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) { return $core; } } + return null; } } diff --git a/src/PromiseCore.php b/src/PromiseCore.php index 5420741..b58776f 100644 --- a/src/PromiseCore.php +++ b/src/PromiseCore.php @@ -1,5 +1,8 @@ */ class PromiseCore { /** - * HTTP request + * HTTP request. * * @var RequestInterface */ private $request; /** - * cURL handle + * cURL handle. * * @var resource */ private $handle; /** - * Promise state + * Response builder. + * + * @var ResponseBuilder + */ + private $responseBuilder; + + /** + * Promise state. * * @var string */ private $state; /** - * Exception + * Exception. * * @var Exception|null */ @@ -57,26 +66,50 @@ class PromiseCore */ private $onRejected = []; - /** - * Received response - * - * @var ResponseInterface|null - */ - private $response = null; - /** * Create shared core. * - * @param RequestInterface $request HTTP request - * @param resource $handle cURL handle - */ - public function __construct(RequestInterface $request, $handle) - { - assert('is_resource($handle)'); - assert('get_resource_type($handle) === "curl"'); + * @param RequestInterface $request HTTP request. + * @param resource|\CurlHandle $handle cURL handle. + * @param ResponseBuilder $responseBuilder Response builder. + * + * @throws \InvalidArgumentException If $handle is not a cURL resource. + */ + public function __construct( + RequestInterface $request, + $handle, + ResponseBuilder $responseBuilder + ) { + if (PHP_MAJOR_VERSION === 7) { + if (!is_resource($handle)) { + throw new \InvalidArgumentException( + sprintf( + 'Parameter $handle expected to be a cURL resource, %s given', + gettype($handle) + ) + ); + } elseif (get_resource_type($handle) !== 'curl') { + throw new \InvalidArgumentException( + sprintf( + 'Parameter $handle expected to be a cURL resource, %s resource given', + get_resource_type($handle) + ) + ); + } + } + + if (PHP_MAJOR_VERSION > 7 && !$handle instanceof \CurlHandle) { + throw new \InvalidArgumentException( + sprintf( + 'Parameter $handle expected to be a cURL resource, %s given', + get_debug_type($handle) + ) + ); + } $this->request = $request; $this->handle = $handle; + $this->responseBuilder = $responseBuilder; $this->state = Promise::PENDING; } @@ -85,12 +118,15 @@ public function __construct(RequestInterface $request, $handle) * * @param callable $callback */ - public function addOnFulfilled(callable $callback) + public function addOnFulfilled(callable $callback): void { if ($this->getState() === Promise::PENDING) { $this->onFulfilled[] = $callback; } elseif ($this->getState() === Promise::FULFILLED) { - $this->response = call_user_func($callback, $this->response); + $response = call_user_func($callback, $this->responseBuilder->getResponse()); + if ($response instanceof ResponseInterface) { + $this->responseBuilder->setResponse($response); + } } } @@ -99,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; @@ -109,9 +145,9 @@ public function addOnRejected(callable $callback) } /** - * Return cURL handle + * Return cURL handle. * - * @return resource + * @return resource|\CurlHandle */ public function getHandle() { @@ -123,17 +159,17 @@ public function getHandle() * * @return string */ - public function getState() + public function getState(): string { return $this->state; } /** - * Return request + * Return request. * * @return RequestInterface */ - public function getRequest() + public function getRequest(): RequestInterface { return $this->request; } @@ -141,16 +177,11 @@ public function getRequest() /** * Return the value of the promise (fulfilled). * - * @return ResponseInterface Response Object only when the Promise is fulfilled. - * - * @throws \LogicException When the promise is not fulfilled. + * @return ResponseInterface Response object only when the Promise is fulfilled */ - public function getResponse() + public function getResponse(): ResponseInterface { - if (null === $this->response) { - throw new \LogicException('Promise is not fulfilled'); - } - return $this->response; + return $this->responseBuilder->getResponse(); } /** @@ -159,61 +190,63 @@ 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. + * @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'); } + return $this->exception; } /** * Fulfill promise. - * - * @param ResponseInterface $response Received response */ - public function fulfill(ResponseInterface $response) + public function fulfill(): void { - $this->response = $response; $this->state = Promise::FULFILLED; - $this->response = $this->call($this->onFulfilled, $this->response); + $response = $this->responseBuilder->getResponse(); + try { + $response->getBody()->seek(0); + } catch (\RuntimeException $e) { + $exception = new Exception\TransferException($e->getMessage(), $e->getCode(), $e); + $this->reject($exception); + + return; + } + + while (count($this->onFulfilled) > 0) { + $callback = array_shift($this->onFulfilled); + $response = call_user_func($callback, $response); + } + + if ($response instanceof ResponseInterface) { + $this->responseBuilder->setResponse($response); + } } /** * Reject promise. * - * @param Exception $exception Reject reason. + * @param Exception $exception Reject reason */ - public function reject(Exception $exception) + public function reject(Exception $exception): void { $this->exception = $exception; $this->state = Promise::REJECTED; - try { - $this->call($this->onRejected, $this->exception); - } catch (Exception $exception) { - $this->exception = $exception; - } - } - - /** - * Call functions. - * - * @param callable[] $callbacks on fulfill or on reject callback queue - * @param mixed $argument response or exception - * - * @return mixed response or exception - */ - private function call(array &$callbacks, $argument) - { - while (count($callbacks) > 0) { - $callback = array_shift($callbacks); - $argument = call_user_func($callback, $argument); + while (count($this->onRejected) > 0) { + $callback = array_shift($this->onRejected); + try { + $exception = call_user_func($callback, $this->exception); + $this->exception = $exception; + } catch (Exception $exception) { + $this->exception = $exception; + } } - return $argument; } } diff --git a/src/ResponseBuilder.php b/src/ResponseBuilder.php new file mode 100644 index 0000000..c7250e7 --- /dev/null +++ b/src/ResponseBuilder.php @@ -0,0 +1,24 @@ +response = $response; + } +} diff --git a/src/ResponseParser.php b/src/ResponseParser.php deleted file mode 100644 index 716ff27..0000000 --- a/src/ResponseParser.php +++ /dev/null @@ -1,75 +0,0 @@ - -*/ -class ResponseParser -{ - /** - * PSR-7 message factory - * - * @var MessageFactory - */ - private $messageFactory; - - /** - * PSR-7 stream factory - * - * @var StreamFactory - */ - private $streamFactory; - - /** - * Create new parser. - * - * @param MessageFactory $messageFactory HTTP Message factory - * @param StreamFactory $streamFactory HTTP Stream factory - */ - public function __construct(MessageFactory $messageFactory, StreamFactory $streamFactory) - { - $this->messageFactory = $messageFactory; - $this->streamFactory = $streamFactory; - } - - /** - * Parse cURL response - * - * @param string $raw raw response - * @param array $info cURL response info - * - * @return ResponseInterface - * - * @throws \InvalidArgumentException - * @throws \RuntimeException - */ - public function parse($raw, array $info) - { - $response = $this->messageFactory->createResponse(); - - $headerSize = $info['header_size']; - $rawHeaders = substr($raw, 0, $headerSize); - - $parser = new HeadersParser(); - $response = $parser->parseString($rawHeaders, $response); - - /* - * substr can return boolean value for empty string. But createStream does not support - * booleans. Converting to string. - */ - $content = (string) substr($raw, $headerSize); - $stream = $this->streamFactory->createStream($content); - $response = $response->withBody($stream); - - return $response; - } -} diff --git a/src/Tools/HeadersParser.php b/src/Tools/HeadersParser.php deleted file mode 100644 index a33b941..0000000 --- a/src/Tools/HeadersParser.php +++ /dev/null @@ -1,88 +0,0 @@ - 2 ? $parts[2] : ''; - /** @var ResponseInterface $response */ - $response = $response - ->withStatus((int) $parts[1], $reasonPhrase) - ->withProtocolVersion(substr($parts[0], 5)); - - foreach ($headers as $headerLine) { - $headerLine = trim($headerLine); - if ('' === $headerLine) { - continue; - } - - $parts = explode(':', $headerLine, 2); - if (count($parts) !== 2) { - throw new \RuntimeException( - sprintf('"%s" is not a valid HTTP header line', $headerLine) - ); - } - $name = trim(urldecode($parts[0])); - $value = trim(urldecode($parts[1])); - if ($response->hasHeader($name)) { - $response = $response->withAddedHeader($name, $value); - } else { - $response = $response->withHeader($name, $value); - } - } - - return $response; - } - - /** - * Parse headers and write them to response object. - * - * @param string $headers Response headers as single string. - * @param ResponseInterface $response Response to write headers to. - * - * @return ResponseInterface - * - * @throws \InvalidArgumentException if $headers is not a string on object with __toString() - * @throws \RuntimeException - */ - public function parseString($headers, ResponseInterface $response) - { - if (!(is_string($headers) - || (is_object($headers) && method_exists($headers, '__toString'))) - ) { - throw new \InvalidArgumentException( - sprintf( - '%s expects parameter 1 to be a string, %s given', - __METHOD__, - is_object($headers) ? get_class($headers) : gettype($headers) - ) - ); - } - return $this->parseArray(explode("\r\n", $headers), $response); - } -} diff --git a/tests/BaseUnitTestCase.php b/tests/BaseUnitTestCase.php deleted file mode 100644 index 6768763..0000000 --- a/tests/BaseUnitTestCase.php +++ /dev/null @@ -1,72 +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/CurlHttpAsyncClientDiactorosTest.php b/tests/CurlHttpAsyncClientDiactorosTest.php deleted file mode 100644 index 4bbe35c..0000000 --- a/tests/CurlHttpAsyncClientDiactorosTest.php +++ /dev/null @@ -1,23 +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'); + $buffer = str_repeat('x', 1024); + for ($i = 0; $i < 2048; ++$i) { + fwrite($fd, $buffer); + } + fclose($fd); + $body = $this->createFileStream($filename); + + $request = self::$messageFactory->createRequest( + 'POST', + PHPUnitUtility::getUri(), + ['content-length' => 1024 * 2048], + $body + ); + + $response = $this->httpAdapter->sendRequest($request); + $this->assertResponse( + $response, + [ + 'body' => 'Ok', + ] + ); + + $request = $this->getRequest(); + self::assertArrayHasKey('HTTP_CONTENT_LENGTH', $request['SERVER']); + self::assertEquals($body->getSize(), $request['SERVER']['HTTP_CONTENT_LENGTH']); + } + + /** + * {@inheritdoc} + * + * @dataProvider requestProvider + */ + public function testSendRequest($httpMethod, $uri, array $httpHeaders, $requestBody): void + { + 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 + ); + } + + /** + * {@inheritdoc} + * + * @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 string Filename + */ + protected function createTempFile(): string + { + $filename = tempnam(sys_get_temp_dir(), 'tests'); + $this->tmpFiles[] = $filename; + + return $filename; + } + + /** + * Delete files created with createTempFile + */ + protected function tearDown(): void + { + parent::tearDown(); + + foreach ($this->tmpFiles as $filename) { + @unlink($filename); + } + } +} diff --git a/tests/PromiseCoreTest.php b/tests/PromiseCoreTest.php deleted file mode 100644 index ecc67d3..0000000 --- a/tests/PromiseCoreTest.php +++ /dev/null @@ -1,99 +0,0 @@ -createRequest('GET', '/'); - $this->handle = curl_init(); - - $core = new PromiseCore($request, $this->handle); - static::assertSame($request, $core->getRequest()); - static::assertSame($this->handle, $core->getHandle()); - - $core->addOnFulfilled( - function (ResponseInterface $response) { - return $response->withAddedHeader('X-Test', 'foo'); - } - ); - - $core->fulfill($this->createResponse()); - 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); - $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()); - } - - /** - * @expectedException \LogicException - */ - public function testNotFulfilled() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - $core = new PromiseCore($request, $this->handle); - $core->getResponse(); - } - - - /** - * @expectedException \LogicException - */ - public function testNotRejected() - { - $request = $this->createRequest('GET', '/'); - $this->handle = curl_init(); - $core = new PromiseCore($request, $this->handle); - $core->getException(); - } -} diff --git a/tests/Tools/HeadersParserTest.php b/tests/Tools/HeadersParserTest.php deleted file mode 100644 index efd1e08..0000000 --- a/tests/Tools/HeadersParserTest.php +++ /dev/null @@ -1,66 +0,0 @@ -createResponse(); - $parser = new HeadersParser(); - $response = $parser->parseString($headers, $response); - static::assertEquals(200, $response->getStatusCode()); - static::assertEquals('OK', $response->getReasonPhrase()); - static::assertEquals('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); - static::assertEquals(['foo=1234', 'bar=4321'], $response->getHeader('Set-Cookie')); - } - - /** - * Test parsing headers with invalid status line - * - * @expectedException \RuntimeException - * @expectedExceptionMessage "HTTP/1.1" is not a valid HTTP status line - */ - public function testInvalidStatusLine() - { - $headers = file_get_contents(__DIR__ . '/data/headers_invalid_status.http'); - $response = MessageFactoryDiscovery::find()->createResponse(); - $parser = new HeadersParser(); - $parser->parseString($headers, $response); - } - - /** - * Test parsing headers with invalid header line - * - * @expectedException \RuntimeException - * @expectedExceptionMessage "Content-Type text/html" is not a valid HTTP header line - */ - public function testInvalidHeaderLine() - { - $headers = file_get_contents(__DIR__ . '/data/headers_invalid_header.http'); - $response = MessageFactoryDiscovery::find()->createResponse(); - $parser = new HeadersParser(); - $parser->parseString($headers, $response); - } - - - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage HeadersParser::parseString expects parameter 1 to be a string, array given - */ - public function testInvalidArgument() - { - $response = MessageFactoryDiscovery::find()->createResponse(); - $parser = new HeadersParser(); - $parser->parseString([], $response); - } -} diff --git a/tests/Tools/data/headers_invalid_header.http b/tests/Tools/data/headers_invalid_header.http deleted file mode 100644 index cbfa1e8..0000000 --- a/tests/Tools/data/headers_invalid_header.http +++ /dev/null @@ -1,2 +0,0 @@ -HTTP/1.1 200 OK -Content-Type text/html diff --git a/tests/Tools/data/headers_invalid_status.http b/tests/Tools/data/headers_invalid_status.http deleted file mode 100644 index 4bea2bb..0000000 --- a/tests/Tools/data/headers_invalid_status.http +++ /dev/null @@ -1,4 +0,0 @@ -HTTP/1.1 -Content-Type: text/html; charset=UTF-8 -Set-Cookie: foo=1234 -Set-Cookie: bar=4321 diff --git a/tests/Tools/data/headers_valid.http b/tests/Tools/data/headers_valid.http deleted file mode 100644 index b3b5536..0000000 --- a/tests/Tools/data/headers_valid.http +++ /dev/null @@ -1,4 +0,0 @@ -HTTP/1.1 200 OK -Content-Type: text/html; charset=UTF-8 -Set-Cookie: foo=1234 -Set-Cookie: bar=4321 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(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..21696b8 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,10 @@ +addClassMap( + [ + 'Puli\\GeneratedPuliFactory' => __DIR__.'/../.puli/GeneratedPuliFactory.php', + ] +);